Skip to content

Commit

Permalink
Add foundation for asynchronous authorization request life cycle
Browse files Browse the repository at this point in the history
Establish basic structure for managing the authorization request (AR) life cycle
asynchronously.

Implement initial error handling for the AR processing queue with exponential
backoff.

Introduce initial integration tests for the AR repository to direct refactoring.

Modify ESLint no-relative-import rule to enhance auto-fix output, reducing
incorrect 'src' path insertions.
  • Loading branch information
wcalderipe committed Jan 15, 2024
1 parent d83d2db commit 58e094a
Show file tree
Hide file tree
Showing 25 changed files with 778 additions and 98 deletions.
4 changes: 3 additions & 1 deletion apps/authz/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"no-relative-import-paths/no-relative-import-paths": [
"error",
{
"allowSameFolder": true
"allowSameFolder": true,
"rootDir": "apps",
"prefix": "@app"
}
]
}
Expand Down
4 changes: 3 additions & 1 deletion apps/orchestration/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"no-relative-import-paths/no-relative-import-paths": [
"error",
{
"allowSameFolder": true
"allowSameFolder": true,
"rootDir": "apps",
"prefix": "@app"
}
]
}
Expand Down
5 changes: 5 additions & 0 deletions apps/orchestration/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,8 @@ orchestration/test/integration:

orchestration/test/e2e:
npx nx test:e2e ${ORCHESTRATION_PROJECT_NAME}

orchestration/test:
make orchestration/test/unit
make orchestration/test/integration
make orchestration/test/e2e
7 changes: 4 additions & 3 deletions apps/orchestration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
```bash
# Boot PostgreSQL and Redis
make docker/up
make orchestration/copy-default-env
make orchestration/db/migrate
make orchestration/setup
```

## Running
Expand All @@ -17,7 +16,7 @@ make orchestration/start/dev

## Testing

Firs time? Setup the test database:
First time? Setup the test database:

```bash
make orchestration/test/copy-default-env
Expand All @@ -27,6 +26,8 @@ make orchestration/test/db/setup
Running the tests:

```bash
# Run all tests
make orchestration/test
make orchestration/test/type
make orchestration/test/unit
make orchestration/test/integration
Expand Down
30 changes: 22 additions & 8 deletions apps/orchestration/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { OrchestrationModule } from '@app/orchestration/orchestration.module'
import { INestApplication, Logger, ValidationPipe } from '@nestjs/common'
import { ClassSerializerInterceptor, INestApplication, Logger, ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { NestFactory, Reflector } from '@nestjs/core'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { lastValueFrom, map, of, switchMap, tap } from 'rxjs'
import { Config } from './orchestration.config'

/**
* Sets up Swagger documentation for the application.
* Sets up Swagger documentation to the application.
*
* @param app - The INestApplication instance.
* @returns The modified INestApplication instance.
Expand All @@ -33,17 +33,29 @@ const withSwagger = (app: INestApplication): INestApplication => {
}

/**
* Sets up REST global validation for the application.
* Sets up global pipes to the application.
*
* @param app - The INestApplication instance.
* @returns The modified INestApplication instance.
*/
const withRestValidation = (app: INestApplication): INestApplication => {
const withGlobalPipes = (app: INestApplication): INestApplication => {
app.useGlobalPipes(new ValidationPipe())

return app
}

/**
* Sets up global interceptors to application.
*
* @param app - The Nest application instance.
* @returns The modified Nest application instance.
*/
const withGlobalInterceptors = (app: INestApplication): INestApplication => {
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))

return app
}

/**
* Boots up the orchestration application.
*
Expand All @@ -58,10 +70,12 @@ async function bootstrap(): Promise<void> {

await lastValueFrom(
of(application).pipe(
map((app) => withSwagger(app)),
map(withSwagger),
tap(() => logger.log('Added Swagger')),
map((app) => withRestValidation(app)),
tap(() => logger.log('Added REST global validation')),
map(withGlobalPipes),
tap(() => logger.log('Added global validation pipe')),
map(withGlobalInterceptors),
tap(() => logger.log('Added global interceptors')),
switchMap((app) => app.listen(port))
)
)
Expand Down
8 changes: 8 additions & 0 deletions apps/orchestration/src/orchestration.constant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { BackoffOptions } from 'bull'

export const QUEUE_PREFIX = 'orchestration'

export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE = 'authorization-request:processing'
export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE_ATTEMPTS = 3
export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE_BACKOFF: BackoffOptions = {
type: 'exponential',
delay: 1_000
}

export const REQUEST_HEADER_ORG_ID = 'x-org-id'
17 changes: 9 additions & 8 deletions apps/orchestration/src/policy-engine/__test__/e2e/facade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ describe('Policy Engine Cluster Facade', () => {
await app.init()
})

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

beforeEach(async () => {
await testPrismaService.getClient().organization.create({ data: org })
})
Expand All @@ -66,13 +73,6 @@ describe('Policy Engine Cluster Facade', () => {
await authzRequestProcessingQueue.empty()
})

afterAll(async () => {
await testPrismaService.truncateAll()
await authzRequestProcessingQueue.empty()

module.close()
})

describe('POST /evaluations', () => {
it('evaluates a sign message authorization request', async () => {
const signMessageRequest = {
Expand Down Expand Up @@ -171,7 +171,8 @@ describe('Policy Engine Cluster Facade', () => {
hash: hashMessage(JSON.stringify(signMessageRequest)),
idempotencyKey: '8dcbb7ad-82a2-4eca-b2f0-b1415c1d4a17',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
evaluations: []
}

beforeEach(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,35 @@ import {
} from '@app/orchestration/policy-engine/core/type/domain.type'
import { AuthorizationRequestRepository } from '@app/orchestration/policy-engine/persistence/repository/authorization-request.repository'
import { AuthorizationRequestProcessingProducer } from '@app/orchestration/policy-engine/queue/producer/authorization-request-processing.producer'
import { Injectable } from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common'
import { catchError, delay, lastValueFrom, map, switchMap, tap } from 'rxjs'
import { v4 as uuid } from 'uuid'

const getStatus = (decision: string): AuthorizationRequestStatus => {
const statuses: Map<string, AuthorizationRequestStatus> = new Map([
['Permit', AuthorizationRequestStatus.PERMITTED],
['Forbid', AuthorizationRequestStatus.FORBIDDEN],
['Confirm', AuthorizationRequestStatus.APPROVING]
])

const status = statuses.get(decision)

if (status) {
return status
}

throw Error('Unknown status returned from the AuthZ')
}

@Injectable()
export class AuthorizationRequestService {
private logger = new Logger(AuthorizationRequestService.name)

constructor(
private authzRequestRepository: AuthorizationRequestRepository,
private authzRequestProcessingProducer: AuthorizationRequestProcessingProducer
private authzRequestProcessingProducer: AuthorizationRequestProcessingProducer,
private httpService: HttpService
) {}

async create(input: CreateAuthorizationRequest): Promise<AuthorizationRequest> {
Expand All @@ -27,16 +49,68 @@ export class AuthorizationRequestService {
}

async process(id: string) {
await this.authzRequestRepository.findById(id)
const authzRequest = await this.authzRequestRepository.findById(id)

await this.authzRequestRepository.changeStatus(id, AuthorizationRequestStatus.PROCESSING)
if (authzRequest) {
await this.authzRequestRepository.changeStatus(id, AuthorizationRequestStatus.PROCESSING)

await new Promise((resolve) => {
setTimeout(() => resolve(true), 3000)
})
await this.evaluate(authzRequest)
}
}

async changeStatus(id: string, status: AuthorizationRequestStatus): Promise<AuthorizationRequest> {
return this.authzRequestRepository.changeStatus(id, status)
}

async complete(id: string) {
await this.authzRequestRepository.changeStatus(id, AuthorizationRequestStatus.APPROVING)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async complete(id: string) {}

async evaluate(input: AuthorizationRequest): Promise<AuthorizationRequest> {
this.logger.log('Sending authorization request to cluster evaluation', {
input
})

return lastValueFrom(
this.httpService.post('http://localhost:3010/evaluation', input).pipe(
delay(3000), // fake some delay
tap((response) => {
this.logger.log('Received evaluation response', {
status: response.status,
headers: response.headers,
response: response.data
})
}),
map((response) => response.data),
switchMap((evaluation) => {
return this.authzRequestRepository.update({
...input,
status: getStatus(evaluation.decision),
evaluations: [
{
id: uuid(),
decision: evaluation.decision,
signature: evaluation.permitSignature,
createdAt: new Date()
}
]
})
}),
tap((authzRequest) => {
this.logger.log('Authorization request status updated', {
orgId: authzRequest.orgId,
id: authzRequest.id,
status: authzRequest.status,
evaluations: authzRequest.evaluations
})
}),
catchError((error) => {
this.logger.error('Authorization request evaluation failed')

throw new UnprocessableEntityException('Authorization request evaluation error', {
description: error.message
})
})
)
)
}
}
14 changes: 13 additions & 1 deletion apps/orchestration/src/policy-engine/core/type/domain.type.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { SetOptional } from 'type-fest'

export type Evaluation = {
id: string
decision: string
signature?: string | null
createdAt: Date
}

export enum Action {
SIGN_TRANSACTION = 'signTransaction',
SIGN_MESSAGE = 'signMessage'
Expand All @@ -8,6 +15,7 @@ export enum Action {
export enum AuthorizationRequestStatus {
CREATED = 'CREATED',
CANCELED = 'CANCELED',
FAILED = 'FAILED',
PROCESSING = 'PROCESSING',
APPROVING = 'APPROVING',
PERMITTED = 'PERMITTED',
Expand All @@ -30,6 +38,7 @@ export type SharedAuthorizationRequest = {
idempotencyKey?: string | null
createdAt: Date
updatedAt: Date
evaluations: Evaluation[]
}

export type Hex = `0x${string}`
Expand All @@ -49,7 +58,6 @@ export type AccessList = { address: Address; storageKeys: Hex[] }[]
// accessList?: AccessList
// type?: TTransactionType
// }

// Temporary lite version
export type TransactionRequest = {
data?: Hex
Expand Down Expand Up @@ -82,3 +90,7 @@ export function isSignTransaction(request: AuthorizationRequest): request is Sig
export function isSignMessage(request: AuthorizationRequest): request is SignMessageAuthorizationRequest {
return (request as SignMessageAuthorizationRequest).action === Action.SIGN_MESSAGE
}

export type AuthorizationRequestProcessingJob = {
id: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,7 @@ import { Action, CreateAuthorizationRequest } from '@app/orchestration/policy-en
import { AuthorizationRequestDto } from '@app/orchestration/policy-engine/http/rest/dto/authorization-request.dto'
import { AuthorizationResponseDto } from '@app/orchestration/policy-engine/http/rest/dto/authorization-response.dto'
import { OrgId } from '@app/orchestration/shared/decorator/org-id.decorator'
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Post,
UsePipes,
ValidationPipe
} from '@nestjs/common'
import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Param, Post } from '@nestjs/common'
import { ApiResponse, ApiTags } from '@nestjs/swagger'
import { plainToInstance } from 'class-transformer'

Expand All @@ -26,7 +15,8 @@ const toDomainType = (orgId: string, body: AuthorizationRequestDto): CreateAutho
const shared = {
orgId,
initiatorId: '97389cac-20f0-4d02-a3a9-b27c564ffd18',
hash: dto.hash
hash: dto.hash,
evaluations: []
}

if (dto.isSignMessage(dto.request)) {
Expand All @@ -51,7 +41,6 @@ const toDomainType = (orgId: string, body: AuthorizationRequestDto): CreateAutho
}

@Controller('/policy-engine')
@UsePipes(new ValidationPipe())
@ApiTags('Policy Engine')
export class FacadeController {
constructor(private authorizationRequestService: AuthorizationRequestService) {}
Expand Down
Loading

0 comments on commit 58e094a

Please sign in to comment.