diff --git a/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts b/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts index 471163e77..dea950706 100644 --- a/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts +++ b/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts @@ -1,14 +1,16 @@ import { HttpStatus, INestApplication } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import request from 'supertest' import { v4 as uuid } from 'uuid' import { AppModule } from '../../../app/app.module' import { EncryptionService } from '../../../encryption/core/encryption.service' -import { load } from '../../../policy-engine.config' +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 { EngineService } from '../../core/service/engine.service' import { CreateTenantDto } from '../../http/rest/dto/create-tenant.dto' import { TenantRepository } from '../../persistence/repository/tenant.repository' @@ -18,6 +20,10 @@ describe('Tenant', () => { let testPrismaService: TestPrismaService let tenantRepository: TenantRepository let encryptionService: EncryptionService + let engineService: EngineService + let configService: ConfigService + + const adminApiKey = 'test-admin-api-key' beforeAll(async () => { module = await Test.createTestingModule({ @@ -35,11 +41,20 @@ describe('Tenant', () => { app = module.createNestApplication() + engineService = module.get(EngineService) tenantRepository = module.get(TenantRepository) testPrismaService = module.get(TestPrismaService) encryptionService = module.get(EncryptionService) + configService = module.get>(ConfigService) + + await testPrismaService.truncateAll() + await encryptionService.setup() - await module.get(EncryptionService).onApplicationBootstrap() + await engineService.create({ + id: configService.get('engine.id', { infer: true }), + masterKey: 'unsafe-test-master-key', + adminApiKey + }) await app.init() }) @@ -50,11 +65,6 @@ describe('Tenant', () => { await app.close() }) - beforeEach(async () => { - await testPrismaService.truncateAll() - await encryptionService.onApplicationBootstrap() - }) - describe('POST /tenants', () => { const clientId = uuid() @@ -70,7 +80,10 @@ describe('Tenant', () => { } it('creates a new tenant', async () => { - const { status, body } = await request(app.getHttpServer()).post('/tenants').send(payload) + 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({ @@ -98,8 +111,12 @@ describe('Tenant', () => { }) it('responds with an error when clientId already exist', async () => { - await request(app.getHttpServer()).post('/tenants').send(payload) - const { status, body } = await request(app.getHttpServer()).post('/tenants').send(payload) + 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', @@ -107,5 +124,18 @@ describe('Tenant', () => { }) 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/app/app.module.ts b/apps/policy-engine/src/app/app.module.ts index 386162adc..7a3e3ea2d 100644 --- a/apps/policy-engine/src/app/app.module.ts +++ b/apps/policy-engine/src/app/app.module.ts @@ -8,10 +8,12 @@ import { KeyValueModule } from '../shared/module/key-value/key-value.module' import { AppController } from './app.controller' import { AppService } from './app.service' import { AdminService } from './core/admin.service' +import { EngineService } from './core/service/engine.service' import { TenantService } from './core/service/tenant.service' import { AdminController } from './http/rest/controller/admin.controller' 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 { TenantRepository } from './persistence/repository/tenant.repository' @@ -30,6 +32,8 @@ import { TenantRepository } from './persistence/repository/tenant.repository' AppService, AdminService, OpaService, + EngineRepository, + EngineService, EntityRepository, TenantRepository, TenantService, diff --git a/apps/policy-engine/src/app/core/exception/engine-not-provisioned.exception.ts b/apps/policy-engine/src/app/core/exception/engine-not-provisioned.exception.ts new file mode 100644 index 000000000..d20a096c1 --- /dev/null +++ b/apps/policy-engine/src/app/core/exception/engine-not-provisioned.exception.ts @@ -0,0 +1,11 @@ +import { HttpStatus } from '@nestjs/common' +import { ApplicationException } from '../../../shared/exception/application.exception' + +export class EngineNotProvisionedException extends ApplicationException { + constructor() { + super({ + message: 'The policy engine instance was not provisioned', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR + }) + } +} diff --git a/apps/policy-engine/src/app/core/service/engine.service.ts b/apps/policy-engine/src/app/core/service/engine.service.ts new file mode 100644 index 000000000..a943c586a --- /dev/null +++ b/apps/policy-engine/src/app/core/service/engine.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Config } from '../../../policy-engine.config' +import { Engine } from '../../../shared/types/domain.type' +import { EngineRepository } from '../../persistence/repository/engine.repository' +import { EngineNotProvisionedException } from '../exception/engine-not-provisioned.exception' + +@Injectable() +export class EngineService { + constructor( + private configService: ConfigService, + private engineRepository: EngineRepository + ) {} + + async getEngine(): Promise { + const engine = await this.engineRepository.findById(this.getId()) + + if (engine) { + return engine + } + + throw new EngineNotProvisionedException() + } + + async create(engine: Engine): Promise { + return this.engineRepository.create(engine) + } + + private getId(): string { + return this.configService.get('engine.id', { infer: true }) + } +} diff --git a/apps/policy-engine/src/app/http/rest/controller/tenant.controller.ts b/apps/policy-engine/src/app/http/rest/controller/tenant.controller.ts index f0142bdf0..5a43596c6 100644 --- a/apps/policy-engine/src/app/http/rest/controller/tenant.controller.ts +++ b/apps/policy-engine/src/app/http/rest/controller/tenant.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, Post } from '@nestjs/common' +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) {} diff --git a/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts b/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts new file mode 100644 index 000000000..fa737ef8f --- /dev/null +++ b/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts @@ -0,0 +1,55 @@ +import { Test } from '@nestjs/testing' +import { EncryptionModule } from '../../../../../encryption/encryption.module' +import { ApplicationException } from '../../../../../shared/exception/application.exception' +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 { Engine } from '../../../../../shared/types/domain.type' +import { EngineRepository } from '../../engine.repository' + +describe(EngineRepository.name, () => { + let repository: EngineRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [EncryptionModule], + providers: [ + KeyValueService, + EngineRepository, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + repository = module.get(EngineRepository) + }) + + describe('create', () => { + const engine: Engine = { + id: 'test-engine-id', + adminApiKey: 'unsafe-test-admin-api-key', + masterKey: 'unsafe-test-master-key' + } + + it('creates a new engine', async () => { + await repository.create(engine) + + const value = await inMemoryKeyValueRepository.get(repository.getKey(engine.id)) + const actualEngine = await repository.findById(engine.id) + + expect(value).not.toEqual(null) + expect(engine).toEqual(actualEngine) + }) + + it('throws an error when engine is duplicate', async () => { + await repository.create(engine) + + await expect(repository.create(engine)).rejects.toThrow(ApplicationException) + }) + }) +}) 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 index cfc8fc1cb..22722b12b 100644 --- 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 @@ -45,7 +45,7 @@ describe(TenantRepository.name, () => { // // TODO: Refactor the encryption service. It MUST be ready for usage given // its arguments rather than depending on a set up step. - await module.get(EncryptionService).onApplicationBootstrap() + await module.get(EncryptionService).setup() repository = module.get(TenantRepository) }) diff --git a/apps/policy-engine/src/app/persistence/repository/engine.repository.ts b/apps/policy-engine/src/app/persistence/repository/engine.repository.ts new file mode 100644 index 000000000..f68a0a2fd --- /dev/null +++ b/apps/policy-engine/src/app/persistence/repository/engine.repository.ts @@ -0,0 +1,45 @@ +import { HttpStatus, Injectable } from '@nestjs/common' +import { ApplicationException } from '../../../shared/exception/application.exception' +import { KeyValueService } from '../../../shared/module/key-value/core/service/key-value.service' +import { engineSchema } from '../../../shared/schema/engine.schema' +import { Engine } from '../../../shared/types/domain.type' + +@Injectable() +export class EngineRepository { + constructor(private keyValueService: KeyValueService) {} + + async findById(id: string): Promise { + const value = await this.keyValueService.get(this.getKey(id)) + + if (value) { + return this.decode(value) + } + + return null + } + + async create(engine: Engine): Promise { + if (await this.keyValueService.get(this.getKey(engine.id))) { + throw new ApplicationException({ + message: 'Engine already exist', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR + }) + } + + await this.keyValueService.set(this.getKey(engine.id), this.encode(engine)) + + return engine + } + + getKey(id: string): string { + return `engine:${id}` + } + + private encode(engine: Engine): string { + return JSON.stringify(engine) + } + + private decode(value: string): Engine { + return engineSchema.parse(JSON.parse(value)) + } +} diff --git a/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts index 1ede4c633..344b63eb2 100644 --- a/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts +++ b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts @@ -24,7 +24,7 @@ export class TenantRepository { } getKey(clientId: string): string { - return `${clientId}:tenant` + return `tenant:${clientId}` } private encode(tenant: Tenant): string { diff --git a/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts b/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts index 0e6afadf4..49c1389ea 100644 --- a/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts +++ b/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts @@ -55,8 +55,8 @@ describe('EncryptionService', () => { await testPrismaService.truncateAll() - if (service.onApplicationBootstrap) { - await service.onApplicationBootstrap() + if (service.setup) { + await service.setup() } }) @@ -66,7 +66,7 @@ describe('EncryptionService', () => { }) it('should create & encrypt a master key on application bootstrap', async () => { - await service.onApplicationBootstrap() + await service.setup() const engine = await testPrismaService.getClient().engine.findFirst({ where: { diff --git a/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts b/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts index aafb327cc..d5cb6d1ba 100644 --- a/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts +++ b/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts @@ -57,8 +57,9 @@ describe('EncryptionService', () => { }).compile() service = moduleRef.get(EncryptionService) - if (service.onApplicationBootstrap) { - await service.onApplicationBootstrap() + + if (service.setup) { + await service.setup() } }) diff --git a/apps/policy-engine/src/encryption/core/encryption.service.ts b/apps/policy-engine/src/encryption/core/encryption.service.ts index f074e436a..cc9e0f2b6 100644 --- a/apps/policy-engine/src/encryption/core/encryption.service.ts +++ b/apps/policy-engine/src/encryption/core/encryption.service.ts @@ -6,7 +6,7 @@ import { buildClient } from '@aws-crypto/client-node' import { Hex, toBytes, toHex } from '@narval/policy-engine-shared' -import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' +import { Inject, Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import crypto from 'crypto' import { Config } from '../../policy-engine.config' @@ -22,7 +22,7 @@ const defaultEncryptionContext = { const { encrypt, decrypt } = buildClient(commitmentPolicy) @Injectable() -export class EncryptionService implements OnApplicationBootstrap { +export class EncryptionService { private logger = new Logger(EncryptionService.name) private configService: ConfigService @@ -39,12 +39,12 @@ export class EncryptionService implements OnApplicationBootstrap { this.engineId = configService.get('engine.id', { infer: true }) } - async onApplicationBootstrap(): Promise { - this.logger.log('Keyring Service boot') + async setup(): Promise { + this.logger.log('Set up keyring') const keyringConfig = this.configService.get('keyring', { infer: true }) // We have a Raw Keyring, so we are using a MasterPassword/KEK+MasterKey for encryption - if (keyringConfig.masterPassword && !keyringConfig.masterAwsKmsArn) { + if (keyringConfig.type === 'raw') { const engine = await this.encryptionRepository.getEngine(this.engineId) let encryptedMasterKey = engine?.masterKey @@ -72,7 +72,7 @@ export class EncryptionService implements OnApplicationBootstrap { this.keyring = keyring } // We have AWS KMS config so we'll use that instead as the MasterKey, which means we don't need a KEK separately - else if (keyringConfig.masterAwsKmsArn && !keyringConfig.masterPassword) { + else if (keyringConfig.type === 'awskms') { const keyring = new KmsKeyringNode({ generatorKeyId: keyringConfig.masterAwsKmsArn }) this.keyring = keyring } else { diff --git a/apps/policy-engine/src/policy-engine.config.ts b/apps/policy-engine/src/policy-engine.config.ts index 3f6e8c03e..72c6175bb 100644 --- a/apps/policy-engine/src/policy-engine.config.ts +++ b/apps/policy-engine/src/policy-engine.config.ts @@ -6,7 +6,7 @@ export enum Env { PRODUCTION = 'production' } -const ConfigSchema = z.object({ +const configSchema = z.object({ env: z.nativeEnum(Env), port: z.coerce.number(), database: z.object({ @@ -15,17 +15,22 @@ const ConfigSchema = z.object({ engine: z.object({ id: z.string() }), - keyring: z.object({ - type: z.enum(['awskms', 'raw']).default('raw'), - masterAwsKmsArn: z.string().optional(), // only if type = awskms - masterPassword: z.string().optional() // only if type = raw - }) + keyring: z.union([ + z.object({ + type: z.literal('raw'), + masterPassword: z.string() + }), + z.object({ + type: z.literal('awskms'), + masterAwsKmsArn: z.string() + }) + ]) }) -export type Config = z.infer +export type Config = z.infer export const load = (): Config => { - const result = ConfigSchema.safeParse({ + const result = configSchema.safeParse({ env: process.env.NODE_ENV, port: process.env.PORT, database: { diff --git a/apps/policy-engine/src/policy-engine.constant.ts b/apps/policy-engine/src/policy-engine.constant.ts new file mode 100644 index 000000000..206027506 --- /dev/null +++ b/apps/policy-engine/src/policy-engine.constant.ts @@ -0,0 +1 @@ +export const REQUEST_HEADER_API_KEY = 'x-api-key' diff --git a/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts b/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts new file mode 100644 index 000000000..977d3abb0 --- /dev/null +++ b/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts @@ -0,0 +1,51 @@ +import { ExecutionContext } from '@nestjs/common' +import { mock } from 'jest-mock-extended' +import { EngineService } from '../../../../app/core/service/engine.service' +import { REQUEST_HEADER_API_KEY } from '../../../../policy-engine.constant' +import { ApplicationException } from '../../../exception/application.exception' +import { AdminApiKeyGuard } from '../../admin-api-key.guard' + +describe(AdminApiKeyGuard.name, () => { + const mockExecutionContext = (apiKey?: string) => { + const headers = { + [REQUEST_HEADER_API_KEY]: apiKey + } + const request = { headers } + + return { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + } + + const mockEngineService = (adminApiKey: string = 'test-admin-api-key') => { + const engineService = mock() + engineService.getEngine.mockResolvedValue({ + adminApiKey, + id: 'test-engine-id', + masterKey: 'test-master-key' + }) + + return engineService + } + + it(`throws an error when ${REQUEST_HEADER_API_KEY} header is missing`, async () => { + const guard = new AdminApiKeyGuard(mockEngineService()) + + await expect(guard.canActivate(mockExecutionContext())).rejects.toThrow(ApplicationException) + }) + + it(`returns true when ${REQUEST_HEADER_API_KEY} matches the engine admin api key`, async () => { + const adminApiKey = 'test-admin-api-key' + const guard = new AdminApiKeyGuard(mockEngineService(adminApiKey)) + + expect(await guard.canActivate(mockExecutionContext(adminApiKey))).toEqual(true) + }) + + it(`returns false when ${REQUEST_HEADER_API_KEY} does not matche the engine admin api key`, async () => { + const guard = new AdminApiKeyGuard(mockEngineService('test-admin-api-key')) + + expect(await guard.canActivate(mockExecutionContext('another-api-key'))).toEqual(false) + }) +}) diff --git a/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts b/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts new file mode 100644 index 000000000..9ef97d01a --- /dev/null +++ b/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts @@ -0,0 +1,25 @@ +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common' +import { EngineService } from '../../app/core/service/engine.service' +import { REQUEST_HEADER_API_KEY } from '../../policy-engine.constant' +import { ApplicationException } from '../exception/application.exception' + +@Injectable() +export class AdminApiKeyGuard implements CanActivate { + constructor(private engineService: EngineService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const apiKey = req.headers[REQUEST_HEADER_API_KEY] + + if (!apiKey) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_API_KEY} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + + const engine = await this.engineService.getEngine() + + return engine.adminApiKey === apiKey + } +} diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts index eb82a7f87..6611c7106 100644 --- a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts +++ b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts @@ -1,6 +1,5 @@ import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' -import { EncryptionService } from '../../../../../../../encryption/core/encryption.service' import { EncryptionModule } from '../../../../../../../encryption/encryption.module' import { load } from '../../../../../../../policy-engine.config' import { TestPrismaService } from '../../../../../../../shared/module/persistence/service/test-prisma.service' @@ -40,11 +39,6 @@ describe(KeyValueService.name, () => { testPrismaService = module.get(TestPrismaService) await testPrismaService.truncateAll() - - // TODO: (@wcalderipe, 05/03/24): The onApplicationBootstrap performs - // multiple side-effects including writing to the storage to set up the - // encryption. - await module.get(EncryptionService).onApplicationBootstrap() }) afterAll(async () => {