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/project.json b/apps/policy-engine/project.json index 70f6ce085..70b1d6be8 100644 --- a/apps/policy-engine/project.json +++ b/apps/policy-engine/project.json @@ -6,13 +6,19 @@ "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "target": "node", "compiler": "tsc", "outputPath": "dist/apps/policy-engine", "main": "apps/policy-engine/src/main.ts", + "assets": [ + "apps/policy-engine/src/open-policy-agent/resource", + "apps/policy-engine/src/open-policy-agent/core/rego" + ], "tsConfig": "apps/policy-engine/tsconfig.app.json", "isolatedConfig": true, "webpackConfig": "apps/policy-engine/webpack.config.js" @@ -39,9 +45,13 @@ }, "lint": { "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], + "outputs": [ + "{options.outputFile}" + ], "options": { - "lintFilePatterns": ["apps/policy-engine/**/*.ts"] + "lintFilePatterns": [ + "apps/policy-engine/**/*.ts" + ] } }, "test:type": { @@ -52,7 +62,9 @@ }, "test:unit": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], "options": { "jestConfig": "apps/policy-engine/jest.unit.ts", "verbose": true @@ -60,7 +72,9 @@ }, "test:integration": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], "options": { "jestConfig": "apps/policy-engine/jest.integration.ts", "verbose": true, @@ -69,7 +83,9 @@ }, "test:e2e": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], "options": { "jestConfig": "apps/policy-engine/jest.e2e.ts", "verbose": true, diff --git a/apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts b/apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts new file mode 100644 index 000000000..9a98af7ba --- /dev/null +++ b/apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts @@ -0,0 +1,143 @@ +import { EncryptionModuleOptionProvider } from '@narval/encryption-module' +import { HttpStatus, INestApplication } from '@nestjs/common' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +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 { 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 { EngineModule } from '../../engine.module' +import { TenantRepository } from '../../persistence/repository/tenant.repository' + +describe('Tenant', () => { + let app: INestApplication + let module: TestingModule + let testPrismaService: TestPrismaService + let tenantRepository: TenantRepository + let engineService: EngineService + let configService: ConfigService + + const adminApiKey = 'test-admin-api-key' + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + EngineModule + ] + }) + .overrideProvider(KeyValueRepository) + .useValue(new InMemoryKeyValueRepository()) + .overrideProvider(EncryptionModuleOptionProvider) + .useValue({ + keyring: getTestRawAesKeyring() + }) + .compile() + + app = module.createNestApplication() + + engineService = module.get(EngineService) + tenantRepository = module.get(TenantRepository) + testPrismaService = module.get(TestPrismaService) + configService = module.get>(ConfigService) + + await testPrismaService.truncateAll() + + await engineService.save({ + id: configService.get('engine.id', { infer: true }), + masterKey: 'unsafe-test-master-key', + adminApiKey + }) + + await app.init() + }) + + afterAll(async () => { + await testPrismaService.truncateAll() + await module.close() + 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 + } + + it('creates a new tenant', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(payload) + const actualTenant = await tenantRepository.findByClientId(clientId) + + expect(body).toMatchObject({ + clientId, + clientSecret: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + dataStore: { + policy: { + ...dataStoreConfiguration, + keys: [] + }, + entity: { + ...dataStoreConfiguration, + keys: [] + } + } + }) + expect(body).toEqual({ + ...actualTenant, + createdAt: actualTenant?.createdAt.toISOString(), + updatedAt: actualTenant?.updatedAt.toISOString() + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + + it('responds with an error when clientId already exist', async () => { + await request(app.getHttpServer()).post('/tenants').set(REQUEST_HEADER_API_KEY, adminApiKey).send(payload) + + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(payload) + + expect(body).toEqual({ + message: 'Tenant already exist', + statusCode: HttpStatus.BAD_REQUEST + }) + expect(status).toEqual(HttpStatus.BAD_REQUEST) + }) + + it('responds with forbidden when admin api key is invalid', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, 'invalid-api-key') + .send(payload) + + expect(body).toMatchObject({ + message: 'Forbidden resource', + statusCode: HttpStatus.FORBIDDEN + }) + expect(status).toEqual(HttpStatus.FORBIDDEN) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/app.controller.ts b/apps/policy-engine/src/engine/app.controller.ts index d761244fc..934f3cb51 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 { generateInboundEvaluationRequest } from '../shared/testing/evaluation.testing' -import { AppService } from './app.service' +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() { @@ -31,34 +31,25 @@ 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 generateInboundEvaluationRequest() - 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 } } diff --git a/apps/policy-engine/src/engine/core/exception/data-store.exception.ts b/apps/policy-engine/src/engine/core/exception/data-store.exception.ts new file mode 100644 index 000000000..0d5dd65f9 --- /dev/null +++ b/apps/policy-engine/src/engine/core/exception/data-store.exception.ts @@ -0,0 +1,3 @@ +import { ApplicationException } from '../../../shared/exception/application.exception' + +export class DataStoreException extends ApplicationException {} diff --git a/apps/policy-engine/src/engine/core/factory/data-store-repository.factory.ts b/apps/policy-engine/src/engine/core/factory/data-store-repository.factory.ts new file mode 100644 index 000000000..7330b7739 --- /dev/null +++ b/apps/policy-engine/src/engine/core/factory/data-store-repository.factory.ts @@ -0,0 +1,33 @@ +import { HttpStatus, Injectable } from '@nestjs/common' +import { FileSystemDataStoreRepository } from '../../persistence/repository/file-system-data-store.repository' +import { HttpDataStoreRepository } from '../../persistence/repository/http-data-store.repository' +import { DataStoreException } from '../exception/data-store.exception' +import { DataStoreRepository } from '../repository/data-store.repository' + +@Injectable() +export class DataStoreRepositoryFactory { + constructor( + private fileSystemRepository: FileSystemDataStoreRepository, + private httpRepository: HttpDataStoreRepository + ) {} + + getRepository(url: string): DataStoreRepository { + switch (this.getProtocol(url)) { + case 'file': + return this.fileSystemRepository + case 'http': + case 'https': + return this.httpRepository + default: + throw new DataStoreException({ + message: 'Data store URL protocol not supported', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { url } + }) + } + } + + private getProtocol(url: string): string { + return url.split(':')[0] + } +} diff --git a/apps/policy-engine/src/engine/core/repository/data-store.repository.ts b/apps/policy-engine/src/engine/core/repository/data-store.repository.ts new file mode 100644 index 000000000..a34e57910 --- /dev/null +++ b/apps/policy-engine/src/engine/core/repository/data-store.repository.ts @@ -0,0 +1,3 @@ +export interface DataStoreRepository { + fetch(url: string): Promise +} diff --git a/apps/policy-engine/src/engine/core/service/__test__/integration/data-store.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/integration/data-store.service.spec.ts new file mode 100644 index 000000000..841ad1285 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/__test__/integration/data-store.service.spec.ts @@ -0,0 +1,100 @@ +import { + Action, + Criterion, + EntityData, + EntitySignature, + FIXTURE, + PolicyData, + PolicySignature, + Then +} from '@narval/policy-engine-shared' +import { HttpModule } from '@nestjs/axios' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import nock from 'nock' +import { withTempJsonFile } from '../../../../../shared/testing/with-temp-json-file.testing' +import { FileSystemDataStoreRepository } from '../../../../persistence/repository/file-system-data-store.repository' +import { HttpDataStoreRepository } from '../../../../persistence/repository/http-data-store.repository' +import { DataStoreRepositoryFactory } from '../../../factory/data-store-repository.factory' +import { DataStoreService } from '../../data-store.service' + +describe(DataStoreService.name, () => { + let service: DataStoreService + + const remoteDataStoreUrl = 'http://9.9.9.9:9000' + + const entityData: EntityData = { + entity: { + data: FIXTURE.ENTITIES + } + } + + const policyData: PolicyData = { + policy: { + data: [ + { + then: Then.PERMIT, + name: 'test-policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ] + } + } + + const signatureStore: EntitySignature & PolicySignature = { + entity: { + signature: 'test-entity-signature' + }, + policy: { + signature: 'test-policy-signature' + } + } + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [HttpModule], + providers: [DataStoreService, DataStoreRepositoryFactory, HttpDataStoreRepository, FileSystemDataStoreRepository] + }).compile() + + service = module.get(DataStoreService) + }) + + describe('fetch', () => { + it('fetches data and signature from distinct stores', async () => { + nock(remoteDataStoreUrl).get('/entity').reply(HttpStatus.OK, entityData) + nock(remoteDataStoreUrl).get('/policy').reply(HttpStatus.OK, policyData) + + await withTempJsonFile(JSON.stringify(signatureStore), async (path) => { + const url = `file://${path}` + const store = { + entity: { + dataUrl: `${remoteDataStoreUrl}/entity`, + signatureUrl: url, + keys: [] + }, + policy: { + dataUrl: `${remoteDataStoreUrl}/policy`, + signatureUrl: url, + keys: [] + } + } + + const { entity, policy } = await service.fetch(store) + + expect(entity).toEqual({ + data: entityData.entity.data, + signature: signatureStore.entity.signature + }) + expect(policy).toEqual({ + data: policyData.policy.data, + signature: signatureStore.policy.signature + }) + }) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts new file mode 100644 index 000000000..f397e42dc --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts @@ -0,0 +1,84 @@ +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { MockProxy, mock } from 'jest-mock-extended' +import { EngineService } from '../../../../../engine/core/service/engine.service' +import { EngineRepository } from '../../../../../engine/persistence/repository/engine.repository' +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 { BootstrapService } from '../../bootstrap.service' +import { TenantService } from '../../tenant.service' + +describe(BootstrapService.name, () => { + let bootstrapService: BootstrapService + let tenantServiceMock: MockProxy + + const dataStore = { + entity: { + dataUrl: 'http://9.9.9.9:90', + signatureUrl: 'http://9.9.9.9:90', + keys: [] + }, + policy: { + dataUrl: 'http://9.9.9.9:90', + signatureUrl: 'http://9.9.9.9:90', + keys: [] + } + } + + const tenantOne = { + dataStore, + clientId: 'test-tenant-one-id', + clientSecret: 'unsafe-client-secret', + createdAt: new Date(), + updatedAt: new Date() + } + + const tenantTwo = { + dataStore, + clientId: 'test-tenant-two-id', + clientSecret: 'unsafe-client-secret', + createdAt: new Date(), + updatedAt: new Date() + } + + beforeEach(async () => { + tenantServiceMock = mock() + tenantServiceMock.findAll.mockResolvedValue([tenantOne, tenantTwo]) + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }) + ], + providers: [ + BootstrapService, + EngineService, + EngineRepository, + KeyValueService, + { + provide: KeyValueRepository, + useClass: InMemoryKeyValueRepository + }, + { + provide: TenantService, + useValue: tenantServiceMock + } + ] + }).compile() + + bootstrapService = module.get(BootstrapService) + }) + + describe('boot', () => { + it('syncs tenants data stores', async () => { + await bootstrapService.boot() + + expect(tenantServiceMock.syncDataStore).toHaveBeenNthCalledWith(1, tenantOne.clientId) + expect(tenantServiceMock.syncDataStore).toHaveBeenNthCalledWith(2, tenantTwo.clientId) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/core/service/__test__/unit/tenant.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/unit/tenant.service.spec.ts new file mode 100644 index 000000000..d22d93f6e --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/__test__/unit/tenant.service.spec.ts @@ -0,0 +1,100 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { DataStoreConfiguration, FIXTURE } from '@narval/policy-engine-shared' +import { Test } from '@nestjs/testing' +import { MockProxy, mock } from 'jest-mock-extended' +import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' +import { EncryptKeyValueService } from '../../../../../shared/module/key-value/core/service/encrypt-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 { Tenant } from '../../../../../shared/type/domain.type' +import { TenantRepository } from '../../../../persistence/repository/tenant.repository' +import { DataStoreService } from '../../data-store.service' +import { TenantService } from '../../tenant.service' + +describe(TenantService.name, () => { + let tenantService: TenantService + let tenantRepository: TenantRepository + let dataStoreServiceMock: MockProxy + + const clientId = 'test-client-id' + + const dataStoreConfiguration: DataStoreConfiguration = { + dataUrl: 'a-url-that-doesnt-need-to-exist-for-the-purpose-of-this-test', + signatureUrl: 'a-url-that-doesnt-need-to-exist-for-the-purpose-of-this-test', + keys: [] + } + + const tenant: Tenant = { + clientId, + clientSecret: 'test-client-secret', + dataStore: { + entity: dataStoreConfiguration, + policy: dataStoreConfiguration + }, + createdAt: new Date(), + updatedAt: new Date() + } + + const stores = { + entity: { + data: FIXTURE.ENTITIES, + signature: 'test-signature' + }, + policy: { + data: FIXTURE.POLICIES, + signature: 'test-signature' + } + } + + beforeEach(async () => { + dataStoreServiceMock = mock() + dataStoreServiceMock.fetch.mockResolvedValue(stores) + + const module = await Test.createTestingModule({ + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], + providers: [ + TenantService, + TenantRepository, + EncryptKeyValueService, + { + provide: DataStoreService, + useValue: dataStoreServiceMock + }, + { + provide: KeyValueRepository, + useClass: InMemoryKeyValueRepository + } + ] + }).compile() + + tenantService = module.get(TenantService) + tenantRepository = module.get(TenantRepository) + }) + + describe('syncDataStore', () => { + beforeEach(async () => { + await tenantRepository.save(tenant) + }) + + it('saves entity and policy stores', async () => { + expect(await tenantRepository.findEntityStore(clientId)).toEqual(null) + expect(await tenantRepository.findPolicyStore(clientId)).toEqual(null) + + await tenantService.syncDataStore(clientId) + + expect(await tenantRepository.findEntityStore(clientId)).toEqual(stores.entity) + expect(await tenantRepository.findPolicyStore(clientId)).toEqual(stores.policy) + }) + + it('fetches the data stores once', async () => { + await tenantService.syncDataStore(clientId) + + expect(dataStoreServiceMock.fetch).toHaveBeenCalledTimes(1) + expect(dataStoreServiceMock.fetch).toHaveBeenCalledWith(tenant.dataStore) + }) + }) +}) 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..94bc03344 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/bootstrap.service.ts @@ -0,0 +1,51 @@ +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) {} + + async boot(): Promise { + this.logger.log('Start engine bootstrap') + + 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:4200/api/data-store', + signatureUrl: 'http://127.0.0.1:4200/api/data-store', + keys: [] + }, + policy: { + dataUrl: 'http://127.0.0.1:4200/api/data-store', + signatureUrl: 'http://127.0.0.1:4200/api/data-store', + keys: [] + } + }, + createdAt: new Date(), + updatedAt: new Date() + }) + } + + 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/engine/core/service/data-store.service.ts b/apps/policy-engine/src/engine/core/service/data-store.service.ts new file mode 100644 index 000000000..3f5832875 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/data-store.service.ts @@ -0,0 +1,68 @@ +import { + DataStoreConfiguration, + EntityStore, + PolicyStore, + entityDataSchema, + entitySignatureSchema, + policyDataSchema, + policySignatureSchema +} from '@narval/policy-engine-shared' +import { HttpStatus, Injectable } from '@nestjs/common' +import { ZodObject, z } from 'zod' +import { DataStoreException } from '../exception/data-store.exception' +import { DataStoreRepositoryFactory } from '../factory/data-store-repository.factory' + +@Injectable() +export class DataStoreService { + constructor(private dataStoreRepositoryFactory: DataStoreRepositoryFactory) {} + + async fetch(store: { entity: DataStoreConfiguration; policy: DataStoreConfiguration }): Promise<{ + entity: EntityStore + policy: PolicyStore + }> { + const [entityData, entitySignature, policyData, policySignature] = await Promise.all([ + this.fetchByUrl(store.entity.dataUrl, entityDataSchema), + this.fetchByUrl(store.entity.signatureUrl, entitySignatureSchema), + this.fetchByUrl(store.policy.dataUrl, policyDataSchema), + this.fetchByUrl(store.policy.signatureUrl, policySignatureSchema) + ]) + + return { + entity: { + data: entityData.entity.data, + signature: entitySignature.entity.signature + }, + policy: { + data: policyData.policy.data, + signature: policySignature.policy.signature + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async fetchByUrl>( + url: string, + schema: DataSchema + ): Promise> { + const data = await this.dataStoreRepositoryFactory.getRepository(url).fetch(url) + const result = schema.safeParse(data) + + if (result.success) { + return result.data + } + + throw new DataStoreException({ + message: 'Invalid store schema', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { + ...(schema.description ? { schema: schema.description } : {}), + url, + errors: result.error.errors.map(({ path, message, code }) => ({ + path, + code, + message + })) + } + }) + } +} diff --git a/apps/policy-engine/src/engine/core/service/evaluation.service.ts b/apps/policy-engine/src/engine/core/service/evaluation.service.ts index 775fa4587..8213be1f5 100644 --- a/apps/policy-engine/src/engine/core/service/evaluation.service.ts +++ b/apps/policy-engine/src/engine/core/service/evaluation.service.ts @@ -1,12 +1,18 @@ 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/core/service/tenant.service' +import { TenantService } from './tenant.service' @Injectable() export class EvaluationService { - constructor(private tenantService: TenantService) {} + constructor( + private configService: ConfigService, + private tenantService: TenantService + ) {} async evaluate(clientId: string, evaluation: EvaluationRequest): Promise { const [entityStore, policyStore] = await Promise.all([ @@ -32,7 +38,11 @@ export class EvaluationService { // WARN: Loading a new engine is an IO bounded process due to the Rego // transpilation and WASM build. - const engine = await new OpenPolicyAgentEngine(policyStore.data, entityStore.data).load() + const engine = await new OpenPolicyAgentEngine({ + entities: entityStore.data, + policies: policyStore.data, + resourcePath: resolve(this.configService.get('resourcePath', { infer: true })) + }).load() return engine.evaluate(evaluation) } diff --git a/apps/policy-engine/src/engine/core/service/tenant.service.ts b/apps/policy-engine/src/engine/core/service/tenant.service.ts new file mode 100644 index 000000000..7dcf38027 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/tenant.service.ts @@ -0,0 +1,101 @@ +import { EntityStore, PolicyStore } from '@narval/policy-engine-shared' +import { HttpStatus, Injectable, Logger } from '@nestjs/common' +import { ApplicationException } from '../../../shared/exception/application.exception' +import { Tenant } from '../../../shared/type/domain.type' +import { TenantRepository } from '../../persistence/repository/tenant.repository' +import { DataStoreService } from './data-store.service' + +@Injectable() +export class TenantService { + private logger = new Logger(TenantService.name) + + constructor( + private tenantRepository: TenantRepository, + private dataStoreService: DataStoreService + ) {} + + async findByClientId(clientId: string): Promise { + return this.tenantRepository.findByClientId(clientId) + } + + async onboard(tenant: Tenant, options?: { syncAfter?: boolean }): Promise { + const syncAfter = options?.syncAfter ?? true + + const exists = await this.tenantRepository.findByClientId(tenant.clientId) + + if (exists) { + throw new ApplicationException({ + message: 'Tenant already exist', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, + context: { clientId: tenant.clientId } + }) + } + + try { + await this.tenantRepository.save(tenant) + + if (syncAfter) { + const hasSynced = await this.syncDataStore(tenant.clientId) + + if (!hasSynced) { + this.logger.warn('Failed to sync new tenant data store during the onboard') + } + } + + return tenant + } catch (error) { + throw new ApplicationException({ + message: 'Failed to onboard new tenant', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + origin: error, + context: { tenant } + }) + } + } + + async syncDataStore(clientId: string): Promise { + this.logger.log('Start syncing tenant data stores', { clientId }) + + try { + const tenant = await this.findByClientId(clientId) + + if (tenant) { + this.logger.log('Sync tenant data stores', { + dataStore: tenant.dataStore + }) + + const stores = await this.dataStoreService.fetch(tenant.dataStore) + + await Promise.all([ + this.tenantRepository.saveEntityStore(clientId, stores.entity), + this.tenantRepository.savePolicyStore(clientId, stores.policy) + ]) + + this.logger.log('Tenant data stores synced', { clientId, stores }) + + return true + } + + return false + } catch (error) { + this.logger.error('Failed to sync tenant data store', { + message: error.message, + stack: error.stack + }) + + return false + } + } + + async findEntityStore(clientId: string): Promise { + return this.tenantRepository.findEntityStore(clientId) + } + + async findPolicyStore(clientId: string): Promise { + return this.tenantRepository.findPolicyStore(clientId) + } + + async findAll(): Promise { + return this.tenantRepository.findAll() + } +} diff --git a/apps/policy-engine/src/engine/engine.module.ts b/apps/policy-engine/src/engine/engine.module.ts index b0f77ed30..afdef8b5a 100644 --- a/apps/policy-engine/src/engine/engine.module.ts +++ b/apps/policy-engine/src/engine/engine.module.ts @@ -1,19 +1,29 @@ import { EncryptionModule } from '@narval/encryption-module' import { HttpModule } from '@nestjs/axios' -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 { 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 { TenantService } from './core/service/tenant.service' +import { TenantController } from './http/rest/controller/tenant.controller' import { OpaService } from './opa/opa.service' 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,8 +39,9 @@ import { EntityRepository } from './persistence/repository/entity.repository' useClass: EncryptionModuleOptionFactory }) ], - controllers: [AppController], + controllers: [AppController, TenantController], providers: [ + AdminApiKeyGuard, AppService, EngineRepository, EngineService, @@ -38,6 +49,14 @@ import { EntityRepository } from './persistence/repository/entity.repository' OpaService, ProvisionService, SigningService, + BootstrapService, + DataStoreRepositoryFactory, + DataStoreService, + FileSystemDataStoreRepository, + HttpDataStoreRepository, + TenantRepository, + TenantService, + EvaluationService, { provide: APP_PIPE, useClass: ValidationPipe @@ -45,4 +64,10 @@ import { EntityRepository } from './persistence/repository/entity.repository' ], exports: [EngineService, ProvisionService] }) -export class EngineModule {} +export class EngineModule implements OnApplicationBootstrap { + constructor(private bootstrapService: BootstrapService) {} + + async onApplicationBootstrap() { + await this.bootstrapService.boot() + } +} diff --git a/apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts new file mode 100644 index 000000000..dc96ba555 --- /dev/null +++ b/apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { randomBytes } from 'crypto' +import { v4 as uuid } from 'uuid' +import { AdminApiKeyGuard } from '../../../../shared/guard/admin-api-key.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() + async create(@Body() body: CreateTenantDto) { + const now = new Date() + + const tenant = await this.tenantService.onboard({ + clientId: body.clientId || uuid(), + clientSecret: randomBytes(42).toString('hex'), + dataStore: { + entity: { + ...body.entityDataStore, + keys: [] + }, + policy: { + ...body.policyDataStore, + keys: [] + } + }, + createdAt: now, + updatedAt: now + }) + + return tenant + } +} diff --git a/apps/policy-engine/src/engine/http/rest/dto/create-tenant.dto.ts b/apps/policy-engine/src/engine/http/rest/dto/create-tenant.dto.ts new file mode 100644 index 000000000..e674b473c --- /dev/null +++ b/apps/policy-engine/src/engine/http/rest/dto/create-tenant.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsDefined, IsString } from 'class-validator' + +class DataStoreConfigurationDto { + dataUrl: string + signatureUrl: string +} + +export class CreateTenantDto { + @IsString() + @ApiPropertyOptional() + clientId?: string + + @IsDefined() + @Type(() => DataStoreConfigurationDto) + @ApiProperty() + entityDataStore: DataStoreConfigurationDto + + @IsDefined() + @Type(() => DataStoreConfigurationDto) + @ApiProperty() + policyDataStore: DataStoreConfigurationDto +} diff --git a/apps/policy-engine/src/engine/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 new file mode 100644 index 000000000..b54e2b023 --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts @@ -0,0 +1,45 @@ +import { EntityData, FIXTURE } from '@narval/policy-engine-shared' +import { Test } from '@nestjs/testing' +import { withTempJsonFile } from '../../../../../shared/testing/with-temp-json-file.testing' +import { DataStoreException } from '../../../../core/exception/data-store.exception' +import { FileSystemDataStoreRepository } from '../../file-system-data-store.repository' + +describe(FileSystemDataStoreRepository.name, () => { + let repository: FileSystemDataStoreRepository + + const entityData: EntityData = { + entity: { + data: FIXTURE.ENTITIES + } + } + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [FileSystemDataStoreRepository] + }).compile() + + repository = module.get(FileSystemDataStoreRepository) + }) + + describe('fetch', () => { + it('fetches data from a data source in the local file system', async () => { + await withTempJsonFile(JSON.stringify(entityData), async (path) => { + const data = await repository.fetch(`file://${path}`) + + expect(data).toEqual(entityData) + }) + }) + + it('throws a DataStoreException when file does not exist', async () => { + const notFoundDataStoreUrl = 'file://./this-file-does-not-exist-in-the-file-system.json' + + await expect(() => repository.fetch(notFoundDataStoreUrl)).rejects.toThrow(DataStoreException) + }) + + it('throws a DataStoreException when the json is invalid', async () => { + await withTempJsonFile('[ invalid }', async (path: string) => { + await expect(() => repository.fetch(`file://${path}`)).rejects.toThrow(DataStoreException) + }) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/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 new file mode 100644 index 000000000..4c388e156 --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/__test__/integration/http-data-store.repository.spec.ts @@ -0,0 +1,46 @@ +import { EntityData, FIXTURE } from '@narval/policy-engine-shared' +import { HttpModule } from '@nestjs/axios' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import nock from 'nock' +import { DataStoreException } from '../../../../core/exception/data-store.exception' +import { HttpDataStoreRepository } from '../../http-data-store.repository' + +describe(HttpDataStoreRepository.name, () => { + let repository: HttpDataStoreRepository + + const dataStoreHost = 'http://some.host:3010' + const dataStoreEndpoint = '/data-store/entities' + const dataStoreUrl = dataStoreHost + dataStoreEndpoint + + const entityData: EntityData = { + entity: { + data: FIXTURE.ENTITIES + } + } + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [HttpModule], + providers: [HttpDataStoreRepository] + }).compile() + + repository = module.get(HttpDataStoreRepository) + }) + + describe('fetch', () => { + it('fetches data from a remote data source via http protocol', async () => { + nock(dataStoreHost).get(dataStoreEndpoint).reply(HttpStatus.OK, entityData) + + const data = await repository.fetch(dataStoreUrl) + + expect(data).toEqual(entityData) + }) + + it('throws a DataStoreException when it fails to fetch', async () => { + nock(dataStoreHost).get(dataStoreEndpoint).reply(HttpStatus.INTERNAL_SERVER_ERROR, {}) + + await expect(() => repository.fetch(dataStoreUrl)).rejects.toThrow(DataStoreException) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/persistence/repository/__test__/unit/tenant.repository.spec.ts b/apps/policy-engine/src/engine/persistence/repository/__test__/unit/tenant.repository.spec.ts new file mode 100644 index 000000000..c778b9b37 --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/__test__/unit/tenant.repository.spec.ts @@ -0,0 +1,140 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { + Action, + Criterion, + DataStoreConfiguration, + EntityStore, + FIXTURE, + PolicyStore, + Then +} from '@narval/policy-engine-shared' +import { Test } from '@nestjs/testing' +import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' +import { EncryptKeyValueService } from '../../../../../shared/module/key-value/core/service/encrypt-key-value.service' +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 { Tenant } from '../../../../../shared/type/domain.type' +import { TenantRepository } from '../../../repository/tenant.repository' + +describe(TenantRepository.name, () => { + let repository: TenantRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + const clientId = 'test-client-id' + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], + providers: [ + KeyValueService, + TenantRepository, + EncryptKeyValueService, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + repository = module.get(TenantRepository) + }) + + describe('save', () => { + const now = new Date() + + const dataStoreConfiguration: DataStoreConfiguration = { + dataUrl: 'a-url-that-doesnt-need-to-exist-for-the-purpose-of-this-test', + signatureUrl: 'a-url-that-doesnt-need-to-exist-for-the-purpose-of-this-test', + keys: [] + } + + const tenant: Tenant = { + clientId, + clientSecret: 'test-client-secret', + dataStore: { + entity: dataStoreConfiguration, + policy: dataStoreConfiguration + }, + createdAt: now, + updatedAt: now + } + + it('saves a new tenant', async () => { + await repository.save(tenant) + + const value = await inMemoryKeyValueRepository.get(repository.getKey(tenant.clientId)) + const actualTenant = await repository.findByClientId(tenant.clientId) + + expect(value).not.toEqual(null) + expect(tenant).toEqual(actualTenant) + }) + + it('indexes the new tenant', async () => { + await repository.save(tenant) + + expect(await repository.getTenantIndex()).toEqual([tenant.clientId]) + }) + }) + + describe('saveEntityStore', () => { + const store: EntityStore = { + data: FIXTURE.ENTITIES, + signature: 'test-fake-signature' + } + + it('saves the entity store', async () => { + await repository.saveEntityStore(clientId, store) + + expect(await repository.findEntityStore(clientId)).toEqual(store) + }) + + it('overwrites existing entity store', async () => { + const storeTwo = { ...store, signature: 'another-test-signature' } + + await repository.saveEntityStore(clientId, store) + await repository.saveEntityStore(clientId, storeTwo) + + expect(await repository.findEntityStore(clientId)).toEqual(storeTwo) + }) + }) + + describe('savePolicyStore', () => { + const store: PolicyStore = { + data: [ + { + then: Then.PERMIT, + name: 'test-policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ], + signature: 'test-fake-signature' + } + + it('saves the policy store', async () => { + await repository.savePolicyStore(clientId, store) + + expect(await repository.findPolicyStore(clientId)).toEqual(store) + }) + + it('overwrites existing policy store', async () => { + const storeTwo = { ...store, signature: 'another-test-signature' } + + await repository.savePolicyStore(clientId, store) + await repository.savePolicyStore(clientId, storeTwo) + + expect(await repository.findPolicyStore(clientId)).toEqual(storeTwo) + }) + }) +}) diff --git a/apps/policy-engine/src/engine/persistence/repository/file-system-data-store.repository.ts b/apps/policy-engine/src/engine/persistence/repository/file-system-data-store.repository.ts new file mode 100644 index 000000000..7dcd1e4bc --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/file-system-data-store.repository.ts @@ -0,0 +1,49 @@ +import { HttpStatus, Injectable } from '@nestjs/common' +import fs from 'fs/promises' +import { DataStoreException } from '../../core/exception/data-store.exception' +import { DataStoreRepository } from '../../core/repository/data-store.repository' + +@Injectable() +export class FileSystemDataStoreRepository implements DataStoreRepository { + async fetch(url: string): Promise { + const path = this.getPath(url) + + if (await this.exists(path)) { + return this.read(path) as Data + } + + throw new DataStoreException({ + message: 'Data store file does not exist in the instance host', + suggestedHttpStatusCode: HttpStatus.NOT_FOUND, + context: { url } + }) + } + + private async read(path: string) { + try { + const content = await fs.readFile(path, 'utf-8') + + return JSON.parse(content) + } catch (error) { + throw new DataStoreException({ + message: 'Unable to parse data store file into JSON', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + origin: error + }) + } + } + + private async exists(path: string): Promise { + try { + await fs.stat(path) + + return true + } catch (error) { + return false + } + } + + private getPath(url: string): string { + return url.replace('file://', '') + } +} diff --git a/apps/policy-engine/src/engine/persistence/repository/http-data-store.repository.ts b/apps/policy-engine/src/engine/persistence/repository/http-data-store.repository.ts new file mode 100644 index 000000000..2b5d162b3 --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/http-data-store.repository.ts @@ -0,0 +1,26 @@ +import { HttpService } from '@nestjs/axios' +import { HttpStatus, Injectable } from '@nestjs/common' +import { catchError, lastValueFrom, map } from 'rxjs' +import { DataStoreException } from '../../core/exception/data-store.exception' +import { DataStoreRepository } from '../../core/repository/data-store.repository' + +@Injectable() +export class HttpDataStoreRepository implements DataStoreRepository { + constructor(private httpService: HttpService) {} + + fetch(url: string): Promise { + return lastValueFrom( + this.httpService.get(url).pipe( + map((response) => response.data), + catchError((error) => { + throw new DataStoreException({ + message: 'Unable to fetch remote data source via HTTP', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { url }, + origin: error + }) + }) + ) + ) + } +} diff --git a/apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts b/apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts new file mode 100644 index 000000000..5f655da14 --- /dev/null +++ b/apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts @@ -0,0 +1,136 @@ +import { EntityStore, PolicyStore, entityStoreSchema, policyStoreSchema } from '@narval/policy-engine-shared' +import { Injectable } from '@nestjs/common' +import { compact } from 'lodash/fp' +import { EncryptKeyValueService } from '../../../shared/module/key-value/core/service/encrypt-key-value.service' +import { tenantIndexSchema, tenantSchema } from '../../../shared/schema/tenant.schema' +import { Tenant } from '../../../shared/type/domain.type' + +@Injectable() +export class TenantRepository { + constructor(private encryptKeyValueService: EncryptKeyValueService) {} + + async findByClientId(clientId: string): Promise { + const value = await this.encryptKeyValueService.get(this.getKey(clientId)) + + if (value) { + return this.decode(value) + } + + return null + } + + async save(tenant: Tenant): Promise { + await this.encryptKeyValueService.set(this.getKey(tenant.clientId), this.encode(tenant)) + await this.index(tenant) + + return tenant + } + + async getTenantIndex(): Promise { + const index = await this.encryptKeyValueService.get(this.getIndexKey()) + + if (index) { + return this.decodeIndex(index) + } + + return [] + } + + async saveEntityStore(clientId: string, store: EntityStore): Promise { + return this.encryptKeyValueService.set(this.getEntityStoreKey(clientId), this.encodeEntityStore(store)) + } + + async findEntityStore(clientId: string): Promise { + const value = await this.encryptKeyValueService.get(this.getEntityStoreKey(clientId)) + + if (value) { + return this.decodeEntityStore(value) + } + + return null + } + + async savePolicyStore(clientId: string, store: PolicyStore): Promise { + return this.encryptKeyValueService.set(this.getPolicyStoreKey(clientId), this.encodePolicyStore(store)) + } + + async findPolicyStore(clientId: string): Promise { + const value = await this.encryptKeyValueService.get(this.getPolicyStoreKey(clientId)) + + if (value) { + return this.decodePolicyStore(value) + } + + return null + } + + // TODO: (@wcalderipe, 07/03/24) we need to rethink this strategy. If we use a + // SQL database, this could generate a massive amount of queries; thus, + // degrading the performance. + // + // An option is to move these general queries `findBy`, findAll`, etc to the + // KeyValeuRepository implementation letting each implementation pick the best + // strategy to solve the problem (e.g. where query in SQL) + async findAll(): Promise { + const ids = await this.getTenantIndex() + const tenants = await Promise.all(ids.map((id) => this.findByClientId(id))) + + return compact(tenants) + } + + getKey(clientId: string): string { + return `tenant:${clientId}` + } + + getIndexKey(): string { + return 'tenant:index' + } + + getEntityStoreKey(clientId: string): string { + return `tenant:${clientId}:entity-store` + } + + getPolicyStoreKey(clientId: string): string { + return `tenant:${clientId}:policy-store` + } + + private async index(tenant: Tenant): Promise { + const currentIndex = await this.getTenantIndex() + + await this.encryptKeyValueService.set(this.getIndexKey(), this.encodeIndex([...currentIndex, tenant.clientId])) + + return true + } + + private encode(tenant: Tenant): string { + return EncryptKeyValueService.encode(tenantSchema.parse(tenant)) + } + + private decode(value: string): Tenant { + return tenantSchema.parse(JSON.parse(value)) + } + + private encodeIndex(value: string[]): string { + return EncryptKeyValueService.encode(tenantIndexSchema.parse(value)) + } + + private decodeIndex(value: string): string[] { + return tenantIndexSchema.parse(JSON.parse(value)) + } + + private encodeEntityStore(value: EntityStore): string { + return EncryptKeyValueService.encode(entityStoreSchema.parse(value)) + } + + private decodeEntityStore(value: string): EntityStore { + return entityStoreSchema.parse(JSON.parse(value)) + } + + private encodePolicyStore(value: PolicyStore): string { + return EncryptKeyValueService.encode(policyStoreSchema.parse(value)) + } + + private decodePolicyStore(value: string): PolicyStore { + return policyStoreSchema.parse(JSON.parse(value)) + } +} 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 index 81d64c1f4..89779bc1c 100644 --- 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 @@ -5,28 +5,64 @@ import { 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 { resolve } from 'path' +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 REGO_CORE_PATH = resolve(__dirname, '../../rego') + +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 new OpenPolicyAgentEngine().load() + engine = await OpenPolicyAgentEngine.empty(await getConfig('resourcePath')).load() }) - describe('constructor', () => { - it('starts with an empty state', () => { - expect(engine.getPolicies()).toEqual([]) - expect(engine.getEntities()).toEqual({ + describe('empty', () => { + it('starts with an empty state', async () => { + const e = OpenPolicyAgentEngine.empty(await getConfig('resourcePath')) + + expect(e.getPolicies()).toEqual([]) + expect(e.getEntities()).toEqual({ addressBook: [], credentials: [], tokens: [], @@ -93,7 +129,11 @@ describe('OpenPolicyAgentEngine', () => { } ] - const e = await new OpenPolicyAgentEngine(policies, FIXTURE.ENTITIES).load() + const e = await new OpenPolicyAgentEngine({ + policies, + entities: FIXTURE.ENTITIES, + resourcePath: await getConfig('resourcePath') + }).load() const request = { action: Action.SIGN_TRANSACTION, @@ -107,21 +147,12 @@ describe('OpenPolicyAgentEngine', () => { resourceId: FIXTURE.WALLET.Engineering.id } - const jwk = privateKeyToJwk(FIXTURE.UNSAFE_PRIVATE_KEY.Alice) - const signer = buildSignerEip191(FIXTURE.UNSAFE_PRIVATE_KEY.Alice) - - const jwt = await signJwt( - { - requestHash: hash(request), - sub: FIXTURE.USER.Alice.id - }, - jwk, - { alg: SigningAlg.EIP191 }, - signer - ) - const evaluation: EvaluationRequest = { - authentication: jwt, + authentication: await getJwt({ + privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, + sub: FIXTURE.USER.Alice.id, + request + }), request } 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 index 8b4fbc534..27c4b9e08 100644 --- 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 @@ -14,38 +14,48 @@ import { Hex, decode, hash, publicKeyToJwk, verifyJwt } from '@narval/signature' import { HttpStatus } from '@nestjs/common' import { loadPolicy } from '@open-policy-agent/opa-wasm' import { compact } from 'lodash/fp' -import { resolve } from 'path' -import { v4 } from 'uuid' +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 { build } from './util/wasm-build.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 + private opa?: OpenPolicyAgentInstance - constructor(policies?: Policy[], entities?: Entities) { - this.entities = entities || { - addressBook: [], - credentials: [], - tokens: [], - userGroupMembers: [], - userGroups: [], - userWallets: [], - users: [], - walletGroupMembers: [], - walletGroups: [], - wallets: [] - } + constructor(params: { policies: Policy[]; entities: Entities; resourcePath: string }) { + this.entities = params.entities + this.policies = params.policies + this.resourcePath = params.resourcePath + } - this.policies = policies || [] + static empty(resourcePath: string): OpenPolicyAgentEngine { + return new OpenPolicyAgentEngine({ + entities: { + addressBook: [], + credentials: [], + tokens: [], + userGroupMembers: [], + userGroups: [], + userWallets: [], + users: [], + walletGroupMembers: [], + walletGroups: [], + wallets: [] + }, + policies: [], + resourcePath: resourcePath + }) } setPolicies(policies: Policy[]): OpenPolicyAgentEngine { @@ -75,9 +85,10 @@ export class OpenPolicyAgentEngine implements Engine { async load(): Promise { try { const wasm = await build({ - path: `/tmp/armory-policy-bundle-${v4()}`, - regoCorePath: resolve(__dirname, '../core/rego'), - policies: this.getPolicies() + policies: this.getPolicies(), + path: `/tmp/armory-policy-bundle-${uuid()}`, + regoCorePath: getRegoCorePath(this.resourcePath), + regoRuleTemplatePath: getRegoRuleTemplatePath(this.resourcePath) }) this.opa = await loadPolicy(wasm, undefined, { @@ -88,6 +99,8 @@ export class OpenPolicyAgentEngine implements Engine { return this } catch (error) { + console.log(error) + throw new OpenPolicyAgentException({ message: 'Fail to load Open Policy Agent engine', suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR, 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 index a1cf12329..7f949c586 100644 --- 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 @@ -1,5 +1,4 @@ import { Action, EvaluationRequest, FIXTURE, SignTransactionAction } from '@narval/policy-engine-shared' -import { Alg } from '@narval/signature' import { InputType, decode } from '@narval/transaction-request-intent' import { generateInboundEvaluationRequest } from '../../../../../shared/testing/evaluation.testing' import { OpenPolicyAgentException } from '../../../exception/open-policy-agent.exception' @@ -45,32 +44,6 @@ describe('toInput', () => { expect(input.transfers).toEqual(evaluation.transfers) }) - it('maps the principal', () => { - const input = toInput(evaluation) - - expect(input.principal).toEqual({ - id: 'test-cred-id', - pubKey: 'test-pub-key', - address: 'test-address', - alg: Alg.ES256K, - userId: 'test-user-id' - }) - }) - - it('maps the approvals', () => { - const input = toInput(evaluation) - - expect(input.approvals).toEqual([ - { - id: 'test-cred-id', - pubKey: 'test-pub-key', - address: 'test-address', - alg: Alg.ES256K, - userId: 'test-user-id' - } - ]) - }) - it('adds the transaction request intent', () => { const input = toInput(evaluation) const intent = decode({ diff --git a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts index 05069e1e2..3f1dce4bd 100644 --- a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts @@ -10,11 +10,26 @@ import { ValueOperators, WalletAddressCriterion } from '@narval/policy-engine-shared' -import { transpile, transpileCriterion, transpileReason } from '../../rego-transpiler.util' +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' + +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) + const rules = await transpile(FIXTURE.POLICIES, await getTemplatePath()) expect(rules).toContain('permit') expect(rules).toContain('forbid') @@ -27,7 +42,6 @@ describe('transpileCriterion', () => { criterion: Criterion.CHECK_NONCE_EXISTS, args: null } - expect(transpileCriterion(item)).toEqual(Criterion.CHECK_NONCE_EXISTS) }) 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 index 984ba0299..718e9b1df 100644 --- 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 @@ -1,12 +1,35 @@ 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 { resolve } from 'path' +import { Config, load } from '../../../../../policy-engine.config' import { withTempDirectory } from '../../../../../shared/testing/with-temp-directory.testing' -import { build, buildOpaBundle, copyRegoCore, createDirectories, unzip, writeRegoPolicies } from '../../wasm-build.util' +import { getRegoRuleTemplatePath } from '../../rego-transpiler.util' +import { + build, + buildOpaBundle, + copyRegoCore, + createDirectories, + getRegoCorePath, + unzip, + writeRegoPolicies +} from '../../wasm-build.util' -const getRegoSourceCodePath = () => resolve(__dirname, '../../../rego/') +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 () => { @@ -26,7 +49,8 @@ describe('writeRegoPolicies', () => { const { file } = await writeRegoPolicies({ policies: FIXTURE.POLICIES, filename: 'policies.rego', - path + path, + regoRuleTemplatePath: await getTemplatePath() }) const content = await readFile(file, 'utf-8') @@ -45,7 +69,7 @@ describe('copyRegoCore', () => { it('copies the rego core files', async () => { await withTempDirectory(async (path) => { await copyRegoCore({ - source: getRegoSourceCodePath(), + source: await getCorePath(), destination: path }) @@ -57,7 +81,7 @@ describe('copyRegoCore', () => { it('does not copy the rego tests', async () => { await withTempDirectory(async (path) => { await copyRegoCore({ - source: getRegoSourceCodePath(), + source: await getCorePath(), destination: path }) @@ -72,7 +96,7 @@ describe('bundleOpaBundle', () => { const { regoSourceDirectory, distDirectory } = await createDirectories(path) await copyRegoCore({ - source: getRegoSourceCodePath(), + source: await getCorePath(), destination: regoSourceDirectory }) @@ -89,7 +113,7 @@ describe('unzip', () => { const { regoSourceDirectory, distDirectory } = await createDirectories(path) await copyRegoCore({ - source: getRegoSourceCodePath(), + source: await getCorePath(), destination: regoSourceDirectory }) @@ -114,7 +138,8 @@ describe('build', () => { await withTempDirectory(async (path) => { const wasm = await build({ path, - regoCorePath: getRegoSourceCodePath(), + regoCorePath: await getCorePath(), + regoRuleTemplatePath: await getTemplatePath(), policies: FIXTURE.POLICIES, cleanAfter: false }) diff --git a/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts b/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts index 88ddd5555..6405e8de8 100644 --- a/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts @@ -2,10 +2,11 @@ import { Criterion, Policy, PolicyCriterion, Then } from '@narval/policy-engine- import { readFile } from 'fs/promises' import Handlebars from 'handlebars' import { isEmpty } from 'lodash' -import { resolve } from 'path' import { v4 as uuid } from 'uuid' -const REGO_RULES_TEMPLATE_PATH = resolve(__dirname, '../../resource/rego-rules.template.hbs') +export const getRegoRuleTemplatePath = (resourcePath: string) => { + return `${resourcePath}/open-policy-agent/rego/rules.template.hbs` +} export const transpileCriterion = (item: PolicyCriterion) => { const criterion: Criterion = item.criterion @@ -62,11 +63,11 @@ export const transpileReason = (item: Policy & { id: string }) => { return `reason = ${JSON.stringify(reason)}` } -export const transpile = async (policies: Policy[]): Promise => { +export const transpile = async (policies: Policy[], templatePath: string): Promise => { Handlebars.registerHelper('criterion', transpileCriterion) Handlebars.registerHelper('reason', transpileReason) - const template = Handlebars.compile(await readFile(REGO_RULES_TEMPLATE_PATH, 'utf-8')) + const template = Handlebars.compile(await readFile(templatePath, 'utf-8')) return template({ // TODO: Here the policy must have an ID already. 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 index 91bbf1417..21908dcd6 100644 --- 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 @@ -8,12 +8,17 @@ 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 }) @@ -30,8 +35,13 @@ export const createDirectories = async (path: string) => { } } -export const writeRegoPolicies = async (option: { policies: Policy[]; filename: string; path: string }) => { - const policies = await transpile(option.policies) +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') @@ -81,7 +91,8 @@ export const build = async (option: BuildWebAssemblyOption): Promise => await writeRegoPolicies({ policies: option.policies, path: generatedRegoDirectory, - filename: 'policies.rego' + filename: 'policies.rego', + regoRuleTemplatePath: option.regoRuleTemplatePath }) const { bundleFile } = await buildOpaBundle({ regoSourceDirectory, distDirectory }) 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..8b1378917 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/open-policy-agent.module.ts @@ -0,0 +1 @@ + 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.module.ts b/apps/policy-engine/src/policy-engine.module.ts index 8a7a7868b..6f32e13cf 100644 --- a/apps/policy-engine/src/policy-engine.module.ts +++ b/apps/policy-engine/src/policy-engine.module.ts @@ -6,7 +6,6 @@ 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 +21,8 @@ import { TenantModule } from './tenant/tenant.module' }), // Domain - EngineModule, - TenantModule + EngineModule + // TenantModule ], providers: [ { diff --git a/apps/policy-engine/src/open-policy-agent/resource/rego-rules.template.hbs b/apps/policy-engine/src/resource/open-policy-agent/rego-rules.template.hbs similarity index 100% rename from apps/policy-engine/src/open-policy-agent/resource/rego-rules.template.hbs rename to apps/policy-engine/src/resource/open-policy-agent/rego-rules.template.hbs diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/approval_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/approval_test.rego new file mode 100644 index 000000000..9493c1be0 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/approval_test.rego @@ -0,0 +1,149 @@ +package main + +test_approversRoles { + roles = approversRoles with input as request + with data.entities as entities + + roles == {"root", "member", "admin"} +} + +test_approversGroups { + groups = approversGroups with input as request + with data.entities as entities + + groups == {"test-user-group-one-uid", "test-user-group-two-uid"} +} + +test_checkApprovalByUserId { + requiredApproval = { + "approvalCount": 2, + "countPrincipal": true, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid", "test-approver-uid"], + } + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 1 +} + +test_checkApprovalByUserId { + requiredApproval = { + "approvalCount": 1, + "countPrincipal": false, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid", "test-approver-uid"], + } + + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 0 +} + +test_checkApprovalByUserGroup { + requiredApproval = { + "approvalCount": 2, + "countPrincipal": true, + "approvalEntityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + } + + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 1 +} + +test_checkApprovalByUserGroup { + requiredApproval = { + "approvalCount": 1, + "countPrincipal": false, + "approvalEntityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + } + + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 0 +} + +test_checkApprovalByUserRole { + requiredApproval = { + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::UserRole", + "entityIds": ["root", "admin"], + } + + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 2 +} + +test_checkApprovalByUserRole { + requiredApproval = { + "approvalCount": 2, + "countPrincipal": true, + "approvalEntityType": "Narval::UserRole", + "entityIds": ["root", "admin"], + } + + res = checkApproval(requiredApproval) with input as request with data.entities as entities + + res == 3 +} + +test_checkApprovalWithoutCountingDuplicates { + requestWithDuplicates = object.union(request, {"principal": {"userId": "test-alice-uid"}, "approvals": [ + { + "userId": "test-bar-uid", + "alg": "ES256K", + "pubKey": "test-bar-pub-key", + "sig": "test-bar-wallet-sig", + }, + { + "userId": "test-bar-uid", + "alg": "ES256K", + "pubKey": "test-bar-pub-key", + "sig": "test-bar-device-sig", + }, + { + "userId": "test-bar-uid", + "alg": "ES256K", + "pubKey": "test-bar-pub-key", + "sig": "test-bar-device-sig", + }, + ]}) + + requiredApproval = { + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bar-uid"], + } + + res = checkApproval(requiredApproval) with input as requestWithDuplicates with data.entities as entities + + res == 1 +} + +test_checkApprovals { + satisfied = { + "approvalCount": 1, + "countPrincipal": true, + "approvalEntityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + } + + missing = { + "approvalCount": 2, + "countPrincipal": true, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid", "test-approver-uid"], + } + + res = checkApprovals([satisfied, missing]) with input as request with data.entities as entities + + res == { + "approvalsSatisfied": [satisfied], + "approvalsMissing": [missing], + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/amount_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/amount_test.rego new file mode 100644 index 000000000..50f19c640 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/amount_test.rego @@ -0,0 +1,53 @@ +package main + +test_intentAmount { + amount = intentAmount(wildcard) with input as request + with data.entities as entities + + amount == to_number(one_matic) + + value = intentAmount("fiat:usd") with input as request + with data.entities as entities + + value == to_number(one_matic_value) +} + +test_checkIntentAmount { + checkIntentAmount({"currency": wildcard, "operator": operators.equal, "value": one_matic}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.notEqual, "value": ten_matic}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.greaterThan, "value": half_matic}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThan, "value": ten_matic}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.greaterThanOrEqual, "value": one_matic}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": one_matic}) with input as request + with data.entities as entities +} + +test_checkTokenValue { + checkIntentAmount({"currency": "fiat:usd", "operator": operators.equal, "value": one_matic_value}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": "fiat:usd", "operator": operators.notEqual, "value": ten_matic_value}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": "fiat:usd", "operator": operators.greaterThan, "value": half_matic_value}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": "fiat:usd", "operator": operators.lessThan, "value": ten_matic_value}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": "fiat:usd", "operator": operators.greaterThanOrEqual, "value": one_matic_value}) with input as request + with data.entities as entities + + checkIntentAmount({"currency": "fiat:usd", "operator": operators.lessThanOrEqual, "value": one_matic_value}) with input as request + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractCall_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractCall_test.rego new file mode 100644 index 000000000..717a3a433 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractCall_test.rego @@ -0,0 +1,30 @@ +package main + +test_contractCall { + contractCallRequest = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "type": "contractCall", + "contract": "eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", + "hexSignature": "0x12345", + }, + } + + checkIntentType({"contractCall"}) with input as contractCallRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as contractCallRequest + with data.entities as entities + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as contractCallRequest + with data.entities as entities + + checkIntentContract({"eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4"}) with input as contractCallRequest + with data.entities as entities + + checkIntentHexSignature({"0x12345"}) with input as contractCallRequest + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractDeploy_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractDeploy_test.rego new file mode 100644 index 000000000..7de0db2a2 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractDeploy_test.rego @@ -0,0 +1,21 @@ +package main + +test_contractDeploy { + contractDeployRequest = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "type": "deployContract", + "chainId": "137", + }, + } + + checkIntentType({"deployContract", "deployErc4337Wallet", "deploySafeWallet"}) with input as contractDeployRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as contractDeployRequest + with data.entities as entities + + checkIntentChainId({"137"}) with input as contractDeployRequest with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/destination_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/destination_test.rego new file mode 100644 index 000000000..1ff55828b --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/destination_test.rego @@ -0,0 +1,22 @@ +package main + +test_destination { + res = destination with input as request + with data.entities as entities + + res == { + "uid": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "address": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chainId": 137, + "classification": "internal", + } + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as request + with data.entities as entities + + checkDestinationAddress({"0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as request + with data.entities as entities + + checkDestinationClassification({"internal"}) with input as request + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/permit_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/permit_test.rego new file mode 100644 index 000000000..30c5c7703 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/permit_test.rego @@ -0,0 +1,48 @@ +package main + +test_permit { + permitRequest = { + "action": "signTypedData", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "type": "permit", + "spender": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "amount": "1000000000000000000", + "deadline": 1634025600, # in ms + }, + } + + checkIntentType({"permit", "permit2"}) with input as permitRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as permitRequest + with data.entities as entities + + checkIntentSpender({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as permitRequest + with data.entities as entities + + checkIntentToken({"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) with input as permitRequest + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": "1000000000000000000"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.equal, "value": "1634025600"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.notEqual, "value": "111111111"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.lessThanOrEqual, "value": "1634025600"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.greaterThanOrEqual, "value": "1634025600"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.lessThan, "value": "16340256000"}) with input as permitRequest + with data.entities as entities + + checkPermitDeadline({"operator": operators.greaterThan, "value": "163402560"}) with input as permitRequest + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/signMessage_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/signMessage_test.rego new file mode 100644 index 000000000..2b4b174dd --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/signMessage_test.rego @@ -0,0 +1,87 @@ +package main + +test_checkSignMessage { + signMessageRequest = { + "action": "signMessage", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "type": "signMessage", + "message": "Hello world!", + }, + } + + checkAction({"signMessage"}) with input as signMessageRequest + with data.entities as entities + + checkIntentType({"signMessage", "signRawMessage"}) with input as signMessageRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as signMessageRequest + with data.entities as entities + + checkIntentMessage({"operator": operators.equal, "value": "Hello world!"}) with input as signMessageRequest + with data.entities as entities + + checkIntentMessage({"operator": operators.contains, "value": "Hello"}) with input as signMessageRequest + with data.entities as entities +} + +test_checkSignRawPayload { + signRawPayloadRequest = { + "action": "signRaw", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "type": "signRawPayload", + "payload": "Hello world!", + "algorithm": "ES256K", + }, + } + + checkAction({"signRaw"}) with input as signRawPayloadRequest + with data.entities as entities + + checkIntentType({"signRawPayload"}) with input as signRawPayloadRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as signRawPayloadRequest + with data.entities as entities + + checkIntentPayload({"operator": operators.equal, "value": "Hello world!"}) with input as signRawPayloadRequest + with data.entities as entities + + checkIntentPayload({"operator": operators.contains, "value": "Hello"}) with input as signRawPayloadRequest + with data.entities as entities + + checkIntentAlgorithm({"ES256K"}) with input as signRawPayloadRequest with data.entities as entities +} + +test_checkSignTypedData { + signTypedDataRequest = { + "action": "signTypedData", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "type": "signTypedData", + "domain": { + "version": "2", + "chainId": 137, + "name": "LINK", + "verifyingContract": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + }, + }, + } + + checkAction({"signTypedData"}) with input as signTypedDataRequest + with data.entities as entities + + checkIntentType({"signTypedData"}) with input as signTypedDataRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as signTypedDataRequest + with data.entities as entities + + checkIntentDomain({ + "chainId": ["1", "137"], + "name": ["UNI", "LINK"], + "verifyingContract": ["eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"], + }) with input as signTypedDataRequest with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/tokenAllowance_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/tokenAllowance_test.rego new file mode 100644 index 000000000..c1aa95185 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/tokenAllowance_test.rego @@ -0,0 +1,30 @@ +package main + +test_tokenAllowance { + tokenAllowanceRequest = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "type": "approveTokenAllowance", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "spender": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "amount": "1000000000000000000", + }, + } + + checkIntentType({"approveTokenAllowance"}) with input as tokenAllowanceRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as tokenAllowanceRequest + with data.entities as entities + + checkIntentSpender({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as tokenAllowanceRequest + with data.entities as entities + + checkIntentToken({"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) with input as tokenAllowanceRequest + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": "1000000000000000000"}) with input as tokenAllowanceRequest + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferNft_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferNft_test.rego new file mode 100644 index 000000000..40ed6cf45 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferNft_test.rego @@ -0,0 +1,90 @@ +package main + +test_transferERC721 { + erc721Request = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "type": "transferERC721", + "contract": "eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", + "token": "eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", + }, + } + + checkIntentType({"transferERC721"}) with input as erc721Request + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as erc721Request + with data.entities as entities + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as erc721Request + with data.entities as entities + + checkIntentContract({"eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4"}) with input as erc721Request + with data.entities as entities + + checkERC721TokenId({"eip155:137/erc721:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173"}) with input as erc721Request + with data.entities as entities +} + +test_transferERC1155 { + erc1155Request = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "type": "transferERC1155", + "contract": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", + "transfers": [ + { + "token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", + "amount": "1", + }, + { + "token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/44444", + "amount": "2", + }, + { + "token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/55555", + "amount": "5", + }, + ], + }, + } + + checkIntentType({"transferERC1155"}) with input as erc1155Request + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as erc1155Request + with data.entities as entities + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as erc1155Request + with data.entities as entities + + checkIntentContract({"eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4"}) with input as erc1155Request + with data.entities as entities + + checkERC1155TokenId({"eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173"}) with input as erc1155Request + with data.entities as entities + + checkERC1155TokenId({"eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/55555"}) with input as erc1155Request + with data.entities as entities + + checkERC1155Transfers([ + {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": "lt", "value": "2"}, + {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/44444", "operator": "lt", "value": "3"}, + {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/55555", "operator": "lt", "value": "6"}, + ]) with input as erc1155Request with data.entities as entities +} + +test_checkERC1155TokenAmount { + checkERC1155TokenAmount("1", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.notEqual, "value": "2"}) + checkERC1155TokenAmount("1", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.equal, "value": "1"}) + checkERC1155TokenAmount("5", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.greaterThanOrEqual, "value": "4"}) + checkERC1155TokenAmount("3", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.lessThanOrEqual, "value": "5"}) + checkERC1155TokenAmount("5", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.greaterThan, "value": "3"}) + checkERC1155TokenAmount("3", {"token": "eip155:137/erc1155:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4/41173", "operator": operators.lessThan, "value": "5"}) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferToken_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferToken_test.rego new file mode 100644 index 000000000..db1b76a36 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferToken_test.rego @@ -0,0 +1,59 @@ +package main + +test_transferNative { + nativeRequest = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "type": "transferNative", + "amount": "1000000000000000000", + "token": "eip155:137/slip44:966", + }, + } + + checkIntentType({"transferNative"}) with input as nativeRequest + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as nativeRequest + with data.entities as entities + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as nativeRequest + with data.entities as entities + + checkIntentToken({"eip155:137/slip44:966"}) with input as nativeRequest + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": "1000000000000000000"}) with input as nativeRequest + with data.entities as entities +} + +test_transferERC20 { + erc20Request = { + "action": "signTransaction", + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "intent": { + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "type": "transferERC20", + "amount": "1000000000000000000", + "contract": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + }, + } + + checkIntentType({"transferERC20"}) with input as erc20Request + with data.entities as entities + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as erc20Request + with data.entities as entities + + checkDestinationId({"eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3"}) with input as erc20Request + with data.entities as entities + + checkIntentContract({"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) with input as erc20Request + with data.entities as entities + + checkIntentAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": "1000000000000000000"}) with input as erc20Request + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego new file mode 100644 index 000000000..d25bc974d --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego @@ -0,0 +1,28 @@ +package main + +test_principal { + user = principal with input as request + with data.entities as entities + + user == {"uid": "test-bob-uid", "role": "root"} + + groups = principalGroups with input as request + with data.entities as entities + + groups == {"test-user-group-one-uid", "test-user-group-two-uid"} + + isPrincipalRootUser with input as request + with data.entities as entities + + isPrincipalAssignedToWallet with input as request + with data.entities as entities + + checkPrincipalId({"test-bob-uid", "test-alice-uid"}) with input as request + with data.entities as entities + + checkPrincipalRole({"root", "admin"}) with input as request + with data.entities as entities + + checkPrincipalGroup({"test-user-group-one-uid"}) with input as request + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/resource_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/resource_test.rego new file mode 100644 index 000000000..b539d3348 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/resource_test.rego @@ -0,0 +1,44 @@ +package main + +test_resource { + checkResourceIntegrity with input as request + with data.entities as entities + + wallet = resource with input as request + with data.entities as entities + + wallet == { + "uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "accountType": "eoa", + "assignees": ["test-bob-uid", "test-alice-uid", "test-bar-uid"], + } + + groups = walletGroups with input as request + with data.entities as entities + + groups == {"test-wallet-group-one-uid"} + + walletGroupsById = getWalletGroups("eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e") with input as request + with data.entities as entities + + walletGroupsById == {"test-wallet-group-one-uid"} + + checkWalletId({"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as request + with data.entities as entities + + checkWalletAddress({"0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}) with input as request + with data.entities as entities + + checkWalletAccountType({"eoa"}) with input as request + with data.entities as entities + + checkWalletGroup({"test-wallet-group-one-uid"}) with input as request + with data.entities as entities +} + +test_extractAddressFromCaip10 { + address = extractAddressFromCaip10("eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e") + + address == "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e" +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/spendingLimit_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/spendingLimit_test.rego new file mode 100644 index 000000000..e7c9ec665 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/spendingLimit_test.rego @@ -0,0 +1,77 @@ +package main + +test_parseUnits { + parseUnits("3000", 6) == 3000000000 +} + +test_checkAccWildcardCondition { + conditions = {"tokens": wildcard} + checkSpendingCondition("eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", conditions.tokens) +} + +test_checkAccCondition { + conditions = {"tokens": { + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }} + checkSpendingCondition("eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", conditions.tokens) +} + +test_checkAccStartDate { + timeWindow = {"startDate": nowSeconds - ((12 * 60) * 60)} + checkSpendingFromStartDate(elevenHoursAgo, timeWindow) +} + +test_checkAccEndDate { + timeWindow = {"endDate": nowSeconds + ((12 * 60) * 60)} + checkSpendingToEndDate(nowSeconds, timeWindow) +} + +test_checkRollingTimeWindow { + timeWindow = { + "type": "rolling", + "value": (12 * 60) * 60, + } + checkSpendingTimeWindow(elevenHoursAgo, timeWindow) +} + +test_checkSpendingLimitByAmount { + conditions = { + "limit": "1000000000000000000", + "operator": operators.greaterThan, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": { + "tokens": { + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }, + "users": {"test-alice-uid"}, + }, + } + + checkSpendingLimit(conditions) with input as request with data.entities as entities +} + +test_checkSpendingLimitByValue { + conditions = { + "limit": "900000000000000000", + "operator": operators.greaterThan, + "currency": "fiat:usd", + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": { + "tokens": { + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }, + "users": {"test-alice-uid"}, + }, + } + + checkSpendingLimit(conditions) with input as request with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/gas_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/gas_test.rego new file mode 100644 index 000000000..89ef4ce55 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/gas_test.rego @@ -0,0 +1,33 @@ +package main + +gasFee = 483000000000000 + +gasFeeUsd = 483000000000000 * 0.99 + +moreGasFee = gasFee + 1000000000 + +lessGasFee = gasFee - 1000000000 + +test_gasFeeAmount { + gasFeeUsd == gasFeeAmount("fiat:usd") with input as request with data.entities as entities +} + +test_checkGasFeeAmount { + checkGasFeeAmount({"currency": wildcard, "operator": operators.equal, "value": gasFee}) with input as request + with data.entities as entities + + checkGasFeeAmount({"currency": wildcard, "operator": operators.notEqual, "value": moreGasFee}) with input as request + with data.entities as entities + + checkGasFeeAmount({"currency": wildcard, "operator": operators.greaterThan, "value": lessGasFee}) with input as request + with data.entities as entities + + checkGasFeeAmount({"currency": wildcard, "operator": operators.lessThan, "value": moreGasFee}) with input as request + with data.entities as entities + + checkGasFeeAmount({"currency": wildcard, "operator": operators.greaterThanOrEqual, "value": gasFee}) with input as request + with data.entities as entities + + checkGasFeeAmount({"currency": wildcard, "operator": operators.lessThanOrEqual, "value": gasFee}) with input as request + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/nonce_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/nonce_test.rego new file mode 100644 index 000000000..5a707b60b --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/nonce_test.rego @@ -0,0 +1,36 @@ +package main + +test_checkNonceExists { + requestWithNonce = {"transactionRequest": { + "from": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chainId": 137, + "maxFeePerGas": "20000000000", + "maxPriorityFeePerGas": "3000000000", + "gas": "21000", + "value": "0xde0b6b3a7640000", + "data": "0x00000000", + "nonce": 192, + "type": "2", + }} + + checkNonceExists with input as requestWithNonce + with data.entities as entities +} + +test_checkNonceNotExists { + requestWithoutNonce = {"transactionRequest": { + "from": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chainId": 137, + "maxFeePerGas": "20000000000", + "maxPriorityFeePerGas": "3000000000", + "gas": "21000", + "value": "0xde0b6b3a7640000", + "data": "0x00000000", + "type": "2", + }} + + checkNonceNotExists with input as requestWithoutNonce + with data.entities as entities +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego new file mode 100644 index 000000000..8b5240749 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego @@ -0,0 +1,196 @@ +package main + +half_matic = "500000000000000000" + +one_matic = "1000000000000000000" + +ten_matic = "10000000000000000000" + +half_matic_value = "495000000000000000" + +one_matic_value = "990000000000000000" + +ten_matic_value = "9900000000000000000" + +twentyHoursAgo = (nowSeconds - ((20 * 60) * 60)) * 1000 # in ms + +elevenHoursAgo = (nowSeconds - ((11 * 60) * 60)) * 1000 # in ms + +tenHoursAgo = (nowSeconds - ((10 * 60) * 60)) * 1000 # in ms + +nineHoursAgo = (nowSeconds - ((9 * 60) * 60)) * 1000 # in ms + +principalReq = {"userId": "test-bob-uid"} + +resourceReq = {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + +transactionRequestReq = { + "from": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chainId": 137, + "maxFeePerGas": "20000000000", + "maxPriorityFeePerGas": "3000000000", + "gas": "21000", + "value": "0xde0b6b3a7640000", + "data": "0x00000000", + "nonce": 192, + "type": "2", +} + +intentReq = { + "type": "transferERC20", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "amount": "1000000000000000000", +} + +approvalsReq = [ + {"userId": "test-bob-uid"}, + {"userId": "test-alice-uid"}, + {"userId": "test-foo-uid"}, + {"userId": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43"}, +] + +feedsReq = [ + { + "source": "armory/price-feed", + "sig": {}, + "data": { + "eip155:137/slip44:966": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + }, + }, + { + "source": "armory/historical-transfer-feed", + "sig": {}, + "data": [ + { + "amount": "3051000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99", "fiat:eur": "1.10"}, + "timestamp": elevenHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + { + "amount": "2000000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99", "fiat:eur": "1.10"}, + "timestamp": tenHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + { + "amount": "1500000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99", "fiat:eur": "1.10"}, + "timestamp": twentyHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + ], + }, +] + +request = { + "action": "signTransaction", + "transactionRequest": transactionRequestReq, + "principal": principalReq, + "resource": resourceReq, + "intent": intentReq, + "approvals": approvalsReq, + "feeds": feedsReq, +} + +entities = { + "users": { + "test-bob-uid": { + "uid": "test-bob-uid", + "role": "root", + }, + "test-alice-uid": { + "uid": "test-alice-uid", + "role": "member", + }, + "test-bar-uid": { + "uid": "test-bar-uid", + "role": "admin", + }, + "test-foo-uid": { + "uid": "test-foo-uid", + "role": "admin", + }, + "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43": { + "uid": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43", + "role": "admin", + }, + }, + "wallets": { + "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { + "uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "accountType": "eoa", + "assignees": ["test-bob-uid", "test-alice-uid", "test-bar-uid"], + }, + "eip155:eoa:0xbbbb208f219a6e6af072f2cfdc615b2c1805f98e": { + "uid": "eip155:eoa:0xbbbb208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xbbbb208f219a6e6af072f2cfdc615b2c1805f98e", + "accountType": "eoa", + "assignees": ["test-bar-uid"], + }, + }, + "userGroups": { + "test-user-group-one-uid": { + "uid": "test-user-group-one-uid", + "name": "dev", + "users": ["test-bob-uid", "test-bar-uid"], + }, + "test-user-group-two-uid": { + "uid": "test-user-group-two-uid", + "name": "finance", + "users": ["test-bob-uid", "test-bar-uid"], + }, + }, + "walletGroups": {"test-wallet-group-one-uid": { + "uid": "test-wallet-group-one-uid", + "name": "dev", + "wallets": ["eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", "eip155:eoa:0xbbbb208f219a6e6af072f2cfdc615b2c1805f98e"], + }}, + "addressBook": { + "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3": { + "uid": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "address": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chainId": 137, + "classification": "internal", + }, + "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", + }, + }, + "tokens": {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174": { + "uid": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "symbol": "USDC", + "chainId": 137, + "decimals": 6, + }}, +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego new file mode 100644 index 000000000..6b87eed05 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego @@ -0,0 +1,96 @@ +package main + +import future.keywords.in + +permit[{"policyId": "approvalByUsers"}] = reason { + resources = {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + transferTypes = {"transferERC20"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"} + transferValueCondition = {"currency": "*", "operator": "lte", "value": "1000000000000000000"} + approvalsRequired = [{ + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid"], + }] + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkWalletId(resources) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkIntentAmount(transferValueCondition) + + approvals = checkApprovals(approvalsRequired) + + reason = { + "type": "permit", + "policyId": "approvalByUsers", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} + +permit[{"policyId": "approvalByUserGroups"}] = reason { + resources = {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + transferTypes = {"transferERC20"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"} + transferValueCondition = {"currency": "*", "operator": "lte", "value": "1000000000000000000"} + approvalsRequired = [{ + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + }] + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkWalletId(resources) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkIntentAmount(transferValueCondition) + + approvals = checkApprovals(approvalsRequired) + + reason = { + "type": "permit", + "policyId": "approvalByUserGroups", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} + +permit[{"policyId": "approvalByUserRoles"}] = reason { + resources = {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + transferTypes = {"transferERC20"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"} + transferValueCondition = {"currency": "*", "operator": "lte", "value": "1000000000000000000"} + approvalsRequired = [{ + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::UserRole", + "entityIds": ["root", "admin"], + }] + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkWalletId(resources) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkIntentAmount(transferValueCondition) + + approvals = checkApprovals(approvalsRequired) + + reason = { + "type": "permit", + "policyId": "approvalByUserRoles", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego new file mode 100644 index 000000000..081193759 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego @@ -0,0 +1,63 @@ +package main + +test_approvalByUsers { + approvalByUsersReq = object.union(request, {"principal": {"userId": "test-alice-uid"}, "approvals": [ + {"userId": "test-bob-uid"}, + {"userId": "test-bar-uid"}, + ]}) + res = permit[{"policyId": "approvalByUsers"}] with input as approvalByUsersReq with data.entities as entities + + res == { + "approvalsMissing": [], + "approvalsSatisfied": [{ + "approvalCount": 2, + "approvalEntityType": "Narval::User", + "countPrincipal": false, + "entityIds": ["test-bob-uid", "test-bar-uid"], + }], + "policyId": "approvalByUsers", + "type": "permit", + } +} + +test_approvalByUserGroups { + approvalByUserGroupsReq = object.union(request, {"principal": {"userId": "test-alice-uid"}, "approvals": [ + {"userId": "test-bob-uid"}, + {"userId": "test-bar-uid"}, + ]}) + + res = permit[{"policyId": "approvalByUserGroups"}] with input as approvalByUserGroupsReq with data.entities as entities + + res == { + "approvalsMissing": [], + "approvalsSatisfied": [{ + "approvalCount": 2, + "approvalEntityType": "Narval::UserGroup", + "countPrincipal": false, + "entityIds": ["test-user-group-one-uid"], + }], + "policyId": "approvalByUserGroups", + "type": "permit", + } +} + +test_approvalByUserRoles { + approvalByUserRolesReq = object.union(request, {"principal": {"userId": "test-alice-uid"}, "approvals": [ + {"userId": "test-bar-uid"}, + {"userId": "test-foo-uid"}, + ]}) + + res = permit[{"policyId": "approvalByUserRoles"}] with input as approvalByUserRolesReq with data.entities as entities + + res == { + "approvalsMissing": [], + "approvalsSatisfied": [{ + "approvalCount": 2, + "approvalEntityType": "Narval::UserRole", + "countPrincipal": false, + "entityIds": ["root", "admin"], + }], + "policyId": "approvalByUserRoles", + "type": "permit", + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e.rego new file mode 100644 index 000000000..2b7f59b9e --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e.rego @@ -0,0 +1,26 @@ +package main + +permit[{"policyId": "examplePermitPolicy"}] = reason { + checkResourceIntegrity + checkNonceExists + checkAction({"signTransaction"}) + checkPrincipalId({"matt@narval.xyz"}) + checkWalletId({"eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b"}) + checkIntentType({"transferNative"}) + checkIntentToken({"eip155:137/slip44:966"}) + checkIntentAmount({"currency": "*", "operator": "lte", "value": "1000000000000000000"}) + approvals = checkApprovals([{"approvalCount": 2, "countPrincipal": false, "approvalEntityType": "Narval::User", "entityIds": ["aa@narval.xyz", "bb@narval.xyz"]}, {"approvalCount": 1, "countPrincipal": false, "approvalEntityType": "Narval::UserRole", "entityIds": ["admin"]}]) + reason = {"type": "permit", "policyId": "examplePermitPolicy", "approvalsSatisfied": approvals.approvalsSatisfied, "approvalsMissing": approvals.approvalsMissing} +} + +forbid[{"policyId": "exampleForbidPolicy"}] = reason { + checkResourceIntegrity + checkNonceExists + checkAction({"signTransaction"}) + checkPrincipalId({"matt@narval.xyz"}) + checkWalletId({"eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b"}) + checkIntentType({"transferNative"}) + checkIntentToken({"eip155:137/slip44:966"}) + checkSpendingLimit({"limit": "1000000000000000000", "operator": "gt", "timeWindow": {"type": "rolling", "value": 43200}, "filters": {"tokens": ["eip155:137/slip44:966"], "users": ["matt@narval.xyz"]}}) + reason = {"type": "forbid", "policyId": "exampleForbidPolicy", "approvalsSatisfied": [], "approvalsMissing": []} +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego new file mode 100644 index 000000000..28d2e315b --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego @@ -0,0 +1,234 @@ +package main + +e2e_req = { + "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/historical-transfer-feed", + "sig": {}, + "data": [ + { + "amount": "100000000000000000", + "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": elevenHoursAgo, + }, + { + "amount": "100000000000000000", + "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": tenHoursAgo, + }, + { + "amount": "100000000000000000", + "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": elevenHoursAgo, + }, + { + "amount": "100000000000000000", + "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": tenHoursAgo, + }, + ], + }, + { + "source": "armory/price-feed", + "sig": {}, + "data": {"eip155:137/slip44:966": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }}, + }, + ], +} + +e2e_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", + }, + }, +} + +test_mattCanTransferLessThanOneMaticWithTwoApprovals { + res = permit[{"policyId": "examplePermitPolicy"}] with input as e2e_req with data.entities as e2e_entities + + res == { + "type": "permit", + "policyId": "examplePermitPolicy", + "approvalsMissing": [], + "approvalsSatisfied": [ + { + "approvalCount": 2, + "approvalEntityType": "Narval::User", + "countPrincipal": false, + "entityIds": ["aa@narval.xyz", "bb@narval.xyz"], + }, + { + "approvalCount": 1, + "countPrincipal": false, + "approvalEntityType": "Narval::UserRole", + "entityIds": ["admin"], + }, + ], + } +} + +test_mattCantTransferMoreThanOneMaticOnTwelveHoursRollingBasis { + res = forbid[{"policyId": "exampleForbidPolicy"}] with input as e2e_req with data.entities as e2e_entities + + res == { + "type": "forbid", + "policyId": "exampleForbidPolicy", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/missing-rules.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/missing-rules.rego new file mode 100644 index 000000000..ce20bb8b7 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/missing-rules.rego @@ -0,0 +1,11 @@ +package main + +permit[{"policyId": "call-contract-custom", "policyName": "call-contract-custom"}] = reason { + checkResourceIntegrity + checkAction({"signTransaction"}) + checkIntentType({"callContract"}) + checkPrincipalId({"auth0|62e1d7ca04533b042cb42419"}) + checkIntentContract({"eip155:137:0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad"}) + checkChainId({"137"}) + reason = {"type": "permit", "policyId": "call-contract-custom", "policyName": "call-contract-custom", "approvalsSatisfied": [], "approvalsMissing": []} +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings.rego new file mode 100644 index 000000000..806eb4665 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings.rego @@ -0,0 +1,216 @@ +package main + +import future.keywords.in + +# Members can't transfer >$5k usd value of USDC in 12 hours on a rolling basis + +forbid[{"policyId": "spendingLimitByRole"}] = reason { + transferTypes = {"transferERC20"} + roles = {"member"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"} + currency = "fiat:usd" + limit = "5000000000" + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkPrincipalRole(roles) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": { + "tokens": tokens, + "roles": roles, + }, + }) + + reason = { + "type": "forbid", + "policyId": "spendingLimitByRole", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +# Alice can't transfer >$5k usd value of USDC in 12 hours on a rolling basis + +forbid[{"policyId": "spendingLimitByUser"}] = reason { + transferTypes = {"transferERC20"} + users = {"test-alice-uid"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"} + currency = "fiat:usd" + limit = "5000000000" + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkPrincipalId(users) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": {"tokens": tokens}, + }) + + reason = { + "type": "forbid", + "policyId": "spendingLimitByUser", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +# Resource wallet can't transfer > $5k usd value in 12 hours on a rolling basis + +forbid[{"policyId": "spendingLimitByWalletResource"}] = reason { + transferTypes = {"transferERC20"} + resources = {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + currency = "fiat:usd" + limit = "5000000000" + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkIntentType(transferTypes) + checkWalletId(resources) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": {"resources": resources}, + }) + + reason = { + "type": "forbid", + "policyId": "spendingLimitByWalletResource", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +# User group can't transfer > $5k usd value in 24 hours on a rolling basis + +forbid[{"policyId": "spendingLimitByUserGroup"}] = reason { + transferTypes = {"transferERC20"} + userGroups = {"test-user-group-one-uid"} + currency = "fiat:usd" + limit = "5000000000" + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkIntentType(transferTypes) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": {"userGroups": userGroups}, + }) + + reason = { + "type": "forbid", + "policyId": "spendingLimitByUserGroup", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +# Wallet group can't transfer > $5k usd value in 24 hours on a rolling basis + +forbid[{"policyId": "spendingLimitByWalletGroup"}] = reason { + transferTypes = {"transferERC20"} + walletGroups = {"test-wallet-group-one-uid"} + currency = "fiat:usd" + limit = "5000000000" + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkIntentType(transferTypes) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": {"walletGroups": walletGroups}, + }) + + reason = { + "type": "forbid", + "policyId": "spendingLimitByWalletGroup", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +# If Alice transfers >$5k usd value of USDC in a 12 hour rolling window, then require approvals + +permit[{"policyId": "spendingLimitWithApprovals"}] = reason { + transferTypes = {"transferERC20"} + users = {"test-alice-uid"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"} + currency = "fiat:usd" + limit = "5000000000" + approvalsRequired = [{ + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid"], + }] + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkPrincipalId(users) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkSpendingLimit({ + "limit": limit, + "operator": operators.greaterThan, + "currency": currency, + "timeWindow": { + "type": "rolling", + "value": (12 * 60) * 60, + }, + "filters": {"tokens": tokens}, + }) + + approvals = checkApprovals(approvalsRequired) + + reason = { + "type": "permit", + "policyId": "spendingLimitWithApprovals", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings_test.rego new file mode 100644 index 000000000..b6160327d --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings_test.rego @@ -0,0 +1,205 @@ +package main + +spendingLimitReq = object.union(request, { + "principal": {"userId": "test-alice-uid"}, + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, +}) + +test_spendingLimitByRole { + res = forbid[{"policyId": "spendingLimitByRole"}] with input as spendingLimitReq with data.entities as entities + + res == { + "type": "forbid", + "policyId": "spendingLimitByRole", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +test_spendingLimitByUser { + res = forbid[{"policyId": "spendingLimitByUser"}] with input as spendingLimitReq with data.entities as entities + + res == { + "type": "forbid", + "policyId": "spendingLimitByUser", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +test_spendingLimitByWalletResource { + res = forbid[{"policyId": "spendingLimitByWalletResource"}] with input as spendingLimitReq with data.entities as entities + + res == { + "type": "forbid", + "policyId": "spendingLimitByWalletResource", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +test_spendingLimitByUserGroup { + spendingLimitByUserGroupReq = object.union(spendingLimitReq, { + "principal": {"userId": "test-bar-uid"}, + "feeds": [ + { + "source": "armory/price-feed", + "sig": {}, + "data": { + "eip155:137/slip44:966": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + }, + }, + { + "source": "armory/historical-transfer-feed", + "data": [ + { + "amount": "3051000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": elevenHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + { + "amount": "2000000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": tenHoursAgo, + "chainId": 137, + "initiatedBy": "test-bar-uid", + }, + { + "amount": "1500000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": twentyHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + ], + }, + ], + }) + + res = forbid[{"policyId": "spendingLimitByUserGroup"}] with input as spendingLimitByUserGroupReq with data.entities as entities + + res == { + "type": "forbid", + "policyId": "spendingLimitByUserGroup", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +test_spendingLimitByWalletGroup { + spendingLimitByWalletGroupReq = object.union(spendingLimitReq, { + "principal": {"userId": "test-bar-uid"}, + "feeds": [ + { + "source": "armory/price-feed", + "sig": {}, + "data": { + "eip155:137/slip44:966": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174": { + "fiat:usd": "0.99", + "fiat:eur": "1.10", + }, + }, + }, + { + "source": "armory/historical-transfer-feed", + "data": [ + { + "amount": "3000000000", + "from": "eip155:eoa:0xbbbb208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": elevenHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + { + "amount": "2000000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": tenHoursAgo, + "chainId": 137, + "initiatedBy": "test-bar-uid", + }, + { + "amount": "1500000000", + "from": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "token": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "rates": {"fiat:usd": "0.99"}, + "timestamp": twentyHoursAgo, + "chainId": 137, + "initiatedBy": "test-alice-uid", + }, + ], + }, + ], + }) + + res = forbid[{"policyId": "spendingLimitByWalletGroup"}] with input as spendingLimitByWalletGroupReq with data.entities as entities + + res == { + "type": "forbid", + "policyId": "spendingLimitByWalletGroup", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +test_permitRuleSpendingLimit { + res = permit[{"policyId": "spendingLimitWithApprovals"}] with input as spendingLimitReq with data.entities as entities + + res == { + "approvalsMissing": [{ + "approvalCount": 2, + "approvalEntityType": "Narval::User", + "countPrincipal": false, + "entityIds": ["test-bob-uid", "test-bar-uid"], + }], + "approvalsSatisfied": [], + "policyId": "spendingLimitWithApprovals", + "type": "permit", + } +} + +test_permitRuleSpendingLimit { + spendingLimitWithApprovalsReq = object.union(request, { + "principal": {"userId": "test-alice-uid"}, + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, "approvals": [ + {"userId": "test-bob-uid"}, + {"userId": "test-bar-uid"}, + ], + }) + + res = permit[{"policyId": "spendingLimitWithApprovals"}] with input as spendingLimitWithApprovalsReq with data.entities as entities + + res == { + "approvalsMissing": [], + "approvalsSatisfied": [{ + "approvalCount": 2, + "approvalEntityType": "Narval::User", + "countPrincipal": false, + "entityIds": ["test-bob-uid", "test-bar-uid"], + }], + "policyId": "spendingLimitWithApprovals", + "type": "permit", + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/action.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/action.rego new file mode 100644 index 000000000..084995462 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/action.rego @@ -0,0 +1,5 @@ +package main + +import future.keywords.in + +checkAction(values) = input.action in values diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego new file mode 100644 index 000000000..110e1a5a7 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego @@ -0,0 +1,116 @@ +package main + +import future.keywords.in + +usersEntities = data.entities.users + +userGroupsEntities = data.entities.userGroups + +approversRoles = {user.role | + approval = input.approvals[_] + user = usersEntities[approval.userId] +} + +approversGroups = {group.uid | + approval = input.approvals[_] + group = userGroupsEntities[_] + approval.userId in group.users +} + +getApprovalsCount(possibleApprovers) = result { + matchedApprovers = {approval.userId | + approval = input.approvals[_] + approval.userId in possibleApprovers + } + result = count(matchedApprovers) +} + +# User approvals + +checkApproval(approval) = result { + approval.countPrincipal == true + approval.approvalEntityType == "Narval::User" + possibleApprovers = {entity | entity = approval.entityIds[_]} | {principal.uid} + result = getApprovalsCount(possibleApprovers) +} + +checkApproval(approval) = result { + approval.countPrincipal == false + approval.approvalEntityType == "Narval::User" + possibleApprovers = {entity | + entity = approval.entityIds[_] + entity != principal.uid + } + result = getApprovalsCount(possibleApprovers) +} + +# User group approvals + +checkApproval(approval) = result { + approval.countPrincipal == true + approval.approvalEntityType == "Narval::UserGroup" + possibleApprovers = {user | + entity = approval.entityIds[_] + users = userGroupsEntities[entity].users + user = users[_] + } | {principal.uid} + + result = getApprovalsCount(possibleApprovers) +} + +checkApproval(approval) = result { + approval.countPrincipal == false + approval.approvalEntityType == "Narval::UserGroup" + possibleApprovers = {user | + entity = approval.entityIds[_] + users = userGroupsEntities[entity].users + user = users[_] + user != principal.uid + } + + result = getApprovalsCount(possibleApprovers) +} + +# User role approvals + +checkApproval(approval) = result { + approval.countPrincipal == true + approval.approvalEntityType == "Narval::UserRole" + possibleApprovers = {user.uid | + user = usersEntities[_] + user.role in approval.entityIds + } | {principal.uid} + + result = getApprovalsCount(possibleApprovers) +} + +checkApproval(approval) = result { + approval.countPrincipal == false + approval.approvalEntityType == "Narval::UserRole" + possibleApprovers = {user.uid | + user = usersEntities[_] + user.role in approval.entityIds + user.uid != principal.uid + } + + result = getApprovalsCount(possibleApprovers) +} + +checkApprovals(approvals) = result { + approvalsMissing = [approval | + approval = approvals[_] + approvalCount = checkApproval(approval) + approvalCount < approval.approvalCount + ] + + approvalsSatisfied = [approval | + approval = approvals[_] + approvalCount = checkApproval(approval) + approvalCount >= approval.approvalCount + ] + + result = { + "approvalsSatisfied": approvalsSatisfied, + "approvalsMissing": approvalsMissing, + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/amount.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/amount.rego new file mode 100644 index 000000000..844cae26e --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/amount.rego @@ -0,0 +1,54 @@ +package main + +import future.keywords.in + +intentAmount(currency) = result { + currency == wildcard + result = to_number(input.intent.amount) +} + +intentAmount(currency) = result { + currency != wildcard + amount = to_number(input.intent.amount) + token = input.intent.token + price = to_number(priceFeed[token][currency]) + result = amount * price +} + +intentAmount(currency) = result { + currency != wildcard + amount = to_number(input.intent.amount) + contract = input.intent.contract + price = to_number(priceFeed[contract][currency]) + result = amount * price +} + +checkIntentAmount(condition) { + condition.operator == operators.equal + intentAmount(condition.currency) == to_number(condition.value) +} + +checkIntentAmount(condition) { + condition.operator == operators.notEqual + intentAmount(condition.currency) != to_number(condition.value) +} + +checkIntentAmount(condition) { + condition.operator == operators.greaterThan + intentAmount(condition.currency) > to_number(condition.value) +} + +checkIntentAmount(condition) { + condition.operator == operators.lessThan + intentAmount(condition.currency) < to_number(condition.value) +} + +checkIntentAmount(condition) { + condition.operator == operators.greaterThanOrEqual + intentAmount(condition.currency) >= to_number(condition.value) +} + +checkIntentAmount(condition) { + condition.operator == operators.lessThanOrEqual + intentAmount(condition.currency) <= to_number(condition.value) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/destination.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/destination.rego new file mode 100644 index 000000000..8b800171e --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/destination.rego @@ -0,0 +1,15 @@ +package main + +import future.keywords.in + +destination = data.entities.wallets[input.intent.to] + +destination = data.entities.addressBook[input.intent.to] + +checkDestinationAccountType(values) = destination.accountType in values + +checkDestinationId(values) = destination.uid in values + +checkDestinationAddress(values) = destination.address in values + +checkDestinationClassification(values) = destination.classification in values diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/intent.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/intent.rego new file mode 100644 index 000000000..c94065ded --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/intent.rego @@ -0,0 +1,15 @@ +package main + +import future.keywords.in + +checkIntentType(values) = input.intent.type in values + +checkIntentContract(values) = input.intent.contract in values + +checkIntentToken(values) = input.intent.token in values + +checkIntentSpender(values) = input.intent.spender in values + +checkIntentChainId(values) = numberToString(input.intent.chainId) in values + +checkIntentHexSignature(values) = input.intent.hexSignature in values diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/permit.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/permit.rego new file mode 100644 index 000000000..645738691 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/permit.rego @@ -0,0 +1,36 @@ +package main + +import future.keywords.in + +# convert ms to ns +permitDeadlineMs = to_number(input.intent.deadline) + +checkPermitDeadline(condition) { + condition.operator == operators.equal + permitDeadlineMs == to_number(condition.value) +} + +checkPermitDeadline(condition) { + condition.operator == operators.notEqual + permitDeadlineMs != to_number(condition.value) +} + +checkPermitDeadline(condition) { + condition.operator == operators.lessThanOrEqual + permitDeadlineMs <= to_number(condition.value) +} + +checkPermitDeadline(condition) { + condition.operator == operators.greaterThanOrEqual + permitDeadlineMs >= to_number(condition.value) +} + +checkPermitDeadline(condition) { + condition.operator == operators.lessThan + permitDeadlineMs < to_number(condition.value) +} + +checkPermitDeadline(condition) { + condition.operator == operators.greaterThan + permitDeadlineMs > to_number(condition.value) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/signMessage.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/signMessage.rego new file mode 100644 index 000000000..6f47efe40 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/signMessage.rego @@ -0,0 +1,61 @@ +package main + +import future.keywords.in + +# Intent Sign Message + +checkIntentMessage(condition) { + condition.operator == operators.equal + condition.value == input.intent.message +} + +checkIntentMessage(condition) { + condition.operator == operators.contains + contains(input.intent.message, condition.value) +} + +# Intent Sign Raw Payload + +checkIntentPayload(condition) { + condition.operator == operators.equal + condition.value == input.intent.payload +} + +checkIntentPayload(condition) { + condition.operator == operators.contains + contains(input.intent.payload, condition.value) +} + +# Intent Sign Raw Payload Algorithm + +checkIntentAlgorithm(values) { + input.intent.algorithm in values +} + +# Intent Sign Typed Data Domain + +checkDomainCondition(value, arr) { + arr == wildcard +} + +checkDomainCondition(value, arr) { + arr != wildcard + value in arr +} + +checkIntentDomain(filters) { + wildcardDomain = { + "version": wildcard, + "chainId": wildcard, + "name": wildcard, + "verifyingContract": wildcard, + } + + domain = object.union(wildcardDomain, input.intent.domain) + conditions = object.union(wildcardDomain, filters) + + checkDomainCondition(domain.version, conditions.version) + checkDomainCondition(numberToString(domain.chainId), conditions.chainId) + checkDomainCondition(domain.name, conditions.name) + checkDomainCondition(domain.verifyingContract, conditions.verifyingContract) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/transferNft.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/transferNft.rego new file mode 100644 index 000000000..f8c75ff51 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/transferNft.rego @@ -0,0 +1,60 @@ +package main + +import future.keywords.in + +intentTransfers = input.intent.transfers + +checkERC721TokenId(values) = input.intent.token in values + +checkERC1155TokenId(values) { + transfer = intentTransfers[_] + transfer.token in values +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.equal + to_number(condition.value) == to_number(amount) +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.notEqual + to_number(condition.value) != to_number(amount) +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.greaterThan + to_number(condition.value) < to_number(amount) +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.lessThan + to_number(condition.value) > to_number(amount) +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.greaterThanOrEqual + to_number(condition.value) <= to_number(amount) +} + +checkERC1155TokenAmount(amount, condition) { + condition.operator == operators.lessThanOrEqual + to_number(condition.value) >= to_number(amount) +} + +checkERC1155Transfers(conditions) { + matches = [e | + some transfer in intentTransfers + some condition in conditions + transfer.token == condition.token + e = [transfer, condition] + ] + + validTransfers = [transfer | + some m in matches + transfer = m[0] + condition = m[1] + checkERC1155TokenAmount(transfer.amount, condition) + ] + + count(intentTransfers) == count(validTransfers) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego new file mode 100644 index 000000000..8deba84d2 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego @@ -0,0 +1,28 @@ +package main + +import future.keywords.in + +principal = data.entities.users[input.principal.userId] + +principalGroups = {group.uid | + group = data.entities.userGroups[_] + input.principal.userId in group.users +} + +isPrincipalRootUser = principal.role == "root" + +isPrincipalAssignedToWallet = principal.uid in resource.assignees + +checkPrincipal { + not isPrincipalRootUser + isPrincipalAssignedToWallet +} + +checkPrincipalId(values) = principal.uid in values + +checkPrincipalRole(values) = principal.role in values + +checkPrincipalGroup(values) { + group = principalGroups[_] + group in values +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/resource.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/resource.rego new file mode 100644 index 000000000..b7ac26678 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/resource.rego @@ -0,0 +1,43 @@ +package main + +import future.keywords.in + +resource = data.entities.wallets[input.resource.uid] + +checkResourceIntegrity { + checkAction({"signTransaction"}) + transactionRequestFromAddress = input.transactionRequest.from + resourceAddress = extractAddressFromCaip10(input.resource.uid) + intentFromAddress = extractAddressFromCaip10(input.intent.from) + transactionRequestFromAddress == resourceAddress + transactionRequestFromAddress == intentFromAddress + resourceAddress == intentFromAddress +} + +walletGroups = {group.uid | + group = data.entities.walletGroups[_] + input.resource.uid in group.wallets +} + +getWalletGroups(id) = {group.uid | + group = data.entities.walletGroups[_] + id in group.wallets +} + +checkWalletId(values) = resource.uid in values + +checkWalletAddress(values) = resource.address in values + +checkWalletAccountType(values) = resource.accountType in values + +checkWalletChainId(values) = numberToString(resource.chainId) in values + +checkWalletGroup(values) { + group = walletGroups[_] + group in values +} + +extractAddressFromCaip10(caip10) = result { + arr = split(caip10, ":") + result = arr[count(arr) - 1] +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/spendingLimit.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/spendingLimit.rego new file mode 100644 index 000000000..88973d3fe --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/spendingLimit.rego @@ -0,0 +1,196 @@ +package main + +import future.keywords.in + +parseUnits(value, decimals) = result { + range = numbers.range(1, decimals) + powTen = [n | i = range[_]; n = 10] + result = to_number(value) * product(powTen) +} + +getUserGroups(id) = {group.uid | + group = data.entities.userGroups[_] + id in group.users +} + +# Check By Condition + +checkSpendingCondition(value, set) { + set == wildcard +} + +checkSpendingCondition(value, set) { + set != wildcard + value in set +} + +# Check By User Groups + +checkSpendingByUserGroups(userId, values) { + values == wildcard +} + +checkSpendingUserGroups(userId, values) { + values != wildcard + groups = getUserGroups(userId) + group = groups[_] + group in values +} + +# Check By Wallet Groups + +checkSpendingByWalletGroups(walletId, values) { + values == wildcard +} + +checkSpendingByWalletGroups(walletId, values) { + values != wildcard + groups = getWalletGroups(walletId) + group = groups[_] + group in values +} + +# Check By Start Date + +checkSpendingFromStartDate(timestamp, timeWindow) { + timeWindow.startDate == wildcard +} + +checkSpendingFromStartDate(timestamp, timeWindow) { + timeWindow.startDate != wildcard + timestampNs = timestamp * 1000000 # convert ms to ns + timestampNs >= secondsToNanoSeconds(timeWindow.startDate) +} + +# Check By End Date + +checkSpendingToEndDate(timestamp, timeWindow) { + timeWindow.endDate == wildcard +} + +checkSpendingToEndDate(timestamp, timeWindow) { + timeWindow.endDate != wildcard + timestampNs = timestamp * 1000000 # convert ms to ns + timestampNs <= secondsToNanoSeconds(timeWindow.endDate) +} + +# Check By Time Window Type + +checkSpendingTimeWindow(timestamp, timeWindow) { + timeWindow.type == wildcard +} + +checkSpendingTimeWindow(timestamp, timeWindow) { + timeWindow.type == "rolling" + timeWindow.value != wildcard + timestampNs = timestamp * 1000000 # convert ms to ns + timestampNs >= time.now_ns() - secondsToNanoSeconds(timeWindow.value) +} + +# Check By Operator + +checkSpendingOperator(spendings, operator, limit) { + operator == operators.lessThan + spendings < limit +} + +checkSpendingOperator(spendings, operator, limit) { + operator == operators.lessThanOrEqual + spendings <= limit +} + +checkSpendingOperator(spendings, operator, limit) { + operator == operators.greaterThan + spendings > limit +} + +checkSpendingOperator(spendings, operator, limit) { + operator == operators.greaterThanOrEqual + spendings >= limit +} + +# Calculate Spending + +calculateSpending(transfer, currency) = result { + currency == wildcard + result = to_number(transfer.amount) +} + +calculateSpending(transfer, currency) = result { + currency != wildcard + result = to_number(transfer.amount) * to_number(transfer.rates[currency]) +} + +# Check Spending Limit + +checkSpendingLimit(params) { + conditions = object.union( + { + "currency": wildcard, + "limit": wildcard, + "operator": wildcard, + "timeWindow": { + "type": wildcard, + "value": wildcard, # in seconds + "startDate": wildcard, # in seconds + "endDate": wildcard, # in seconds + }, + "filters": { + "tokens": wildcard, + "users": wildcard, + "resources": wildcard, + "chains": wildcard, + "userGroups": wildcard, + "walletGroups": wildcard, + }, + }, + params, + ) + + currency = conditions.currency + + limit = to_number(conditions.limit) + + operator = conditions.operator + + timeWindow = conditions.timeWindow + + filters = conditions.filters + + amount = intentAmount(currency) + + spendings = sum([spending | + transfer = transferFeed[_] + + # filter by tokens + checkSpendingCondition(transfer.token, filters.tokens) + + # filter by users + checkSpendingCondition(transfer.initiatedBy, filters.users) + + # filter by resource wallets + checkSpendingCondition(transfer.from, filters.resources) + + # filter by chains + checkSpendingCondition(numberToString(transfer.chainId), filters.chains) + + # filter by user groups + checkSpendingByUserGroups(transfer.initiatedBy, filters.userGroups) + + # filter by wallet groups + checkSpendingByWalletGroups(transfer.from, filters.walletGroups) + + # filter by start date + checkSpendingFromStartDate(transfer.timestamp, timeWindow) + + # filter by end date + checkSpendingToEndDate(transfer.timestamp, timeWindow) + + # filter by time window type + checkSpendingTimeWindow(transfer.timestamp, timeWindow) + + spending = calculateSpending(transfer, currency) + ]) + + checkSpendingOperator(spendings + amount, operator, limit) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/chainId.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/chainId.rego new file mode 100644 index 000000000..fbe0a4e48 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/chainId.rego @@ -0,0 +1,9 @@ +package main + +import future.keywords.in + +chainId = numberToString(input.transactionRequest.chainId) + +checkChainId(values) { + chainId in values +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/gas.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/gas.rego new file mode 100644 index 000000000..ce982e346 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/gas.rego @@ -0,0 +1,49 @@ +package main + +gasFee = (to_number(input.transactionRequest.maxFeePerGas) + to_number(input.transactionRequest.maxPriorityFeePerGas)) * to_number(input.transactionRequest.gas) + +# Gas Fee Amount + +gasFeeAmount(currency) = result { + currency == wildcard + result = gasFee +} + +gasFeeAmount(currency) = result { + currency != wildcard + token = chainAssetId[chainId] + price = to_number(priceFeed[token][currency]) + result = gasFee * price +} + +# Check Gas Fee Amount + +checkGasFeeAmount(condition) { + condition.operator == operators.equal + to_number(condition.value) == gasFeeAmount(condition.currency) +} + +checkGasFeeAmount(condition) { + condition.operator == operators.notEqual + to_number(condition.value) != gasFeeAmount(condition.currency) +} + +checkGasFeeAmount(condition) { + condition.operator == operators.greaterThan + to_number(condition.value) < gasFeeAmount(condition.currency) +} + +checkGasFeeAmount(condition) { + condition.operator == operators.lessThan + to_number(condition.value) > gasFeeAmount(condition.currency) +} + +checkGasFeeAmount(condition) { + condition.operator == operators.greaterThanOrEqual + to_number(condition.value) <= gasFeeAmount(condition.currency) +} + +checkGasFeeAmount(condition) { + condition.operator == operators.lessThanOrEqual + to_number(condition.value) >= gasFeeAmount(condition.currency) +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/nonce.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/nonce.rego new file mode 100644 index 000000000..9453143bf --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/nonce.rego @@ -0,0 +1,9 @@ +package main + +checkNonceExists { + input.transactionRequest.nonce +} + +checkNonceNotExists { + not input.transactionRequest.nonce +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/main.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/main.rego new file mode 100644 index 000000000..0a6e432b3 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/main.rego @@ -0,0 +1,112 @@ +package main + +import future.keywords.in + +numberToString(n) = format_int(to_number(n), 10) + +secondsToNanoSeconds(epochS) = epochS * 1000000000 + +nanoSecondsToSeconds(epochNs) = epochNs / 1000000000 + +nowSeconds = nanoSecondsToSeconds(time.now_ns()) + +wildcard = "*" + +operators = { + "equal": "eq", + "notEqual": "ne", + "greaterThan": "gt", + "greaterThanOrEqual": "gte", + "lessThan": "lt", + "lessThanOrEqual": "lte", + "contains": "contains", +} + +chainAssetId = { + "1": "eip155:1/slip44:60", + "10": "eip155:10/slip44:614", + "56": "eip155:56/slip44:714", + "137": "eip155:137/slip44:966", + "250": "eip155:250/slip44:1007", + "42161": "eip155:42161/slip44:9001", + "42220": "eip155:42220/slip44:52752", + "43114": "eip155:43114/slip44:9000", +} + +priceFeed = result { + feed = input.feeds[_] + feed.source == "armory/price-feed" + result = feed.data +} + +transferFeed = result { + feed = input.feeds[_] + feed.source == "armory/historical-transfer-feed" + result = feed.data +} + +default evaluate = { + "permit": false, + "reasons": set(), + # The default flag indicates whether the rule was evaluated as expected or if + # it fell back to the default value. It also helps identify cases of what we + # call "implicit deny" in the legacy policy engine. + "default": true, +} + +permit[{"policyId": "allow-root-user", "policyName": "Allow root user"}] = reason { + isPrincipalRootUser + + reason = { + "type": "permit", + "policyId": "allow-root-user", + "policyName": "Allow root user", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +forbid[{"policyId": "default-forbid-policy", "policyName": "Default Forbid Policy"}] = reason { + false + + reason = { + "type": "forbid", + "policyId": "default-forbid-policy", + "policyName": "Default Forbid Policy", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} + +evaluate = decision { + permitSet = {p | p = permit[_]} + forbidSet = {f | f = forbid[_]} + + count(forbidSet) == 0 + count(permitSet) > 0 + + # If ALL Approval in permitSet has count(approval.approvalsMissing) == 0, set "permit": true. + # We "Stack" approvals, so multiple polices that match & each have different requirements, ALL must succeed. + # If you want to avoid this, the rules should get upper bounded so they're mutually exlusive, but that's done at the policy-builder time, not here. + + # Filter permitSet to only include objects where approvalsMissing is empty + filteredPermitSet = {p | p = permitSet[_]; count(p.approvalsMissing) == 0} + + decision = { + "permit": count(filteredPermitSet) == count(permitSet), + "reasons": permitSet, + } +} + +evaluate = decision { + permitSet = {p | p = permit[_]} + forbidSet = {f | f = forbid[_]} + + # If the forbid set is not empty, set "permit": false. + count(forbidSet) > 0 + + decision = { + "permit": false, + "reasons": forbidSet, + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/policies/meta-permission.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/policies/meta-permission.rego new file mode 100644 index 000000000..abb111c28 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/policies/meta-permission.rego @@ -0,0 +1,51 @@ +package main + +metaPermissions = { + "CREATE_ORGANIZATION", + "CREATE_USER", + "UPDATE_USER", + "CREATE_CREDENTIAL", + "ASSIGN_USER_GROUP", + "ASSIGN_WALLET_GROUP", + "ASSIGN_USER_WALLET", + "DELETE_USER", + "REGISTER_WALLET", + "CREATE_ADDRESS_BOOK_ACCOUNT", + "EDIT_WALLET", + "UNASSIGN_WALLET", + "REGISTER_TOKENS", + "EDIT_USER_GROUP", + "DELETE_USER_GROUP", + "CREATE_WALLET_GROUP", + "DELETE_WALLET_GROUP", +} + +permit[{"policyId": "permit-meta-permissions", "policyName": "Permit admin user role for meta permissions"}] = reason { + checkAction(metaPermissions) + checkPrincipalRole({"admin"}) + approvals = checkApprovals([{ + "approvalCount": 2, + "countPrincipal": false, + "approvalEntityType": "Narval::UserRole", + "entityIds": ["root", "admin"], + }]) + reason = { + "type": "permit", + "policyId": "permit-meta-permissions", + "policyName": "Permit admin user role for meta permissions", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} + +forbid[{"policyId": "forbid-meta-permissions", "policyName": "Forbid member user role for meta permissions"}] = reason { + checkAction(metaPermissions) + checkPrincipalRole({"member"}) + reason = { + "type": "forbid", + "policyId": "forbid-meta-permissions", + "policyName": "Forbid member user role for meta permissions", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/rules.template.hbs b/apps/policy-engine/src/resource/open-policy-agent/rego/rules.template.hbs new file mode 100644 index 000000000..7e27a48f2 --- /dev/null +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/rules.template.hbs @@ -0,0 +1,11 @@ +package main + +{{#each policies}} + {{then}}[{"policyId": "{{id}}", "policyName": "{{name}}" }] = reason { + {{#each when}} + {{#criterion this}}{{/criterion}} + {{/each}} + {{#reason this}}{{/reason}} + } + +{{/each}}