From 3629b3e127bfc15dbeb8421cae9e54c1f611badf Mon Sep 17 00:00:00 2001 From: William Calderipe Date: Mon, 4 Mar 2024 12:26:33 +0100 Subject: [PATCH] Tenant persistence with in-memory key-value storage (#144) --- .../src/app/__test__/e2e/admin.spec.ts | 2 +- apps/policy-engine/src/app/app.module.ts | 2 +- .../__test__/unit/tenant.repository.spec.ts | 60 ++++++++++++++ .../repository/tenant.repository.ts | 37 +++++++++ .../app.config.ts => policy-engine.config.ts} | 0 .../core/repository/key-value.repository.ts | 7 ++ .../__test__/unit/key-value.service.spec.ts | 44 ++++++++++ .../core/service/key-value.service.ts | 30 +++++++ .../module/key-value/key-value.module.ts | 16 ++++ .../in-memory-key-value.repository.ts | 23 +++++ .../src/shared/schema/tenant.schema.ts | 13 +++ .../src/shared/types/domain.type.ts | 9 +- packages/policy-engine-shared/src/index.ts | 1 + .../src/lib/schema/data-store.schema.ts | 37 +++++++++ .../src/lib/schema/entity.schema.ts | 83 +++++++++++++++++++ .../src/lib/type/data-store.type.ts | 22 +++++ 16 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts create mode 100644 apps/policy-engine/src/app/persistence/repository/tenant.repository.ts rename apps/policy-engine/src/{app/app.config.ts => policy-engine.config.ts} (100%) create mode 100644 apps/policy-engine/src/shared/module/key-value/core/repository/key-value.repository.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/core/service/__test__/unit/key-value.service.spec.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/key-value.module.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts create mode 100644 apps/policy-engine/src/shared/schema/tenant.schema.ts create mode 100644 packages/policy-engine-shared/src/lib/schema/data-store.schema.ts create mode 100644 packages/policy-engine-shared/src/lib/schema/entity.schema.ts create mode 100644 packages/policy-engine-shared/src/lib/type/data-store.type.ts diff --git a/apps/policy-engine/src/app/__test__/e2e/admin.spec.ts b/apps/policy-engine/src/app/__test__/e2e/admin.spec.ts index fd26b2695..5735c80fb 100644 --- a/apps/policy-engine/src/app/__test__/e2e/admin.spec.ts +++ b/apps/policy-engine/src/app/__test__/e2e/admin.spec.ts @@ -8,9 +8,9 @@ import { readFileSync, unlinkSync } from 'fs' import { mock } from 'jest-mock-extended' import request from 'supertest' import { AppModule } from '../../../app/app.module' +import { load } from '../../../policy-engine.config' import { Organization } from '../../../shared/types/entities.types' import { Criterion, Then, TimeWindow } from '../../../shared/types/policy.type' -import { load } from '../../app.config' import { EntityRepository } from '../../persistence/repository/entity.repository' const REQUEST_HEADER_ORG_ID = 'x-org-id' diff --git a/apps/policy-engine/src/app/app.module.ts b/apps/policy-engine/src/app/app.module.ts index bef8fbb62..f1cdca190 100644 --- a/apps/policy-engine/src/app/app.module.ts +++ b/apps/policy-engine/src/app/app.module.ts @@ -2,7 +2,7 @@ import { HttpModule } from '@nestjs/axios' import { Module, ValidationPipe } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' -import { load } from './app.config' +import { load } from '../policy-engine.config' import { AppController } from './app.controller' import { AppService } from './app.service' import { AdminService } from './core/admin.service' diff --git a/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts b/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts new file mode 100644 index 000000000..425ff03bb --- /dev/null +++ b/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts @@ -0,0 +1,60 @@ +import { DataStoreConfiguration } from '@narval/policy-engine-shared' +import { Test } from '@nestjs/testing' +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 { Tenant } from '../../../../../shared/types/domain.type' +import { TenantRepository } from '../../../repository/tenant.repository' + +describe(TenantRepository.name, () => { + let repository: TenantRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + providers: [ + KeyValueService, + TenantRepository, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + repository = module.get(TenantRepository) + }) + + describe('create', () => { + 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: 'test-client-id', + clientSecret: 'test-client-secret', + dataStore: { + entity: dataStoreConfiguration, + policy: dataStoreConfiguration + }, + createdAt: now, + updatedAt: now + } + + it('creates a new tenant', async () => { + await repository.create(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) + }) + }) +}) diff --git a/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts new file mode 100644 index 000000000..1ede4c633 --- /dev/null +++ b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common' +import { KeyValueService } from '../../../shared/module/key-value/core/service/key-value.service' +import { tenantSchema } from '../../../shared/schema/tenant.schema' +import { Tenant } from '../../../shared/types/domain.type' + +@Injectable() +export class TenantRepository { + constructor(private keyValueService: KeyValueService) {} + + async findByClientId(clientId: string): Promise { + const value = await this.keyValueService.get(this.getKey(clientId)) + + if (value) { + return this.decode(value) + } + + return null + } + + async create(tenant: Tenant): Promise { + await this.keyValueService.set(this.getKey(tenant.clientId), this.encode(tenant)) + + return tenant + } + + getKey(clientId: string): string { + return `${clientId}:tenant` + } + + private encode(tenant: Tenant): string { + return JSON.stringify(tenant) + } + + private decode(value: string): Tenant { + return tenantSchema.parse(JSON.parse(value)) + } +} diff --git a/apps/policy-engine/src/app/app.config.ts b/apps/policy-engine/src/policy-engine.config.ts similarity index 100% rename from apps/policy-engine/src/app/app.config.ts rename to apps/policy-engine/src/policy-engine.config.ts diff --git a/apps/policy-engine/src/shared/module/key-value/core/repository/key-value.repository.ts b/apps/policy-engine/src/shared/module/key-value/core/repository/key-value.repository.ts new file mode 100644 index 000000000..b70505d50 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/core/repository/key-value.repository.ts @@ -0,0 +1,7 @@ +export const KeyValueRepository = Symbol('KeyValueRepository') + +export interface KeyValueRepository { + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise +} diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/unit/key-value.service.spec.ts b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/unit/key-value.service.spec.ts new file mode 100644 index 000000000..b5b4e6a98 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/unit/key-value.service.spec.ts @@ -0,0 +1,44 @@ +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { load } from '../../../../../../../policy-engine.config' +import { InMemoryKeyValueRepository } from '../../../../persistence/repository/in-memory-key-value.repository' +import { KeyValueRepository } from '../../../repository/key-value.repository' +import { KeyValueService } from '../../key-value.service' + +describe('foo', () => { + let service: KeyValueService + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }) + ], + providers: [ + KeyValueService, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + service = module.get(KeyValueService) + }) + + describe('set', () => { + it('sets encrypted value in the key-value storage', async () => { + const key = 'test-key' + const value = 'not encrypted value' + + await service.set(key, value) + + expect(await service.get(key)).toEqual(value) + }) + }) +}) diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts b/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts new file mode 100644 index 000000000..88023934c --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common' +import { KeyValueRepository } from '../repository/key-value.repository' + +/** + * The key-value service is the main interface to interact with any storage + * back-end. Since the storage backend lives outside the engine, it's considered + * untrusted so the engine will encrypt the data before it sends them to the + * storage. + * + * It's because of that the key-value service assumes data is always encrypted. + * If you need non-encrypted data, you can use the key-value repository. + */ +@Injectable() +export class KeyValueService { + constructor(@Inject(KeyValueRepository) private keyValueRepository: KeyValueRepository) {} + + async get(key: string): Promise { + // TODO (@wcalderipe, 01/03/2024): Add decryption step. + return this.keyValueRepository.get(key) + } + + async set(key: string, value: string): Promise { + // TODO (@wcalderipe, 01/03/2024): Add encryption step. + return this.keyValueRepository.set(key, value) + } + + async delete(key: string): Promise { + return this.keyValueRepository.delete(key) + } +} diff --git a/apps/policy-engine/src/shared/module/key-value/key-value.module.ts b/apps/policy-engine/src/shared/module/key-value/key-value.module.ts new file mode 100644 index 000000000..4323036c6 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/key-value.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { KeyValueRepository } from './core/repository/key-value.repository' +import { KeyValueService } from './core/service/key-value.service' +import { InMemoryKeyValueRepository } from './persistence/repository/in-memory-key-value.repository' + +@Module({ + providers: [ + KeyValueService, + { + provide: KeyValueRepository, + useClass: InMemoryKeyValueRepository + } + ], + exports: [KeyValueService, KeyValueRepository] +}) +export class KeyValueModule {} diff --git a/apps/policy-engine/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts b/apps/policy-engine/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts new file mode 100644 index 000000000..df88feab2 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common' +import { KeyValueRepository } from '../../core/repository/key-value.repository' + +@Injectable() +export class InMemoryKeyValueRepository implements KeyValueRepository { + private store = new Map() + + async get(key: string): Promise { + return this.store.get(key) || null + } + + async set(key: string, value: string): Promise { + this.store.set(key, value) + + return true + } + + async delete(key: string): Promise { + this.store.delete(key) + + return true + } +} diff --git a/apps/policy-engine/src/shared/schema/tenant.schema.ts b/apps/policy-engine/src/shared/schema/tenant.schema.ts new file mode 100644 index 000000000..64848aca8 --- /dev/null +++ b/apps/policy-engine/src/shared/schema/tenant.schema.ts @@ -0,0 +1,13 @@ +import { dataStoreConfigurationSchema } from 'packages/policy-engine-shared/src/lib/schema/data-store.schema' +import { z } from 'zod' + +export const tenantSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + dataStore: z.object({ + entity: dataStoreConfigurationSchema, + policy: dataStoreConfigurationSchema + }), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date() +}) diff --git a/apps/policy-engine/src/shared/types/domain.type.ts b/apps/policy-engine/src/shared/types/domain.type.ts index e0129bf4d..bb441d52a 100644 --- a/apps/policy-engine/src/shared/types/domain.type.ts +++ b/apps/policy-engine/src/shared/types/domain.type.ts @@ -6,13 +6,10 @@ import { TransactionRequest } from '@narval/policy-engine-shared' import { Intent } from '@narval/transaction-request-intent' +import { z } from 'zod' +import { tenantSchema } from '../schema/tenant.schema' -export enum UserRoles { - ROOT = 'root', - ADMIN = 'admin', - MEMBER = 'member', - MANAGER = 'manager' -} +export type Tenant = z.infer export type RegoInput = { action: Action diff --git a/packages/policy-engine-shared/src/index.ts b/packages/policy-engine-shared/src/index.ts index ca5017b1c..7ed926e47 100644 --- a/packages/policy-engine-shared/src/index.ts +++ b/packages/policy-engine-shared/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/dto' export * from './lib/schema/address.schema' export * from './lib/schema/hex.schema' export * from './lib/type/action.type' +export * from './lib/type/data-store.type' export * from './lib/type/domain.type' export * from './lib/type/entity.type' export * from './lib/util/caip.util' diff --git a/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts b/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts new file mode 100644 index 000000000..76e96e62e --- /dev/null +++ b/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { entitiesSchema } from './entity.schema' + +export const jsonWebKeySetSchema = z.object({ + kty: z.string().describe('Key Type (e.g. RSA or EC'), + use: z.string(), + kid: z.string().describe('Arbitrary key ID'), + alg: z.string().describe('Algorithm'), + n: z.string().describe('Key modulus'), + e: z.string().describe('Key exponent') +}) + +export const dataStoreProtocolSchema = z.enum(['file']) + +export const dataStoreConfigurationSchema = z.object({ + dataUrl: z.string(), + signatureUrl: z.string(), + keys: z.array(jsonWebKeySetSchema) +}) + +export const entityDataSchema = z.object({ + entity: z.object({ + data: entitiesSchema + }) +}) + +export const entitySignatureSchema = z.object({ + entity: z.object({ + signature: z.string() + }) +}) + +export const entityJsonWebKeySetSchema = z.object({ + entity: z.object({ + keys: z.array(jsonWebKeySetSchema) + }) +}) diff --git a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts new file mode 100644 index 000000000..af83f0293 --- /dev/null +++ b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts @@ -0,0 +1,83 @@ +import { Alg } from '@narval/signature' +import { z } from 'zod' +import { addressSchema } from './address.schema' + +export const userRoleSchema = z.enum(['ROOT', 'ADMIN', 'MEMBER', 'MANAGER']) + +export const accountTypeSchema = z.enum(['EOA', '4337']) + +export const accountClassificationSchema = z.enum(['EXTERNAL', 'COUNTERPARTY', 'INTERNAL', 'WALLET']) + +export const credentialEntitySchema = z.object({ + id: z.string(), + pubKey: z.string(), + alg: z.nativeEnum(Alg), + userId: z.string() +}) + +export const organizationEntitySchema = z.object({ + id: z.string() +}) + +export const userEntitySchema = z.object({ + id: z.string(), + role: userRoleSchema +}) + +export const userGroupEntitySchema = z.object({ + id: z.string() +}) + +export const userWalletEntitySchema = z.object({ + userId: z.string(), + walletId: z.string() +}) + +export const userGroupMemberEntitySchema = z.object({ + userId: z.string(), + groupId: z.string() +}) + +export const walletEntitySchema = z.object({ + id: z.string(), + address: addressSchema, + accountType: accountTypeSchema, + chainId: z.number().optional() +}) + +export const walletGroupEntitySchema = z.object({ + id: z.string() +}) + +export const walletGroupMemberEntitySchema = z.object({ + walletId: z.string(), + groupId: z.string() +}) + +export const addressBookAccountEntitySchema = z.object({ + id: z.string(), + address: addressSchema, + chainId: z.number(), + classification: accountClassificationSchema +}) + +export const tokenEntitySchema = z.object({ + id: z.string(), + address: addressSchema, + symbol: z.string(), + chainId: z.number(), + decimals: z.number() +}) + +export const entitiesSchema = z.object({ + addressBook: z.array(addressBookAccountEntitySchema), + credentials: z.array(credentialEntitySchema), + tokens: z.array(tokenEntitySchema), + userGroupMembers: z.array(userGroupMemberEntitySchema), + userGroups: z.array(userGroupEntitySchema), + userWallets: z.array(userWalletEntitySchema), + users: z.array(userEntitySchema), + walletGroupMembers: z.array(walletGroupMemberEntitySchema), + walletGroups: z.array(walletGroupEntitySchema), + wallets: z.array(walletEntitySchema) +}) diff --git a/packages/policy-engine-shared/src/lib/type/data-store.type.ts b/packages/policy-engine-shared/src/lib/type/data-store.type.ts new file mode 100644 index 000000000..0b97a6d9e --- /dev/null +++ b/packages/policy-engine-shared/src/lib/type/data-store.type.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { + dataStoreConfigurationSchema, + dataStoreProtocolSchema, + entityDataSchema, + entityJsonWebKeySetSchema, + entitySignatureSchema, + jsonWebKeySetSchema +} from '../schema/data-store.schema' + +export type JsonWebKeySet = z.infer + +export type DataStoreProtocol = z.infer +export const DataStoreProtocol = dataStoreProtocolSchema.Enum + +export type DataStoreConfiguration = z.infer + +export type EntityData = z.infer + +export type EntitySignature = z.infer + +export type EntityJsonWebKeySet = z.infer