Audit logging, secrets manager completion, sns and sqs wrapping up

This commit is contained in:
Matthew Bessette 2023-03-22 03:00:53 -04:00
parent 07d3841cd7
commit ee0babc2e3
30 changed files with 845 additions and 23 deletions

View File

@ -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",

View File

@ -5,6 +5,7 @@ import * as Joi from 'joi';
export type AwsProperties = {
accountId: string;
region: string;
host: string;
}
export enum Format {

View File

@ -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<string, any>,
@Headers() headers: Record<string, any>,
) {
@ -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) {

View File

@ -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,
],
},

20
src/audit/audit.entity.ts Normal file
View File

@ -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;
}

View File

@ -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<T> implements NestInterceptor<T, Response> {
constructor(
@InjectRepository(Audit)
private readonly auditRepo: Repository<Audit>,
) {}
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
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();
})
);
}
}

View File

@ -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<Attribute> {
return await this.repo.findOne({ where: { arn, name: ResourcePolicyName }});
}
async createResourcePolicy(arn: string, value: string): Promise<Attribute> {
return await this.create({arn, value, name: ResourcePolicyName });
}
async create(dto: CreateAttributeDto): Promise<Attribute> {
return await this.repo.save(dto);
}

View File

@ -4,4 +4,5 @@ export interface CommonConfig {
DB_DATABASE: string;
DB_LOGGING?: boolean;
DB_SYNCHRONIZE?: boolean;
HOST: string;
}

View File

@ -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',
});

View File

@ -0,0 +1,8 @@
export interface CreateSecretDto {
versionId?: string;
name: string;
description?: string;
secretString?: string;
accountId: string;
region: string;
}

View File

@ -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<QueryParams> {
constructor(
@InjectRepository(Secret)
private readonly secretRepo: Repository<Secret>,
private readonly secretService: SecretService,
) {
super();
}
@ -30,21 +29,21 @@ export class CreateSecretHandler extends AbstractActionHandler<QueryParams> {
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 };
}

View File

@ -27,11 +27,9 @@ export class DescribeSecretHandler extends AbstractActionHandler {
validator = Joi.object<QueryParams, true>({ 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,
}
}
}
}

View File

@ -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<Secret>,
private readonly attributesService: AttributesService,
) {
super();
}
format = Format.Json;
action = Action.SecretsManagerGetResourcePolicy;
validator = Joi.object<QueryParams, true>({ 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,
}
}
}

View File

@ -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<QueryParams, true>({
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,
}
}
}

View File

@ -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<Secret>,
private readonly attributesService: AttributesService,
) {
super();
}
format = Format.Json;
action = Action.SecretsManagerPutResourcePolicy;
validator = Joi.object<QueryParams, true>({
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,
}
}
}

View File

@ -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<QueryParams> {
constructor(
private readonly secretService: SecretService,
) {
super();
}
format = Format.Json;
action = Action.SecretsManagerPutSecretValue;
validator = Joi.object<QueryParams, true>({
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: [] }
}
}

View File

@ -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;
}
}

View File

@ -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<Secret>,
) {}
async findLatestByNameAndRegion(name: string, region: string): Promise<Secret> {
return await this.secretRepo.findOne({ where: { name, region }, order: { createdAt: 'DESC' } });
}
async findByNameAndVersion(name: string, versionId: string): Promise<Secret> {
// 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<Secret> {
return await this.secretRepo.create({
...dto,
versionId: dto.versionId ?? uuid.v4(),
}).save();
}
}

View File

@ -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),

View File

@ -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<QueryParams> {
constructor(
@InjectRepository(SnsTopicSubscription)
private readonly snsTopicSubscriptionRepo: Repository<SnsTopicSubscription>,
private readonly sqsQueueEntryService: SqsQueueEntryService,
private readonly attributeService: AttributesService,
) {
super();
}
format = Format.Xml;
action = Action.SnsPublish;
validator = Joi.object<QueryParams, true>({
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 };
}
}

View File

@ -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,

View File

@ -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<QueryParams> {
constructor(
@InjectRepository(SqsQueue)
private readonly sqsQueueRepo: Repository<SqsQueue>,
private readonly tagsService: TagsService,
private readonly attributeService: AttributesService,
) {
super();
}
format = Format.Xml;
action = Action.SqsCreateQueue;
validator = Joi.object<QueryParams, true>({ 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) };
}
}

View File

@ -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<QueryParams> {
constructor(
private readonly sqsQueueEntryService: SqsQueueEntryService,
) {
super();
}
format = Format.Xml;
action = Action.SqsPurgeQueue;
validator = Joi.object<QueryParams, true>({ __path: Joi.string().required() });
protected async handle({ __path }: QueryParams, awsProperties: AwsProperties) {
const [accountId, name] = SqsQueue.getAccountIdAndNameFromPath(__path);
await this.sqsQueueEntryService.purge(accountId, name);
}
}

View File

@ -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<QueryParams> {
constructor(
private readonly sqsQueueEntryService: SqsQueueEntryService,
) {
super();
}
format = Format.Xml;
action = Action.SqsReceiveMessage;
validator = Joi.object<QueryParams, true>({
__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() }},
]
}
}));
}
}

View File

@ -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<QueryParams> {
constructor(
@InjectRepository(SqsQueue)
private readonly sqsQueueRepo: Repository<SqsQueue>,
private readonly attributeService: AttributesService,
) {
super();
}
format = Format.Xml;
action = Action.SqsSetQueueAttributes;
validator = Joi.object<QueryParams, true>({
'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 });
}
}

View File

@ -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<string, QueueEntry[]> = {};
constructor(
@InjectRepository(SqsQueue)
private readonly sqsQueueRepo: Repository<SqsQueue>,
) {}
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<QueueEntry[]> {
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] = [];
}
}

View File

@ -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);
}
}

5
src/sqs/sqs.constants.ts Normal file
View File

@ -0,0 +1,5 @@
import { AbstractActionHandler } from '../abstract-action.handler';
import { Action } from '../action.enum';
export type SqsHandlers = Record<Action, AbstractActionHandler>;
export const SqsHandlers = Symbol.for('SQS_HANDLERS');

View File

@ -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 {}

View File

@ -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"