Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: release #16

Merged
merged 9 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# replace this
# Webformulieren submission storage

Deze applicatie is verantwoordelijk voor de opslag van formulierinzendingen, voor afnemers (Mijn Nijmegen) die deze moeten kunnen tonen aan de indiener. De applicatie is gesubscribed op het SNS-topic dat in [webformulieren](https://github.com/gemeentenijmegen/webformulieren) bestaat, en verwerkt inzendingen bij binnenkomst.

Het is nog niet mogelijk ingezonden formulieren op te halen.
75 changes: 56 additions & 19 deletions src/ApiStack.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,70 @@
import { Stack } from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { AnyPrincipal, Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { ITopic, Topic } from 'aws-cdk-lib/aws-sns';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
import { Configurable } from './Configuration';
import { Statics } from './statics';
import { SubmissionSnsEventHandler } from './SubmissionSnsEventHandler';

interface ApiStackProps extends StackProps, Configurable {};
/**
* Contains all API-related resources.
*/
export class ApiStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);

// const api = new RestApi(this, 'gateway');
// api.root.addMethod('ANY', new MockIntegration({
// integrationResponses: [
// { statusCode: '200' },
// ],
// passthroughBehavior: PassthroughBehavior.NEVER,
// requestTemplates: {
// 'application/json': '{ "statusCode": 200 }',
// },
// }), {
// methodResponses: [
// { statusCode: '200' },
// ],
// });
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);

const internalTopic = new SNSTopic(this, 'submissions', { publishingAccountIds: props.configuration.allowedAccountIdsToPublishToSNS });
new SubmissionSnsEventHandler(this, 'submissionhandler', {
topicArn: StringParameter.valueForStringParameter(this, Statics.ssmSubmissionTopicArn),
topicArns: [internalTopic.topic.topicArn, StringParameter.valueForStringParameter(this, Statics.ssmSubmissionTopicArn)],
});
}
}

interface SNSTopicProps extends StackProps {
/**
* Allow access for different AWS accounts to publish to this topic
*/
publishingAccountIds?: string[];
}
class SNSTopic extends Construct {
topic: ITopic;
constructor(scope: Construct, id: string, props: SNSTopicProps) {
super(scope, id);

this.topic = new Topic(this, 'submissions', {
displayName: 'submissions',
});

this.allowCrossAccountAccess(props.publishingAccountIds);
}

/**
* Allow cross account access to this topic
*
* This allows lambda's with the execution role 'storesubmissions-lambda-role'
* in the accounts in `allowedAccountIds` access to publish to this topic.
*
* @param allowedAccountIds array of account IDs
*/
allowCrossAccountAccess(allowedAccountIds?: string[]): void {
if (!allowedAccountIds || allowedAccountIds.length == 0) { return; }
const crossAccountPrincipalArns = allowedAccountIds.map(
(accountId) => `arn:aws:iam::${accountId}:role/storesubmissions-lambda-role`,
);
this.topic.addToResourcePolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'SNS:Publish',
],
resources: [this.topic.topicArn],
principals: [new AnyPrincipal()],
conditions: {
ArnLike: {
'aws:PrincipalArn': crossAccountPrincipalArns,
},
},
}));
}
}
10 changes: 4 additions & 6 deletions src/ApiStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { PermissionsBoundaryAspect } from '@gemeentenijmegen/aws-constructs';
import { Aspects, Stage, StageProps, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { ApiStack } from './ApiStack';
import { Configurable, Configuration } from './Configuration';
import { Configurable } from './Configuration';
import { Statics } from './statics';
import { StorageStack } from './StorageStack';

Expand All @@ -13,20 +13,18 @@ interface ApiStageProps extends StageProps, Configurable { }
*/
export class ApiStage extends Stage {

readonly configuration: Configuration;

constructor(scope: Construct, id: string, props: ApiStageProps) {
super(scope, id, props);

Tags.of(this).add('cdkManaged', 'yes');
Tags.of(this).add('Project', Statics.projectName);
Aspects.of(this).add(new PermissionsBoundaryAspect());

this.configuration = props.configuration;
const configuration = props.configuration;

const storageStack = new StorageStack(this, 'storage');
const storageStack = new StorageStack(this, 'storage', { configuration });

const apiStack = new ApiStack(this, 'api');
const apiStack = new ApiStack(this, 'api', { configuration } );
apiStack.addDependency(storageStack);
}
}
8 changes: 8 additions & 0 deletions src/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export interface Configuration {
* includePipelineValidationChcks
*/
readonly includePipelineValidationChecks: boolean;

/**
* Allow this project's SNS topic to be published to
* by other accounts. This allows access to lambda
* execution roles named 'storesubmissions-lambda-role'.
*/
readonly allowedAccountIdsToPublishToSNS?: string[];
}

export function getConfiguration(branchName: string): Configuration {
Expand All @@ -45,6 +52,7 @@ const configurations: { [name: string] : Configuration } = {
deployFromEnvironment: Statics.gnBuildEnvironment,
deployToEnvironment: Statics.appDevEnvironment,
includePipelineValidationChecks: false,
allowedAccountIdsToPublishToSNS: [Statics.acceptanceWebformulierenAccountId],
},
production: {
branchName: 'main',
Expand Down
11 changes: 7 additions & 4 deletions src/StorageStack.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Duration, Stack } from 'aws-cdk-lib';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType, BillingMode, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { Key } from 'aws-cdk-lib/aws-kms';
import { Bucket, BucketEncryption, ObjectOwnership } from 'aws-cdk-lib/aws-s3';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
import { Configurable } from './Configuration';
import { Statics } from './statics';


interface StorageStackProps extends StackProps, Configurable {};

/**
* Contains all API-related resources.
*/
export class StorageStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
constructor(scope: Construct, id: string, props: StorageStackProps) {
super(scope, id, props);

const key = this.key();
/**
Expand Down Expand Up @@ -63,7 +67,6 @@ export class StorageStack extends Stack {

return key;
}

private addArnToParameterStore(id: string, arn: string, name: string) {
new StringParameter(this, id, {
stringValue: arn,
Expand Down
144 changes: 144 additions & 0 deletions src/StorageStack.ts.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType, BillingMode, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Key } from 'aws-cdk-lib/aws-kms';
import { Bucket, BucketEncryption, ObjectOwnership } from 'aws-cdk-lib/aws-s3';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
import { Configurable } from './Configuration';
import { Statics } from './statics';


interface StorageStackProps extends StackProps, Configurable {};

/**
* Contains all API-related resources.
*/
export class StorageStack extends Stack {
constructor(scope: Construct, id: string, props: StorageStackProps) {
super(scope, id, props);

const key = this.key();
/**
* This bucket will receive submission attachments
* (Submission PDF, uploads) for each submission.
*/
const bucket = new Bucket(this, 'submission-attachments', {
eventBridgeEnabled: true,
enforceSSL: true,
encryption: BucketEncryption.KMS,
objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
lifecycleRules: [
{
expiration: Duration.days(365),
Copy link
Member

Choose a reason for hiding this comment

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

waarom 365 dagen bewaren en niet korter of langer(zou ik verwachten nl)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Daar heeft nog niemand iets van gevonden, onbeperkt lijkt me ook onwenselijk. Ik zie overigens dat de merge conflict-file hierin zit, dus dit is niet nieuw.

},
],
encryptionKey: key,
});
this.addArnToParameterStore('bucketParam', bucket.bucketArn, Statics.ssmSubmissionBucketArn);
this.addArnToParameterStore('bucketNameParam', bucket.bucketName, Statics.ssmSubmissionBucketName);

const table = new Table(this, 'submissions', {
partitionKey: { name: 'pk', type: AttributeType.STRING },
sortKey: { name: 'sk', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'ttl',
encryptionKey: key,
encryption: TableEncryption.CUSTOMER_MANAGED,
});
this.addArnToParameterStore('tableParam', table.tableArn, Statics.ssmSubmissionTableArn);
this.addArnToParameterStore('tableNameParam', table.tableName, Statics.ssmSubmissionTableName);

this.addParameters();
}

private key(crossAccountIds?: string[]) {
const crossAccountPrincipalArns = this.crossAccountIdArns(crossAccountIds);
const key = new Key(this, 'kmskey', {
enableKeyRotation: true,
description: 'encryption key for user data',
alias: `${Statics.projectName}/user-data`,
});

this.allowCrossAccountKeyAccess(crossAccountPrincipalArns, key);

// Store key arn to be used in other stacks/projects
new StringParameter(this, 'key', {
stringValue: key.keyArn,
parameterName: Statics.ssmDataKeyArn,
});

return key;
}
<<<<<<< HEAD
=======

private allowCrossAccountKeyAccess(crossAccountPrincipalArns: string[] | null, key: Key) {
if (crossAccountPrincipalArns) {
key.addToResourcePolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'kms:GenerateDataKey',
'kms:Decrypt',
],
resources: [key.keyArn],
conditions: {
ArnLike: {
'aws:PrincipalArn': crossAccountPrincipalArns,
},
},
}), false);
}
}

private crossAccountIdArns(crossAccountIds: string[] | undefined) {
if (crossAccountIds && crossAccountIds.length > 0) {
return crossAccountIds.map(
(accountId) => `arn:aws:iam::${accountId}:role/storesubmissions-lambda-role`,
);
}
return null;
}

>>>>>>> development
private addArnToParameterStore(id: string, arn: string, name: string) {
new StringParameter(this, id, {
stringValue: arn,
parameterName: name,
});
}

/**
* Add general parameters, the values of which should be added later
*/
private addParameters() {
new StringParameter(this, 'submissionTopicArn', {
stringValue: '-',
parameterName: Statics.ssmSubmissionTopicArn,
});

new StringParameter(this, 'sourceBucketArn', {
stringValue: '-',
parameterName: Statics.ssmSourceBucketArn,
description: 'ARN for the source bucket, to allow copying submission files',
});

new StringParameter(this, 'sourceKeyArn', {
stringValue: '-',
parameterName: Statics.ssmSourceKeyArn,
description: 'ARN for the source bucket encryption key, to allow copying submission files',
});

new StringParameter(this, 'formIoBaseUrl', {
stringValue: '-',
parameterName: Statics.ssmFormIoBaseUrl,
description: 'Base url for retrieving form config. Includes stage path.',
});

new Secret(this, 'formIoApiKey', {
secretName: Statics.secretFormIoApiKey,
description: 'FormIO Api token for retrieving form config',
});
}
}
15 changes: 10 additions & 5 deletions src/SubmissionSnsEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Duration } from 'aws-cdk-lib';
import { ITable, Table } from 'aws-cdk-lib/aws-dynamodb';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Key } from 'aws-cdk-lib/aws-kms';
import { Function } from 'aws-cdk-lib/aws-lambda';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Bucket, IBucket } from 'aws-cdk-lib/aws-s3';
import { ISecret, Secret } from 'aws-cdk-lib/aws-secretsmanager';
Expand All @@ -13,14 +14,14 @@ import { SubmissionFunction } from './app/submission/submission-function';
import { Statics } from './statics';

interface SubmissionSnsEventHandlerProps {
topicArn: string;
topicArns: string[];
}
export class SubmissionSnsEventHandler extends Construct {
private role?: Role;
public lambda: Function;
constructor(scope: Construct, id: string, props: SubmissionSnsEventHandlerProps) {
super(scope, id);

const topic = Topic.fromTopicArn(this, 'submission-topic', props.topicArn);
const table = Table.fromTableName(this, 'table', StringParameter.valueForStringParameter(this, Statics.ssmSubmissionTableName));
const key = Key.fromKeyArn(this, 'key', StringParameter.valueForStringParameter(this, Statics.ssmDataKeyArn));
// IBucket requires encryption key, otherwise grant methods won't add the correct permissions
Expand All @@ -31,7 +32,8 @@ export class SubmissionSnsEventHandler extends Construct {
const sourceBucket = Bucket.fromBucketArn(this, 'sourceBucket', StringParameter.valueForStringParameter(this, Statics.ssmSourceBucketArn));
const secret = Secret.fromSecretNameV2(this, 'apikey', Statics.secretFormIoApiKey);

this.submissionHandlerLambda(storageBucket, sourceBucket, table, topic, secret);
const topics = props.topicArns.map((topicArn, i)=> Topic.fromTopicArn(this, `submission-topic-${i}`, topicArn));
this.lambda = this.submissionHandlerLambda(storageBucket, sourceBucket, table, topics, secret);
}

/**
Expand All @@ -46,7 +48,7 @@ export class SubmissionSnsEventHandler extends Construct {
* @param table The dynamodb table to store submission (meta)data in
* @param topic The SNS Topic to subscribe to for submissions
*/
private submissionHandlerLambda(bucket: IBucket, sourceBucket: IBucket, table: ITable, topic: ITopic, secret: ISecret) {
private submissionHandlerLambda(bucket: IBucket, sourceBucket: IBucket, table: ITable, topics: ITopic[], secret: ISecret) {
const submissionLambda = new SubmissionFunction(this, 'submission', {
role: this.lambdaRole(),
logRetention: RetentionDays.SIX_MONTHS,
Expand All @@ -65,7 +67,10 @@ export class SubmissionSnsEventHandler extends Construct {
const key = Key.fromKeyArn(this, 'sourceBucketKey', StringParameter.valueForStringParameter(this, Statics.ssmSourceKeyArn));
key.grantDecrypt(submissionLambda);

topic.addSubscription(new LambdaSubscription(submissionLambda));
for (const topic of topics) {
topic.addSubscription(new LambdaSubscription(submissionLambda));
}
return submissionLambda;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/app/submission/SubmissionSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const SubmissionSchema = z.object({
reference: z.string(),
data: z.object({
kenmerk: z.string(),
naamIngelogdeGebruiker: z.string(),
}).required().passthrough(),
naamIngelogdeGebruiker: z.string().optional(),
}).passthrough(),
employeeData: z.any(),
pdf: z.object({
reference: z.string(),
Expand Down
Loading