Skip to content

Commit

Permalink
Minimal create tenant endpoint (#153)
Browse files Browse the repository at this point in the history
  • Loading branch information
wcalderipe authored Mar 5, 2024
1 parent 81a1063 commit 3686ce0
Show file tree
Hide file tree
Showing 22 changed files with 339 additions and 21 deletions.
111 changes: 111 additions & 0 deletions apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { HttpStatus, INestApplication } from '@nestjs/common'
import { ConfigModule } 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 { 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 { CreateTenantDto } from '../../http/rest/dto/create-tenant.dto'
import { TenantRepository } from '../../persistence/repository/tenant.repository'

describe('Tenant', () => {
let app: INestApplication
let module: TestingModule
let testPrismaService: TestPrismaService
let tenantRepository: TenantRepository
let encryptionService: EncryptionService

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
load: [load],
isGlobal: true
}),
AppModule
]
})
.overrideProvider(KeyValueRepository)
.useValue(new InMemoryKeyValueRepository())
.compile()

app = module.createNestApplication()

tenantRepository = module.get<TenantRepository>(TenantRepository)
testPrismaService = module.get<TestPrismaService>(TestPrismaService)
encryptionService = module.get<EncryptionService>(EncryptionService)

await module.get<EncryptionService>(EncryptionService).onApplicationBootstrap()

await app.init()
})

afterAll(async () => {
await testPrismaService.truncateAll()
await module.close()
await app.close()
})

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

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').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').send(payload)
const { status, body } = await request(app.getHttpServer()).post('/tenants').send(payload)

expect(body).toEqual({
message: 'Tenant already exist',
statusCode: HttpStatus.BAD_REQUEST
})
expect(status).toEqual(HttpStatus.BAD_REQUEST)
})
})
})
11 changes: 9 additions & 2 deletions apps/policy-engine/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import { ConfigModule } from '@nestjs/config'
import { APP_PIPE } from '@nestjs/core'
import { EncryptionModule } from '../encryption/encryption.module'
import { load } from '../policy-engine.config'
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 { 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 { EntityRepository } from './persistence/repository/entity.repository'
import { TenantRepository } from './persistence/repository/tenant.repository'

@Module({
imports: [
Expand All @@ -18,14 +22,17 @@ import { EntityRepository } from './persistence/repository/entity.repository'
isGlobal: true
}),
HttpModule,
EncryptionModule
EncryptionModule,
KeyValueModule
],
controllers: [AppController, AdminController],
controllers: [AppController, AdminController, TenantController],
providers: [
AppService,
AdminService,
OpaService,
EntityRepository,
TenantRepository,
TenantService,
{
provide: APP_PIPE,
useClass: ValidationPipe
Expand Down
25 changes: 25 additions & 0 deletions apps/policy-engine/src/app/core/service/tenant.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { HttpStatus, Injectable } from '@nestjs/common'
import { ApplicationException } from '../../../shared/exception/application.exception'
import { Tenant } from '../../../shared/types/domain.type'
import { TenantRepository } from '../../persistence/repository/tenant.repository'

@Injectable()
export class TenantService {
constructor(private tenantRepository: TenantRepository) {}

async findByClientId(clientId: string): Promise<Tenant | null> {
return this.tenantRepository.findByClientId(clientId)
}

async create(tenant: Tenant): Promise<Tenant> {
if (await this.tenantRepository.findByClientId(tenant.clientId)) {
throw new ApplicationException({
message: 'Tenant already exist',
suggestedHttpStatusCode: HttpStatus.BAD_REQUEST,
context: { clientId: tenant.clientId }
})
}

return this.tenantRepository.create(tenant)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Body, Controller, Post } from '@nestjs/common'
import { randomBytes } from 'crypto'
import { v4 as uuid } from 'uuid'
import { TenantService } from '../../../core/service/tenant.service'
import { CreateTenantDto } from '../dto/create-tenant.dto'

@Controller('/tenants')
export class TenantController {
constructor(private tenantService: TenantService) {}

@Post()
async create(@Body() body: CreateTenantDto) {
const now = new Date()

const tenant = await this.tenantService.create({
clientId: body.clientId || uuid(),
clientSecret: randomBytes(42).toString('hex'),
dataStore: {
entity: {
...body.entityDataStore,
keys: []
},
policy: {
...body.policyDataStore,
keys: []
}
},
createdAt: now,
updatedAt: now
})

return tenant
}
}
24 changes: 24 additions & 0 deletions apps/policy-engine/src/app/http/rest/dto/create-tenant.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 8 additions & 3 deletions apps/policy-engine/src/app/opa/opa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,14 @@ export class OpaService implements OnApplicationBootstrap {

async reloadEntityData() {
if (!this.opaEngine) throw new Error('OPA Engine not initialized')
const data = await this.fetchEntityData()
this.opaEngine.setData(data)
this.logger.log('Reloaded OPA Engine data')

try {
const data = await this.fetchEntityData()
this.opaEngine.setData(data)
this.logger.log('Reloaded OPA Engine data')
} catch (error) {
this.logger.error('Failed to bootstrap OPA service')
}
}

private async getOpaEngine(): Promise<OpaEngine> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { DataStoreConfiguration } from '@narval/policy-engine-shared'
import { Test } from '@nestjs/testing'
import { mock } from 'jest-mock-extended'
import { EncryptionService } from '../../../../../encryption/core/encryption.service'
import { EncryptionModule } from '../../../../../encryption/encryption.module'
import { EncryptionRepository } from '../../../../../encryption/persistence/repository/encryption.repository'
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'
Expand All @@ -13,17 +17,36 @@ describe(TenantRepository.name, () => {
beforeEach(async () => {
inMemoryKeyValueRepository = new InMemoryKeyValueRepository()

const encryptionRepository = mock<EncryptionRepository>()
encryptionRepository.getEngine.mockResolvedValue({
id: 'test-engine',
masterKey: 'unsafe-test-master-key',
adminApiKey: 'unsafe-test-api-key'
})

const module = await Test.createTestingModule({
imports: [EncryptionModule],
providers: [
KeyValueService,
TenantRepository,
{
provide: EncryptionRepository,
useValue: encryptionRepository
},
{
provide: KeyValueRepository,
useValue: inMemoryKeyValueRepository
}
]
}).compile()

// IMPORTANT: The onApplicationBootstrap performs several side-effects to
// set up the encryption.
//
// 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()

repository = module.get<TenantRepository>(TenantRepository)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ export class EntityRepository implements OnApplicationBootstrap {
// TODO (@wcalderipe, 15/02/24): Figure out where the organization will come
// from. It depends on the deployment model: standalone engine per
// organization or cluster with multi tenant.
if (!this.entities) {
const entities = await this.fetch(FIXTURE.ORGANIZATION.id)

this.entities = entities
try {
if (!this.entities) {
const entities = await this.fetch(FIXTURE.ORGANIZATION.id)

this.entities = entities
}
} catch (error) {
this.logger.error('Failed to bootstrap entities', error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ describe('EncryptionService', () => {
service = module.get<EncryptionService>(EncryptionService)
testPrismaService = module.get<TestPrismaService>(TestPrismaService)

await testPrismaService.truncateAll()

if (service.onApplicationBootstrap) {
await service.onApplicationBootstrap()
}
Expand All @@ -71,6 +73,7 @@ describe('EncryptionService', () => {
id: 'local-dev-engine-instance-1'
}
})

expect(engine?.masterKey).toBeDefined()
})
})
3 changes: 2 additions & 1 deletion apps/policy-engine/src/encryption/encryption.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { EncryptionRepository } from './persistence/repository/encryption.reposi
provide: APP_PIPE,
useClass: ValidationPipe
}
]
],
exports: [EncryptionService]
})
export class EncryptionModule {}
25 changes: 25 additions & 0 deletions apps/policy-engine/src/shared/exception/application.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { HttpException, HttpStatus } from '@nestjs/common'

export type ApplicationExceptionParams = {
message: string
suggestedHttpStatusCode: HttpStatus
context?: unknown
origin?: Error
}

export class ApplicationException extends HttpException {
readonly context: unknown
readonly origin?: Error

constructor(params: ApplicationExceptionParams) {
super(params.message, params.suggestedHttpStatusCode)

if (Error.captureStackTrace) {
Error.captureStackTrace(this, ApplicationException)
}

this.name = this.constructor.name
this.context = params.context
this.origin = params.origin
}
}
Loading

0 comments on commit 3686ce0

Please sign in to comment.