From ee0babc2e36a9e5bbc8ffa8eeafbb5838f661a12 Mon Sep 17 00:00:00 2001 From: Matthew Bessette Date: Wed, 22 Mar 2023 03:00:53 -0400 Subject: [PATCH] Audit logging, secrets manager completion, sns and sqs wrapping up --- package.json | 1 + src/abstract-action.handler.ts | 1 + src/app.controller.ts | 16 +++- src/app.module.ts | 10 ++- src/audit/audit.entity.ts | 20 +++++ src/audit/audit.interceptor.ts | 40 ++++++++++ src/aws-shared-entities/attributes.service.ts | 10 +++ src/config/common-config.interface.ts | 1 + src/config/local.config.ts | 4 +- src/secrets-manager/create-secret.dto.ts | 8 ++ src/secrets-manager/create-secret.handler.ts | 13 ++-- .../describe-secret.handler.ts | 17 ++-- .../get-resource-policy.handler.ts | 46 +++++++++++ .../get-secret-value.handler.ts | 48 ++++++++++++ .../put-resource-policy.handler.ts | 48 ++++++++++++ .../put-secret-value.handler.ts | 51 ++++++++++++ src/secrets-manager/secret.entity.ts | 5 ++ src/secrets-manager/secret.service.ts | 32 ++++++++ src/secrets-manager/secrets-manager.module.ts | 10 +++ src/sns/publish.handler.ts | 77 +++++++++++++++++++ src/sns/sns.module.ts | 4 + src/sqs/create-queue.handler.ts | 49 ++++++++++++ src/sqs/purge-queue.handler.ts | 30 ++++++++ src/sqs/receive-message.handler.ts | 51 ++++++++++++ src/sqs/set-queue-attributes.handler.ts | 45 +++++++++++ src/sqs/sqs-queue-entry.service.ts | 68 ++++++++++++++++ src/sqs/sqs-queue.entity.ts | 41 ++++++++++ src/sqs/sqs.constants.ts | 5 ++ src/sqs/sqs.module.ts | 60 +++++++++++++++ yarn.lock | 57 ++++++++++++++ 30 files changed, 845 insertions(+), 23 deletions(-) create mode 100644 src/audit/audit.entity.ts create mode 100644 src/audit/audit.interceptor.ts create mode 100644 src/secrets-manager/create-secret.dto.ts create mode 100644 src/secrets-manager/get-secret-value.handler.ts create mode 100644 src/secrets-manager/put-resource-policy.handler.ts create mode 100644 src/secrets-manager/put-secret-value.handler.ts create mode 100644 src/secrets-manager/secret.service.ts create mode 100644 src/sns/publish.handler.ts create mode 100644 src/sqs/create-queue.handler.ts create mode 100644 src/sqs/purge-queue.handler.ts create mode 100644 src/sqs/receive-message.handler.ts create mode 100644 src/sqs/set-queue-attributes.handler.ts create mode 100644 src/sqs/sqs-queue-entry.service.ts create mode 100644 src/sqs/sqs-queue.entity.ts create mode 100644 src/sqs/sqs.constants.ts diff --git a/package.json b/package.json index 4bd2317..aaa517f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@nestjs/core": "^9.3.10", "@nestjs/platform-express": "^9.3.10", "@nestjs/typeorm": "^9.0.1", + "@types/express": "^4.17.17", "class-transformer": "^0.5.1", "joi": "^17.9.0", "js2xmlparser": "^5.0.0", diff --git a/src/abstract-action.handler.ts b/src/abstract-action.handler.ts index 91fc7ee..a412397 100644 --- a/src/abstract-action.handler.ts +++ b/src/abstract-action.handler.ts @@ -5,6 +5,7 @@ import * as Joi from 'joi'; export type AwsProperties = { accountId: string; region: string; + host: string; } export enum Format { diff --git a/src/app.controller.ts b/src/app.controller.ts index 010bd7d..a06803b 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Body, Controller, Get, Inject, Post, Headers, Header } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Inject, Post, Headers, Header, Req, HttpStatus, HttpCode, UseInterceptors } from '@nestjs/common'; import { ActionHandlers } from './app.constants'; import * as Joi from 'joi'; import { Action } from './action.enum'; @@ -7,6 +7,8 @@ import * as js2xmlparser from 'js2xmlparser'; import { ConfigService } from '@nestjs/config'; import { CommonConfig } from './config/common-config.interface'; import * as uuid from 'uuid'; +import { Request } from 'express'; +import { AuditInterceptor } from './audit/audit.interceptor'; @Controller() export class AppController { @@ -18,8 +20,10 @@ export class AppController { ) {} @Post() - @Header('x-amzn-RequestId', uuid.v4()) + @HttpCode(200) + @UseInterceptors(AuditInterceptor) async post( + @Req() request: Request, @Body() body: Record, @Headers() headers: Record, ) { @@ -29,7 +33,7 @@ export class AppController { return o; }, {}) - const queryParams = { ...body, ...lowerCasedHeaders }; + const queryParams = { __path: request.path, ...body, ...lowerCasedHeaders }; console.log({queryParams}) const actionKey = queryParams['x-amz-target'] ? 'x-amz-target' : 'Action'; @@ -50,7 +54,11 @@ export class AppController { throw new BadRequestException(validatorError); } - const awsProperties = { accountId: this.configService.get('AWS_ACCOUNT_ID'), region: this.configService.get('AWS_REGION') }; + const awsProperties = { + accountId: this.configService.get('AWS_ACCOUNT_ID'), + region: this.configService.get('AWS_REGION'), + host: this.configService.get('HOST'), + }; const jsonResponse = await handler.getResponse(validQueryParams, awsProperties); if (handler.format === Format.Xml) { diff --git a/src/app.module.ts b/src/app.module.ts index f064cd6..199eaf2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,10 @@ import { AppController } from './app.controller'; import { AwsSharedEntitiesModule } from './aws-shared-entities/aws-shared-entities.module'; import { SecretsManagerModule } from './secrets-manager/secrets-manager.module'; import { SecretsManagerHandlers } from './secrets-manager/secrets-manager.constants'; +import { SqsModule } from './sqs/sqs.module'; +import { SqsHandlers } from './sqs/sqs.constants'; +import { Audit } from './audit/audit.entity'; +import { AuditInterceptor } from './audit/audit.interceptor'; @Module({ imports: [ @@ -28,19 +32,23 @@ import { SecretsManagerHandlers } from './secrets-manager/secrets-manager.consta entities: [__dirname + '/**/*.entity{.ts,.js}'], }), }), - SnsModule, + TypeOrmModule.forFeature([Audit]), SecretsManagerModule, + SnsModule, + SqsModule, AwsSharedEntitiesModule, ], controllers: [ AppController, ], providers: [ + AuditInterceptor, { provide: ActionHandlers, useFactory: (...args) => args.reduce((m, hs) => ({ ...m, ...hs }), {}), inject: [ SnsHandlers, + SqsHandlers, SecretsManagerHandlers, ], }, diff --git a/src/audit/audit.entity.ts b/src/audit/audit.entity.ts new file mode 100644 index 0000000..0f11f3f --- /dev/null +++ b/src/audit/audit.entity.ts @@ -0,0 +1,20 @@ +import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('audit') +export class Audit extends BaseEntity { + + @PrimaryColumn() + id: string; + + @CreateDateColumn() + createdAt: string; + + @Column({ nullable: true }) + action: string; + + @Column({ nullable: true }) + request: string; + + @Column({ nullable: true }) + response: string; +} diff --git a/src/audit/audit.interceptor.ts b/src/audit/audit.interceptor.ts new file mode 100644 index 0000000..5ba50b2 --- /dev/null +++ b/src/audit/audit.interceptor.ts @@ -0,0 +1,40 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Observable, tap } from 'rxjs'; +import { Repository } from 'typeorm'; +import { Audit } from './audit.entity'; +import * as uuid from 'uuid'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + + constructor( + @InjectRepository(Audit) + private readonly auditRepo: Repository, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + + const requestId = uuid.v4(); + const httpContext = context.switchToHttp(); + + const request = httpContext.getRequest(); + const targetHeaderKey = Object.keys(request.headers).find( k => k.toLocaleLowerCase() === 'x-amz-target'); + const action = request.headers[targetHeaderKey] ? request.headers[targetHeaderKey] : request.body.Action; + + const response = context.switchToHttp().getResponse(); + + response.header('x-amzn-RequestId', requestId); + + return next.handle().pipe( + tap(async (data) => { + await this.auditRepo.create({ + id: requestId, + action, + request: JSON.stringify({ __path: request.path, ...request.headers, ...request.body }), + response: JSON.stringify(data), + }).save(); + }) + ); + } +} diff --git a/src/aws-shared-entities/attributes.service.ts b/src/aws-shared-entities/attributes.service.ts index e1ebcb8..6d8e0e1 100644 --- a/src/aws-shared-entities/attributes.service.ts +++ b/src/aws-shared-entities/attributes.service.ts @@ -4,6 +4,8 @@ import { Repository } from 'typeorm'; import { Attribute } from './attributes.entity'; import { CreateAttributeDto } from './create-attribute.dto'; +const ResourcePolicyName = 'ResourcePolicy'; + @Injectable() export class AttributesService { @@ -16,6 +18,14 @@ export class AttributesService { return await this.repo.find({ where: { arn }}); } + async getResourcePolicyByArn(arn: string): Promise { + return await this.repo.findOne({ where: { arn, name: ResourcePolicyName }}); + } + + async createResourcePolicy(arn: string, value: string): Promise { + return await this.create({arn, value, name: ResourcePolicyName }); + } + async create(dto: CreateAttributeDto): Promise { return await this.repo.save(dto); } diff --git a/src/config/common-config.interface.ts b/src/config/common-config.interface.ts index cbcd366..d55ee3e 100644 --- a/src/config/common-config.interface.ts +++ b/src/config/common-config.interface.ts @@ -4,4 +4,5 @@ export interface CommonConfig { DB_DATABASE: string; DB_LOGGING?: boolean; DB_SYNCHRONIZE?: boolean; + HOST: string; } diff --git a/src/config/local.config.ts b/src/config/local.config.ts index 227ad74..d0bbf3e 100644 --- a/src/config/local.config.ts +++ b/src/config/local.config.ts @@ -3,7 +3,9 @@ import { CommonConfig } from "./common-config.interface"; export default (): CommonConfig => ({ AWS_ACCOUNT_ID: '123456789012', AWS_REGION: 'us-east-1', - DB_DATABASE: ':memory:', // 'local-aws.sqlite', // :memory: + // DB_DATABASE: ':memory:', + DB_DATABASE: 'local-aws.sqlite', DB_LOGGING: true, DB_SYNCHRONIZE: true, + HOST: 'http://localhost:8081', }); diff --git a/src/secrets-manager/create-secret.dto.ts b/src/secrets-manager/create-secret.dto.ts new file mode 100644 index 0000000..48c1d95 --- /dev/null +++ b/src/secrets-manager/create-secret.dto.ts @@ -0,0 +1,8 @@ +export interface CreateSecretDto { + versionId?: string; + name: string; + description?: string; + secretString?: string; + accountId: string; + region: string; +} diff --git a/src/secrets-manager/create-secret.handler.ts b/src/secrets-manager/create-secret.handler.ts index b8c3305..eb17ff2 100644 --- a/src/secrets-manager/create-secret.handler.ts +++ b/src/secrets-manager/create-secret.handler.ts @@ -5,21 +5,20 @@ import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action import { Action } from '../action.enum'; import * as Joi from 'joi'; import { Secret } from './secret.entity'; -import * as uuid from 'uuid'; +import { SecretService } from './secret.service'; type QueryParams = { Name: string; Description: string; SecretString: string; - ClientRequestToken: string; + ClientRequestToken?: string; } @Injectable() export class CreateSecretHandler extends AbstractActionHandler { constructor( - @InjectRepository(Secret) - private readonly secretRepo: Repository, + private readonly secretService: SecretService, ) { super(); } @@ -30,21 +29,21 @@ export class CreateSecretHandler extends AbstractActionHandler { Name: Joi.string().required(), Description: Joi.string().allow('', null), SecretString: Joi.string().allow('', null), - ClientRequestToken: Joi.string().required(), + ClientRequestToken: Joi.string(), }); protected async handle(params: QueryParams, awsProperties: AwsProperties) { const { Name: name, Description: description, SecretString: secretString, ClientRequestToken } = params; - const secret = await this.secretRepo.create({ + const secret = await this.secretService.create({ versionId: ClientRequestToken, description, name, secretString, accountId: awsProperties.accountId, region: awsProperties.region, - }).save(); + }); return { ARN: secret.arn, VersionId: secret.versionId, Name: secret.name }; } diff --git a/src/secrets-manager/describe-secret.handler.ts b/src/secrets-manager/describe-secret.handler.ts index d58e1d7..d45b226 100644 --- a/src/secrets-manager/describe-secret.handler.ts +++ b/src/secrets-manager/describe-secret.handler.ts @@ -27,11 +27,9 @@ export class DescribeSecretHandler extends AbstractActionHandler { validator = Joi.object({ SecretId: Joi.string().required() }); protected async handle({ SecretId }: QueryParams, awsProperties: AwsProperties) { - - const parts = SecretId.split(':'); - const name = parts.length > 1 ? parts[-1] : SecretId; - const secret = await this.secretRepo.findOne({ where: { name } }); + const name = Secret.getNameFromSecretId(SecretId); + const secret = await this.secretRepo.findOne({ where: { name }, order: { createdAt: 'DESC' } }); if (!secret) { throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for."); @@ -42,19 +40,18 @@ export class DescribeSecretHandler extends AbstractActionHandler { return { "ARN": secret.arn, - "CreatedDate": new Date(secret.createdAt).getMilliseconds(), - "DeletedDate": 0, + "CreatedDate": new Date(secret.createdAt).toISOString(), + "DeletedDate": null, "Description": secret.description, "KmsKeyId": "", - "LastAccessedDate": new Date().getMilliseconds(), - "LastChangedDate": new Date(secret.createdAt).getMilliseconds(), - "LastRotatedDate": 0, + "LastChangedDate": new Date(secret.createdAt).toISOString(), + "LastRotatedDate": null, "Name": secret.name, "OwningService": secret.accountId, "PrimaryRegion": secret.region, "ReplicationStatus": [], "RotationEnabled": false, "Tags": listOfTagPairs, - } + } } } diff --git a/src/secrets-manager/get-resource-policy.handler.ts b/src/secrets-manager/get-resource-policy.handler.ts index e69de29..401133b 100644 --- a/src/secrets-manager/get-resource-policy.handler.ts +++ b/src/secrets-manager/get-resource-policy.handler.ts @@ -0,0 +1,46 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { Secret } from './secret.entity'; +import { TagsService } from '../aws-shared-entities/tags.service'; +import { AttributesService } from '../aws-shared-entities/attributes.service'; + +type QueryParams = { + SecretId: string; +} + +@Injectable() +export class GetResourcePolicyHandler extends AbstractActionHandler { + + constructor( + @InjectRepository(Secret) + private readonly secretRepo: Repository, + private readonly attributesService: AttributesService, + ) { + super(); + } + + format = Format.Json; + action = Action.SecretsManagerGetResourcePolicy; + validator = Joi.object({ SecretId: Joi.string().required() }); + + protected async handle({ SecretId }: QueryParams, awsProperties: AwsProperties) { + + const name = Secret.getNameFromSecretId(SecretId); + const secret = await this.secretRepo.findOne({ where: { name }, order: { createdAt: 'DESC' } }); + + if (!secret) { + throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for."); + } + + const attribute = await this.attributesService.getResourcePolicyByArn(secret.arn); + return { + ARN: secret.arn, + Name: secret.name, + ResourcePolicy: attribute?.value, + } + } +} diff --git a/src/secrets-manager/get-secret-value.handler.ts b/src/secrets-manager/get-secret-value.handler.ts new file mode 100644 index 0000000..6dcc9ee --- /dev/null +++ b/src/secrets-manager/get-secret-value.handler.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { Secret } from './secret.entity'; +import { SecretService } from './secret.service'; + +type QueryParams = { + SecretId: string; + VersionId: string; +} + +@Injectable() +export class GetSecretValueHandler extends AbstractActionHandler { + + constructor( + private readonly secretService: SecretService, + ) { + super(); + } + + format = Format.Json; + action = Action.SecretsManagerGetSecretValue; + validator = Joi.object({ + SecretId: Joi.string().required(), + VersionId: Joi.string().allow(null, ''), + }); + + protected async handle({ SecretId, VersionId}: QueryParams, awsProperties: AwsProperties) { + + const name = Secret.getNameFromSecretId(SecretId); + const secret = VersionId ? + await this.secretService.findByNameAndVersion(name, VersionId) : + await this.secretService.findLatestByNameAndRegion(name, awsProperties.region); + + if (!secret) { + throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for."); + } + + return { + ARN: secret.arn, + CreatedDate: secret.createdAt, + Name: secret.name, + SecretString: secret.secretString, + VersionId: secret.versionId, + } + } +} diff --git a/src/secrets-manager/put-resource-policy.handler.ts b/src/secrets-manager/put-resource-policy.handler.ts new file mode 100644 index 0000000..e1f4cca --- /dev/null +++ b/src/secrets-manager/put-resource-policy.handler.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { Secret } from './secret.entity'; +import { AttributesService } from '../aws-shared-entities/attributes.service'; + +type QueryParams = { + SecretId: string; + ResourcePolicy: string; +} + +@Injectable() +export class PutResourcePolicyHandler extends AbstractActionHandler { + + constructor( + @InjectRepository(Secret) + private readonly secretRepo: Repository, + private readonly attributesService: AttributesService, + ) { + super(); + } + + format = Format.Json; + action = Action.SecretsManagerPutResourcePolicy; + validator = Joi.object({ + SecretId: Joi.string().required(), + ResourcePolicy: Joi.string().required(), + }); + + protected async handle({ SecretId, ResourcePolicy }: QueryParams, awsProperties: AwsProperties) { + + const name = Secret.getNameFromSecretId(SecretId); + const secret = await this.secretRepo.findOne({ where: { name }, order: { createdAt: 'DESC' } }); + + if (!secret) { + throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for."); + } + + await this.attributesService.createResourcePolicy(secret.arn, ResourcePolicy); + return { + ARN: secret.arn, + Name: secret.name, + } + } +} diff --git a/src/secrets-manager/put-secret-value.handler.ts b/src/secrets-manager/put-secret-value.handler.ts new file mode 100644 index 0000000..704d895 --- /dev/null +++ b/src/secrets-manager/put-secret-value.handler.ts @@ -0,0 +1,51 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { Secret } from './secret.entity'; +import { SecretService } from './secret.service'; + +type QueryParams = { + ClientRequestToken?: string; + SecretId: string; + SecretString: string; +} + +@Injectable() +export class PutSecretValueHandler extends AbstractActionHandler { + + constructor( + private readonly secretService: SecretService, + ) { + super(); + } + + format = Format.Json; + action = Action.SecretsManagerPutSecretValue; + validator = Joi.object({ + ClientRequestToken: Joi.string(), + SecretId: Joi.string().required(), + SecretString: Joi.string(), + }); + + protected async handle(params: QueryParams, awsProperties: AwsProperties) { + + const { SecretId, SecretString: secretString, ClientRequestToken } = params; + const name = Secret.getNameFromSecretId(SecretId); + const oldSecret = await this.secretService.findLatestByNameAndRegion(name, awsProperties.region); + + if (!oldSecret) { + throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for."); + } + + const secret = await this.secretService.create({ + versionId: ClientRequestToken, + name: oldSecret.name, + secretString, + accountId: awsProperties.accountId, + region: awsProperties.region, + }); + + return { ARN: secret.arn, VersionId: secret.versionId, Name: secret.name, VersionStages: [] } + } +} diff --git a/src/secrets-manager/secret.entity.ts b/src/secrets-manager/secret.entity.ts index c84e118..66ea1aa 100644 --- a/src/secrets-manager/secret.entity.ts +++ b/src/secrets-manager/secret.entity.ts @@ -28,4 +28,9 @@ export class Secret extends BaseEntity { get arn(): string { return `arn:aws:secretsmanager:${this.region}:${this.accountId}:${this.name}`; } + + static getNameFromSecretId(secretId: string) { + const parts = secretId.split(':'); + return parts.length > 1 ? parts.pop() : secretId; + } } diff --git a/src/secrets-manager/secret.service.ts b/src/secrets-manager/secret.service.ts new file mode 100644 index 0000000..5a282a1 --- /dev/null +++ b/src/secrets-manager/secret.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateSecretDto } from './create-secret.dto'; +import { Secret } from './secret.entity'; +import * as uuid from 'uuid'; + +@Injectable() +export class SecretService { + + constructor( + @InjectRepository(Secret) + private readonly secretRepo: Repository, + ) {} + + async findLatestByNameAndRegion(name: string, region: string): Promise { + return await this.secretRepo.findOne({ where: { name, region }, order: { createdAt: 'DESC' } }); + } + + async findByNameAndVersion(name: string, versionId: string): Promise { + // TypeORM BUG: https://github.com/typeorm/typeorm/issues/5694 - Cannot use findOne here + const [ secret ] = await this.secretRepo.find({ where: { name, versionId } }); + return secret; + } + + async create(dto: CreateSecretDto): Promise { + return await this.secretRepo.create({ + ...dto, + versionId: dto.versionId ?? uuid.v4(), + }).save(); + } +} diff --git a/src/secrets-manager/secrets-manager.module.ts b/src/secrets-manager/secrets-manager.module.ts index 5d85679..a4b8096 100644 --- a/src/secrets-manager/secrets-manager.module.ts +++ b/src/secrets-manager/secrets-manager.module.ts @@ -7,12 +7,21 @@ import { DefaultActionHandlerProvider } from '../default-action-handler/default- import { ExistingActionHandlersProvider } from '../default-action-handler/existing-action-handlers.provider'; import { CreateSecretHandler } from './create-secret.handler'; import { DescribeSecretHandler } from './describe-secret.handler'; +import { GetResourcePolicyHandler } from './get-resource-policy.handler'; +import { GetSecretValueHandler } from './get-secret-value.handler'; +import { PutResourcePolicyHandler } from './put-resource-policy.handler'; +import { PutSecretValueHandler } from './put-secret-value.handler'; import { Secret } from './secret.entity'; +import { SecretService } from './secret.service'; import { SecretsManagerHandlers } from './secrets-manager.constants'; const handlers = [ CreateSecretHandler, DescribeSecretHandler, + GetResourcePolicyHandler, + GetSecretValueHandler, + PutResourcePolicyHandler, + PutSecretValueHandler, ] const actions = [ @@ -46,6 +55,7 @@ const actions = [ AwsSharedEntitiesModule, ], providers: [ + SecretService, ...handlers, ExistingActionHandlersProvider(handlers), DefaultActionHandlerProvider(SecretsManagerHandlers, Format.Json, actions), diff --git a/src/sns/publish.handler.ts b/src/sns/publish.handler.ts new file mode 100644 index 0000000..3a2a78f --- /dev/null +++ b/src/sns/publish.handler.ts @@ -0,0 +1,77 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { SqsQueueEntryService } from '../sqs/sqs-queue-entry.service'; +import { SnsTopicSubscription } from './sns-topic-subscription.entity'; +import * as uuid from 'uuid'; +import { AttributesService } from '../aws-shared-entities/attributes.service'; +import { SqsQueue } from '../sqs/sqs-queue.entity'; + +type QueryParams = { + TopicArn: string; + TargetArn: string; + Subject?: string; + Message: string; +} + +@Injectable() +export class PublishHandler extends AbstractActionHandler { + + constructor( + @InjectRepository(SnsTopicSubscription) + private readonly snsTopicSubscriptionRepo: Repository, + private readonly sqsQueueEntryService: SqsQueueEntryService, + private readonly attributeService: AttributesService, + ) { + super(); + } + + format = Format.Xml; + action = Action.SnsPublish; + validator = Joi.object({ + TargetArn: Joi.string(), + TopicArn: Joi.string(), + Subject: Joi.string().allow(null, ''), + Message: Joi.string().required(), + }); + + protected async handle({ TopicArn, TargetArn, Message, Subject }: QueryParams, awsProperties: AwsProperties) { + const arn = TopicArn ?? TargetArn; + + if (!arn) { + throw new BadRequestException(); + } + + const MessageId = uuid.v4(); + const subscriptions = await this.snsTopicSubscriptionRepo.find({ where: { topicArn: arn } }); + const topicAttributes = await this.attributeService.getByArn(arn); + + for (const sub of subscriptions) { + const attributes = await this.attributeService.getByArn(sub.arn); + if (sub.protocol === 'sqs') { + const { value: isRaw } = attributes.find(a => a.name === 'RawMessageDelivery'); + const [queueAccountId, queueName] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(sub.endpoint); + + const message = isRaw === 'true' ? Message : JSON.stringify({ + Type: "Notification", + MessageId, + TopicArn: arn, + Subject, + Message, + Timestamp: new Date().toISOString(), + SignatureVersion: topicAttributes.find(a => a.name === 'SignatureVersion')?.value ?? '1', + Signature: '', + SigningCertURL: '', + UnsubscribeURL: `${awsProperties.host}/?Action=Unsubscribe&SubscriptionArn=${sub.arn}`, + }); + + await this.sqsQueueEntryService.publish(queueAccountId, queueName, message); + } + } + + return { MessageId }; + } +} diff --git a/src/sns/sns.module.ts b/src/sns/sns.module.ts index 2174810..c031a19 100644 --- a/src/sns/sns.module.ts +++ b/src/sns/sns.module.ts @@ -6,11 +6,13 @@ import { AwsSharedEntitiesModule } from '../aws-shared-entities/aws-shared-entit import { ExistingActionHandlers } from '../default-action-handler/default-action-handler.constants'; import { DefaultActionHandlerProvider } from '../default-action-handler/default-action-handler.provider'; import { ExistingActionHandlersProvider } from '../default-action-handler/existing-action-handlers.provider'; +import { SqsModule } from '../sqs/sqs.module'; import { CreateTopicHandler } from './create-topic.handler'; import { GetSubscriptionAttributesHandler } from './get-subscription-attributes.handler'; import { GetTopicAttributesHandler } from './get-topic-attributes.handler'; import { ListTagsForResourceHandler } from './list-tags-for-resource.handler'; import { ListTopicsHandler } from './list-topics.handler'; +import { PublishHandler } from './publish.handler'; import { SetSubscriptionAttributesHandler } from './set-subscription-attributes.handler'; import { SetTopicAttributesHandler } from './set-topic-attributes.handler'; import { SnsTopicSubscription } from './sns-topic-subscription.entity'; @@ -24,6 +26,7 @@ const handlers = [ GetTopicAttributesHandler, ListTagsForResourceHandler, ListTopicsHandler, + PublishHandler, SetSubscriptionAttributesHandler, SetTopicAttributesHandler, SubscribeHandler, @@ -78,6 +81,7 @@ const actions = [ imports: [ TypeOrmModule.forFeature([SnsTopic, SnsTopicSubscription]), AwsSharedEntitiesModule, + SqsModule, ], providers: [ ...handlers, diff --git a/src/sqs/create-queue.handler.ts b/src/sqs/create-queue.handler.ts new file mode 100644 index 0000000..3661959 --- /dev/null +++ b/src/sqs/create-queue.handler.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { TagsService } from '../aws-shared-entities/tags.service'; +import { SqsQueue } from './sqs-queue.entity'; +import { AttributesService } from '../aws-shared-entities/attributes.service'; + +type QueryParams = { + QueueName: string; +} + +@Injectable() +export class CreateQueueHandler extends AbstractActionHandler { + + constructor( + @InjectRepository(SqsQueue) + private readonly sqsQueueRepo: Repository, + private readonly tagsService: TagsService, + private readonly attributeService: AttributesService, + ) { + super(); + } + + format = Format.Xml; + action = Action.SqsCreateQueue; + validator = Joi.object({ QueueName: Joi.string().required() }); + + protected async handle(params: QueryParams, awsProperties: AwsProperties) { + + const { QueueName: name } = params; + + const queue = await this.sqsQueueRepo.create({ + name, + accountId: awsProperties.accountId, + region: awsProperties.region, + }).save(); + + const tags = TagsService.tagPairs(params); + await this.tagsService.createMany(queue.arn, tags); + + const attributes = AttributesService.attributePairs(params); + await this.attributeService.createMany(queue.arn, attributes); + + return { QueueUrl: queue.getUrl(awsProperties.host) }; + } +} diff --git a/src/sqs/purge-queue.handler.ts b/src/sqs/purge-queue.handler.ts new file mode 100644 index 0000000..8aea674 --- /dev/null +++ b/src/sqs/purge-queue.handler.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { SqsQueue } from './sqs-queue.entity'; +import { SqsQueueEntryService } from './sqs-queue-entry.service'; + +type QueryParams = { + __path: string; +} + +@Injectable() +export class PurgeQueueHandler extends AbstractActionHandler { + + constructor( + private readonly sqsQueueEntryService: SqsQueueEntryService, + ) { + super(); + } + + format = Format.Xml; + action = Action.SqsPurgeQueue; + validator = Joi.object({ __path: Joi.string().required() }); + + protected async handle({ __path }: QueryParams, awsProperties: AwsProperties) { + + const [accountId, name] = SqsQueue.getAccountIdAndNameFromPath(__path); + await this.sqsQueueEntryService.purge(accountId, name); + } +} diff --git a/src/sqs/receive-message.handler.ts b/src/sqs/receive-message.handler.ts new file mode 100644 index 0000000..fdf8b40 --- /dev/null +++ b/src/sqs/receive-message.handler.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { SqsQueue } from './sqs-queue.entity'; +import { SqsQueueEntryService } from './sqs-queue-entry.service'; +import crypto from 'crypto'; + +type QueryParams = { + __path: string; + MaxNumberOfMessages?: number; + VisibilityTimeout?: number; +} + +@Injectable() +export class ReceiveMessageHandler extends AbstractActionHandler { + + constructor( + private readonly sqsQueueEntryService: SqsQueueEntryService, + ) { + super(); + } + + format = Format.Xml; + action = Action.SqsReceiveMessage; + validator = Joi.object({ + __path: Joi.string().required(), + MaxNumberOfMessages: Joi.number(), + VisibilityTimeout: Joi.number(), + }); + + protected async handle({ __path, MaxNumberOfMessages, VisibilityTimeout }: QueryParams, awsProperties: AwsProperties) { + + const [accountId, name] = SqsQueue.getAccountIdAndNameFromPath(__path); + const records = await this.sqsQueueEntryService.recieveMessages(accountId, name, MaxNumberOfMessages, VisibilityTimeout); + return records.map(r => ({ + Message: { + MessageId: r.id, + ReceiptHandle: r.id, + MD5OfBody: crypto.createHash('md5').update(r.message).digest("hex"), + Body: r.message, + '#': [ + { Attribute: { Name: 'SenderId', Value: r.senderId }}, + { Attribute: { Name: 'SentTimestamp', Value: r.createdAt.getSeconds() }}, + { Attribute: { Name: 'ApproximateReceiveCount', Value: 1 }}, + { Attribute: { Name: 'ApproximateFirstReceiveTimestamp', Value: r.createdAt.getSeconds() }}, + ] + } + })); + } +} diff --git a/src/sqs/set-queue-attributes.handler.ts b/src/sqs/set-queue-attributes.handler.ts new file mode 100644 index 0000000..990fe29 --- /dev/null +++ b/src/sqs/set-queue-attributes.handler.ts @@ -0,0 +1,45 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import * as Joi from 'joi'; +import { AttributesService } from '../aws-shared-entities/attributes.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { SqsQueue } from './sqs-queue.entity'; +import { Repository } from 'typeorm'; + +type QueryParams = { + 'Attribute.Name': string; + 'Attribute.Value': string; + __path: string; +} + +@Injectable() +export class SetQueueAttributesHandler extends AbstractActionHandler { + + constructor( + @InjectRepository(SqsQueue) + private readonly sqsQueueRepo: Repository, + private readonly attributeService: AttributesService, + ) { + super(); + } + + format = Format.Xml; + action = Action.SqsSetQueueAttributes; + validator = Joi.object({ + 'Attribute.Name': Joi.string().required(), + 'Attribute.Value': Joi.string().required(), + __path: Joi.string().required(), + }); + + protected async handle(params: QueryParams, awsProperties: AwsProperties) { + const [accountId, name] = SqsQueue.getAccountIdAndNameFromPath(params.__path); + const queue = await this.sqsQueueRepo.findOne({ where: { accountId , name } }); + + if(!queue) { + throw new BadRequestException('ResourceNotFoundException'); + } + + await this.attributeService.create({ name: params['Attribute.Name'], value: params['Attribute.Value'], arn: queue.arn }); + } +} diff --git a/src/sqs/sqs-queue-entry.service.ts b/src/sqs/sqs-queue-entry.service.ts new file mode 100644 index 0000000..83e099d --- /dev/null +++ b/src/sqs/sqs-queue-entry.service.ts @@ -0,0 +1,68 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SqsQueue } from './sqs-queue.entity'; +import * as uuid from 'uuid'; + +type QueueEntry = { + id: string; + queueArn: string; + senderId: string; + message: string; + inFlightReleaseDate: Date; + createdAt: Date; +} + +@Injectable() +export class SqsQueueEntryService { + + // Heavy use may require event-driven locking implementation + private queues: Record = {}; + + constructor( + @InjectRepository(SqsQueue) + private readonly sqsQueueRepo: Repository, + ) {} + + async publish(accountId: string, queueName: string, message: string) { + const queue = await this.sqsQueueRepo.findOne({ where: { accountId, name: queueName }}); + + if (!queue) { + console.warn(`Warning bad subscription to ${queueName}`); + return; + } + + if (this.queues) { + this.queues[queue.arn] = []; + } + + this.queues[queue.arn].push({ + id: uuid.v4(), + queueArn: queue.arn, + senderId: accountId, + message, + inFlightReleaseDate: new Date(), + createdAt: new Date(), + }); + } + + async recieveMessages(accountId: string, queueName: string, maxNumberOfMessages = 10, visabilityTimeout = 0): Promise { + const queue = await this.sqsQueueRepo.findOne({ where: { accountId, name: queueName }}); + + if (!queue) { + throw new BadRequestException(); + } + + const accessDate = new Date(); + const newInFlightReleaseDate = new Date(accessDate); + newInFlightReleaseDate.setSeconds(accessDate.getSeconds() + visabilityTimeout); + const records = this.queues[queue.arn]?.filter(e => e.inFlightReleaseDate <= accessDate).slice(0, maxNumberOfMessages - 1); + records.forEach(e => e.inFlightReleaseDate = newInFlightReleaseDate); + return records; + } + + async purge(accountId: string, queueName: string) { + const queue = await this.sqsQueueRepo.findOne({ where: { accountId, name: queueName }}); + this.queues[queue.arn] = []; + } +} diff --git a/src/sqs/sqs-queue.entity.ts b/src/sqs/sqs-queue.entity.ts new file mode 100644 index 0000000..c5ccaaa --- /dev/null +++ b/src/sqs/sqs-queue.entity.ts @@ -0,0 +1,41 @@ +import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('sqs_queue') +export class SqsQueue extends BaseEntity { + + @PrimaryColumn({ name: 'name' }) + name: string; + + @Column({ name: 'account_id', nullable: false }) + accountId: string; + + @Column({ name: 'region', nullable: false }) + region: string; + + get arn(): string { + return `arn:aws:sns:${this.region}:${this.accountId}:${this.name}`; + } + + getUrl(host: string): string { + return `${host}/${this.accountId}/${this.name}`; + } + + static getAccountIdAndNameFromPath(path: string): [string, string] { + const [_, accountId, name] = path.split('/'); + return [accountId, name]; + } + + static getAccountIdAndNameFromArn(arn: string): [string, string] { + const parts = arn.split(':'); + const name = parts.pop(); + const accountId = parts.pop(); + return [accountId, name]; + } + + static tryGetAccountIdAndNameFromPathOrArn(pathOrArn: string): [string, string] { + if (pathOrArn.split(':').length) { + return SqsQueue.getAccountIdAndNameFromArn(pathOrArn); + } + return SqsQueue.getAccountIdAndNameFromPath(pathOrArn); + } +} diff --git a/src/sqs/sqs.constants.ts b/src/sqs/sqs.constants.ts new file mode 100644 index 0000000..3cf55eb --- /dev/null +++ b/src/sqs/sqs.constants.ts @@ -0,0 +1,5 @@ +import { AbstractActionHandler } from '../abstract-action.handler'; +import { Action } from '../action.enum'; + +export type SqsHandlers = Record; +export const SqsHandlers = Symbol.for('SQS_HANDLERS'); diff --git a/src/sqs/sqs.module.ts b/src/sqs/sqs.module.ts index e69de29..27975c7 100644 --- a/src/sqs/sqs.module.ts +++ b/src/sqs/sqs.module.ts @@ -0,0 +1,60 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Format } from '../abstract-action.handler'; +import { Action } from '../action.enum'; +import { AwsSharedEntitiesModule } from '../aws-shared-entities/aws-shared-entities.module'; +import { DefaultActionHandlerProvider } from '../default-action-handler/default-action-handler.provider'; +import { ExistingActionHandlersProvider } from '../default-action-handler/existing-action-handlers.provider'; +import { CreateQueueHandler } from './create-queue.handler'; +import { PurgeQueueHandler } from './purge-queue.handler'; +import { SetQueueAttributesHandler } from './set-queue-attributes.handler'; +import { SqsQueueEntryService } from './sqs-queue-entry.service'; +import { SqsQueue } from './sqs-queue.entity'; +import { SqsHandlers } from './sqs.constants'; + +const handlers = [ + CreateQueueHandler, + PurgeQueueHandler, + SetQueueAttributesHandler, +] + +const actions = [ + Action.SqsAddPermisson, + Action.SqsChangeMessageVisibility, + Action.SqsChangeMessageVisibilityBatch, + Action.SqsCreateQueue, + Action.SqsDeleteMessage, + Action.SqsDeleteMessageBatch, + Action.SqsDeleteQueue, + Action.SqsGetQueueAttributes, + Action.SqsGetQueueUrl, + Action.SqsListDeadLetterSourceQueues, + Action.SqsListQueues, + Action.SqsListQueueTags, + Action.SqsPurgeQueue, + Action.SqsReceiveMessage, + Action.SqsRemovePermission, + Action.SqsSendMessage, + Action.SqsSendMessageBatch, + Action.SqsSetQueueAttributes, + Action.SqsTagQueue, + Action.SqsUntagQueue, +] + +@Module({ + imports: [ + TypeOrmModule.forFeature([SqsQueue]), + AwsSharedEntitiesModule, + ], + providers: [ + ...handlers, + SqsQueueEntryService, + ExistingActionHandlersProvider(handlers), + DefaultActionHandlerProvider(SqsHandlers, Format.Xml, actions), + ], + exports: [ + SqsHandlers, + SqsQueueEntryService, + ] +}) +export class SqsModule {} diff --git a/yarn.lock b/yarn.lock index 31a9eee..b7e940e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -301,6 +301,21 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/body-parser@*": + version "1.19.2" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" + integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.35" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" + integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + dependencies: + "@types/node" "*" + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" @@ -327,11 +342,35 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== +"@types/express-serve-static-core@^4.17.33": + version "4.17.33" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz#de35d30a9d637dc1450ad18dd583d75d5733d543" + integrity sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express@^4.17.17": + version "4.17.17" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" + integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/json-schema@*", "@types/json-schema@^7.0.8": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== +"@types/mime@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" + integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== + "@types/node@*": version "18.15.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.3.tgz#f0b991c32cfc6a4e7f3399d6cb4b8cf9a0315014" @@ -342,6 +381,24 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/qs@*": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + +"@types/range-parser@*": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" + integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== + +"@types/serve-static@*": + version "1.15.1" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d" + integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ== + dependencies: + "@types/mime" "*" + "@types/node" "*" + "@types/uuid@8.3.4": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"