Skip to content

Commit

Permalink
Check admin API key to create a tenant (#154)
Browse files Browse the repository at this point in the history
* Check admin API key to create a tenant

* Fix import path
  • Loading branch information
wcalderipe authored Mar 5, 2024
1 parent d93d654 commit 0473682
Show file tree
Hide file tree
Showing 17 changed files with 295 additions and 39 deletions.
52 changes: 41 additions & 11 deletions apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -18,6 +20,10 @@ describe('Tenant', () => {
let testPrismaService: TestPrismaService
let tenantRepository: TenantRepository
let encryptionService: EncryptionService
let engineService: EngineService
let configService: ConfigService<Config, true>

const adminApiKey = 'test-admin-api-key'

beforeAll(async () => {
module = await Test.createTestingModule({
Expand All @@ -35,11 +41,20 @@ describe('Tenant', () => {

app = module.createNestApplication()

engineService = module.get<EngineService>(EngineService)
tenantRepository = module.get<TenantRepository>(TenantRepository)
testPrismaService = module.get<TestPrismaService>(TestPrismaService)
encryptionService = module.get<EncryptionService>(EncryptionService)
configService = module.get<ConfigService<Config, true>>(ConfigService)

await testPrismaService.truncateAll()
await encryptionService.setup()

await module.get<EncryptionService>(EncryptionService).onApplicationBootstrap()
await engineService.create({
id: configService.get('engine.id', { infer: true }),
masterKey: 'unsafe-test-master-key',
adminApiKey
})

await app.init()
})
Expand All @@ -50,11 +65,6 @@ describe('Tenant', () => {
await app.close()
})

beforeEach(async () => {
await testPrismaService.truncateAll()
await encryptionService.onApplicationBootstrap()
})

describe('POST /tenants', () => {
const clientId = uuid()

Expand All @@ -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({
Expand Down Expand Up @@ -98,14 +111,31 @@ 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',
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)
})
})
})
4 changes: 4 additions & 0 deletions apps/policy-engine/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -30,6 +32,8 @@ import { TenantRepository } from './persistence/repository/tenant.repository'
AppService,
AdminService,
OpaService,
EngineRepository,
EngineService,
EntityRepository,
TenantRepository,
TenantService,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
})
}
}
32 changes: 32 additions & 0 deletions apps/policy-engine/src/app/core/service/engine.service.ts
Original file line number Diff line number Diff line change
@@ -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<Config, true>,
private engineRepository: EngineRepository
) {}

async getEngine(): Promise<Engine> {
const engine = await this.engineRepository.findById(this.getId())

if (engine) {
return engine
}

throw new EngineNotProvisionedException()
}

async create(engine: Engine): Promise<Engine> {
return this.engineRepository.create(engine)
}

private getId(): string {
return this.configService.get('engine.id', { infer: true })
}
}
Original file line number Diff line number Diff line change
@@ -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) {}

Expand Down
Original file line number Diff line number Diff line change
@@ -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>(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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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>(EncryptionService).onApplicationBootstrap()
await module.get<EncryptionService>(EncryptionService).setup()

repository = module.get<TenantRepository>(TenantRepository)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Engine | null> {
const value = await this.keyValueService.get(this.getKey(id))

if (value) {
return this.decode(value)
}

return null
}

async create(engine: Engine): Promise<Engine> {
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class TenantRepository {
}

getKey(clientId: string): string {
return `${clientId}:tenant`
return `tenant:${clientId}`
}

private encode(tenant: Tenant): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ describe('EncryptionService', () => {

await testPrismaService.truncateAll()

if (service.onApplicationBootstrap) {
await service.onApplicationBootstrap()
if (service.setup) {
await service.setup()
}
})

Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ describe('EncryptionService', () => {
}).compile()

service = moduleRef.get<EncryptionService>(EncryptionService)
if (service.onApplicationBootstrap) {
await service.onApplicationBootstrap()

if (service.setup) {
await service.setup()
}
})

Expand Down
12 changes: 6 additions & 6 deletions apps/policy-engine/src/encryption/core/encryption.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Config, true>
Expand All @@ -39,12 +39,12 @@ export class EncryptionService implements OnApplicationBootstrap {
this.engineId = configService.get('engine.id', { infer: true })
}

async onApplicationBootstrap(): Promise<void> {
this.logger.log('Keyring Service boot')
async setup(): Promise<void> {
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

Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 0473682

Please sign in to comment.