Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add managed data store to armory app #233

Merged
merged 35 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
eeef6c9
setup new admin module
Apr 15, 2024
a73cd29
Merge remote-tracking branch 'origin/main' into feature/nar-1581-crea…
Apr 15, 2024
ecc5deb
Merge remote-tracking branch 'origin/main' into feature/nar-1581-crea…
Apr 15, 2024
93c7393
Merge remote-tracking branch 'origin/main' into feature/nar-1581-crea…
Apr 15, 2024
ad783ef
add prisma schema
Apr 15, 2024
775cf16
update schema
Apr 16, 2024
8f776ac
add policy model
Apr 16, 2024
2c6d7f3
add comment
Apr 16, 2024
8c4eef8
add signature model
Apr 16, 2024
59d23ac
wip
Apr 17, 2024
af421fe
fix
Apr 17, 2024
5af09d4
revert
Apr 17, 2024
04281ad
revert
Apr 17, 2024
e16051a
CR
Apr 17, 2024
62ef776
Merge remote-tracking branch 'origin/main' into bootstrap-admin-service
Apr 17, 2024
37f8e6d
remove resourceId from data store action
Apr 17, 2024
316be9e
Add headers to data store config (#234)
Apr 19, 2024
62366f2
Add sending evaluation request
Apr 19, 2024
13dd168
fix
Apr 19, 2024
ce0e6d7
fix
Apr 19, 2024
46a4de7
fix
Apr 19, 2024
08bf013
Update apps/armory/src/admin/core/service/entity-data-store.service.ts
Apr 19, 2024
8125622
Update apps/armory/src/orchestration/core/service/cluster.service.ts
Apr 19, 2024
739ff94
fixes after CR
Apr 19, 2024
4d2d4fd
fix
Apr 19, 2024
78a1f20
fix circular dependency
Apr 19, 2024
fc120d6
fixes after CR
May 3, 2024
5ae2e11
Merge remote-tracking branch 'origin/main' into bootstrap-admin-service
May 3, 2024
b417f77
table fixes
May 6, 2024
a2a0383
Add signature unit test
May 6, 2024
23dd945
fix
May 6, 2024
0c4888e
add parser when setting data store
May 6, 2024
5719e53
fix devtool
May 6, 2024
3c4eee9
Merge remote-tracking branch 'origin/main' into bootstrap-admin-service
May 6, 2024
892c5f7
last CR
May 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/armory/src/armory.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum Env {
const configSchema = z.object({
env: z.nativeEnum(Env),
port: z.coerce.number(),
cors: z.array(z.string()).optional(),
database: z.object({
url: z.string().startsWith('postgresql://')
}),
Expand All @@ -28,6 +29,7 @@ export const load = (): Config => {
const result = configSchema.safeParse({
env: process.env.NODE_ENV,
port: process.env.PORT,
cors: process.env.CORS ? process.env.CORS.split(',') : [],
database: {
url: process.env.ARMORY_DATABASE_URL
},
Expand Down
4 changes: 3 additions & 1 deletion apps/armory/src/armory.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ClassSerializerInterceptor, Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { load } from './armory.config'
import { ManagedDataStoreModule } from './managed-data-store/managed-data-store.module'
import { OrchestrationModule } from './orchestration/orchestration.module'
import { QueueModule } from './shared/module/queue/queue.module'
import { TransferTrackingModule } from './transfer-tracking/transfer-tracking.module'
Expand All @@ -14,7 +15,8 @@ import { TransferTrackingModule } from './transfer-tracking/transfer-tracking.mo
}),
QueueModule.forRoot(),
OrchestrationModule,
TransferTrackingModule
TransferTrackingModule,
ManagedDataStoreModule
],
providers: [
{
Expand Down
3 changes: 2 additions & 1 deletion apps/armory/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withSwagger } from '@narval/nestjs-shared'
import { withCors, withSwagger } from '@narval/nestjs-shared'
import { ClassSerializerInterceptor, INestApplication, Logger, ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory, Reflector } from '@nestjs/core'
Expand Down Expand Up @@ -76,6 +76,7 @@ async function bootstrap(): Promise<void> {
map(withGlobalPipes),
map(withGlobalInterceptors),
map(withGlobalFilters(configService)),
map(withCors(configService.get('cors'))),
switchMap((app) => app.listen(port))
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { FIXTURE } from '@narval/policy-engine-shared'
import { Payload, SigningAlg, buildSignerEip191, hash, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature'
import { ApplicationException } from '../../../../shared/exception/application.exception'
import { SignatureService } from '../../service/signature.service'

describe(SignatureService.name, () => {
const signatureService = new SignatureService()
const DATA_STORE_PRIVATE_KEY = '7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5'
const jwk = secp256k1PrivateKeyToJwk(`0x${DATA_STORE_PRIVATE_KEY}`)

it('throws an exception if the payload iat is older than the db createdAt date', async () => {
const payload: Payload = {
data: hash(FIXTURE.ENTITIES),
sub: 'test-root-user-uid',
iss: 'https://armory.narval.xyz',
iat: Math.floor(new Date('2023-01-01').getTime() / 1000) // in seconds
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure we always add the iat claim using seconds?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from my research seconds are the standard. See this RFC.

}

const signature = await signJwt(payload, jwk, { alg: SigningAlg.EIP191 }, buildSignerEip191(DATA_STORE_PRIVATE_KEY))

await expect(() =>
signatureService.verifySignature({
pubKey: jwk,
payload: { signature, data: FIXTURE.ENTITIES },
date: new Date('2024-01-01')
})
).rejects.toThrow(ApplicationException)
})

it('returns true if the payload iat is more recent than the db createdAt date', async () => {
const payload: Payload = {
data: hash(FIXTURE.ENTITIES),
sub: 'test-root-user-uid',
iss: 'https://armory.narval.xyz',
iat: Math.floor(new Date('2024-01-01').getTime() / 1000) // in seconds
}

const signature = await signJwt(payload, jwk, { alg: SigningAlg.EIP191 }, buildSignerEip191(DATA_STORE_PRIVATE_KEY))

const result = await signatureService.verifySignature({
pubKey: jwk,
payload: { signature, data: FIXTURE.ENTITIES },
date: new Date('2023-01-01')
})

expect(result).toEqual(true)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Entities, EntityStore } from '@narval/policy-engine-shared'
import { publicKeySchema } from '@narval/signature'
import { HttpStatus, Injectable, NotFoundException } from '@nestjs/common'
import { ClientRepository } from '../../persistence/repository/client.repository'
import { EntityDataStoreRepository } from '../../persistence/repository/entity-data-store.repository'
import { SignatureService } from './signature.service'

@Injectable()
export class EntityDataStoreService extends SignatureService<Entities> {
constructor(
private entitydataStoreRepository: EntityDataStoreRepository,
private clientRepository: ClientRepository
) {
super()
}

async getEntities(orgId: string): Promise<EntityStore | null> {
const entityStore = await this.entitydataStoreRepository.getLatestDataStore(orgId)

return entityStore ? EntityStore.parse(entityStore.data) : null
}

async setEntities(orgId: string, payload: EntityStore) {
const client = await this.clientRepository.getClient(orgId)

if (!client) {
throw new NotFoundException({
message: 'Client data not found',
suggestedHttpStatusCode: HttpStatus.NOT_FOUND
})
}

const dataStore = await this.entitydataStoreRepository.getLatestDataStore(orgId)

await this.verifySignature({
payload,
pubKey: publicKeySchema.parse(client.entityPublicKey),
date: dataStore?.createdAt
})

return this.entitydataStoreRepository.setDataStore(orgId, {
version: dataStore?.version ? dataStore.version + 1 : 1,
data: EntityStore.parse(payload)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Policy, PolicyStore } from '@narval/policy-engine-shared'
import { publicKeySchema } from '@narval/signature'
import { HttpStatus, Injectable, NotFoundException } from '@nestjs/common'
import { ClientRepository } from '../../persistence/repository/client.repository'
import { PolicyDataStoreRepository } from '../../persistence/repository/policy-data-store.repository'
import { SignatureService } from './signature.service'

@Injectable()
export class PolicyDataStoreService extends SignatureService<Policy[]> {
constructor(
private policyDataStoreRepository: PolicyDataStoreRepository,
private clientRepository: ClientRepository
) {
super()
}

async getPolicies(orgId: string): Promise<PolicyStore | null> {
const policyStore = await this.policyDataStoreRepository.getLatestDataStore(orgId)

return policyStore ? PolicyStore.parse(policyStore.data) : null
}

async setPolicies(orgId: string, payload: PolicyStore) {
const client = await this.clientRepository.getClient(orgId)

if (!client) {
throw new NotFoundException({
message: 'Client data not found',
suggestedHttpStatusCode: HttpStatus.NOT_FOUND
})
}

const dataStore = await this.policyDataStoreRepository.getLatestDataStore(orgId)

await this.verifySignature({
payload,
pubKey: publicKeySchema.parse(client.policyPublicKey),
date: dataStore?.createdAt
})

return this.policyDataStoreRepository.setDataStore(orgId, {
version: dataStore?.version ? dataStore.version + 1 : 1,
data: PolicyStore.parse(payload)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Jwk, hash, verifyJwt } from '@narval/signature'
import { HttpStatus, Injectable } from '@nestjs/common'
import { ApplicationException } from '../../../shared/exception/application.exception'

@Injectable()
export class SignatureService<T> {
async verifySignature({
pubKey,
payload,
date
}: {
pubKey: Jwk
payload: { signature: string; data: T }
date: Date | undefined
}) {
const validJwt = await verifyJwt(payload.signature, pubKey)

if (validJwt.payload.data !== hash(payload.data)) {
throw new ApplicationException({
message: 'Signature hash mismatch',
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}

if (date && validJwt.payload.iat && validJwt.payload.iat < date.getTime() / 1000) {
throw new ApplicationException({
message: 'Signature timestamp mismatch',
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}

return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Entities, EntityStore, JwtString, Policy, PolicyStore } from '@narval/policy-engine-shared'
import { Body, Controller, Get, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { OrgId } from '../../../shared/decorator/org-id.decorator'
import { EntityDataStoreService } from '../../core/service/entity-data-store.service'
import { PolicyDataStoreService } from '../../core/service/policy-data-store.service'

@Controller('/managed-data-store')
@ApiTags('Managed Data Store')
export class DataStoreController {
constructor(
private entityDataStoreService: EntityDataStoreService,
private policyDataStoreService: PolicyDataStoreService
) {}

@Get('/entities')
async getEntities(@OrgId() orgId: string): Promise<{ entity: EntityStore } | null> {
const entity = await this.entityDataStoreService.getEntities(orgId)
return entity ? { entity } : null
}

@Get('/policies')
async getPolicies(@OrgId() orgId: string): Promise<{ policy: PolicyStore } | null> {
const policy = await this.policyDataStoreService.getPolicies(orgId)
return policy ? { policy } : null
}

@Post('/entities')
setEntities(@OrgId() orgId: string, @Body() payload: { signature: JwtString; data: Entities }) {
return this.entityDataStoreService.setEntities(orgId, payload)
}

@Post('/policies')
setPolicies(@OrgId() orgId: string, @Body() payload: { signature: JwtString; data: Policy[] }) {
return this.policyDataStoreService.setPolicies(orgId, payload)
}
}
53 changes: 53 additions & 0 deletions apps/armory/src/managed-data-store/managed-data-store.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { HttpModule } from '@nestjs/axios'
import { ClassSerializerInterceptor, Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
import { ZodValidationPipe } from 'nestjs-zod'
import { load } from '../armory.config'
import { OrchestrationModule } from '../orchestration/orchestration.module'
import { ApplicationExceptionFilter } from '../shared/filter/application-exception.filter'
import { ZodExceptionFilter } from '../shared/filter/zod-exception.filter'
import { PersistenceModule } from '../shared/module/persistence/persistence.module'
import { EntityDataStoreService } from './core/service/entity-data-store.service'
import { PolicyDataStoreService } from './core/service/policy-data-store.service'
import { DataStoreController } from './http/controller/data-store.controller'
import { ClientRepository } from './persistence/repository/client.repository'
import { EntityDataStoreRepository } from './persistence/repository/entity-data-store.repository'
import { PolicyDataStoreRepository } from './persistence/repository/policy-data-store.repository'

@Module({
imports: [
ConfigModule.forRoot({
load: [load]
}),
HttpModule,
PersistenceModule,
OrchestrationModule
],
controllers: [DataStoreController],
providers: [
EntityDataStoreService,
PolicyDataStoreService,
EntityDataStoreRepository,
PolicyDataStoreRepository,
ClientRepository,
{
provide: APP_FILTER,
useClass: ApplicationExceptionFilter
},
{
provide: APP_FILTER,
useClass: ZodExceptionFilter
},
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor
},
{
provide: APP_PIPE,
useClass: ZodValidationPipe
}
],
exports: []
})
export class ManagedDataStoreModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common'
import { Organization } from '@prisma/client/armory'
import { PrismaService } from '../../../shared/module/persistence/service/prisma.service'

@Injectable()
export class ClientRepository {
constructor(private prismaService: PrismaService) {}

async getClient(id: string): Promise<Organization | null> {
return this.prismaService.organization.findUnique({ where: { id } })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EntityStore } from '@narval/policy-engine-shared'
import { Injectable } from '@nestjs/common'
import { EntityDataStore } from '@prisma/client/armory'
import { PrismaService } from '../../../shared/module/persistence/service/prisma.service'

@Injectable()
export class EntityDataStoreRepository {
constructor(private prismaService: PrismaService) {}

setDataStore(orgId: string, data: { version: number; data: EntityStore }) {
return this.prismaService.entityDataStore.create({ data: { orgId, ...data } })
}

async getLatestDataStore(orgId: string): Promise<EntityDataStore | null> {
const version = await this.getLatestVersion(orgId)

if (!version) return null

const dataStore = await this.prismaService.entityDataStore.findFirst({ where: { orgId, version } })

if (!dataStore) return null

return dataStore
}

private async getLatestVersion(orgId: string): Promise<number> {
const data = await this.prismaService.entityDataStore.aggregate({
where: {
orgId
},
_max: {
version: true
}
})

return data._max?.version || 0
}
}
Loading
Loading