Skip to content

Commit

Permalink
Wire RDS credentials to app
Browse files Browse the repository at this point in the history
  • Loading branch information
danielnaab committed Feb 28, 2025
1 parent 09c4b9e commit ff3889f
Show file tree
Hide file tree
Showing 9 changed files with 545 additions and 11 deletions.
1 change: 1 addition & 0 deletions apps/server-doj/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@gsa-tts/forms-database": "workspace:*",
"@gsa-tts/forms-infra-core": "workspace:*",
"@gsa-tts/forms-server": "workspace:*"
},
"devDependencies": {
Expand Down
34 changes: 32 additions & 2 deletions apps/server-doj/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { createPostgresDatabaseContext } from '@gsa-tts/forms-database/context';
import { getAWSSecretsManagerVault } from '@gsa-tts/forms-infra-core';

import { createCustomServer } from './server.js';

const port = process.env.PORT || 4321;

const getCloudGovServerSecrets = () => {
if (process.env.VCAP_SERVICES === undefined) {
throw new Error('VCAP_SERVICES not found');
return;
}
const services = JSON.parse(process.env.VCAP_SERVICES || '{}');
return {
Expand All @@ -14,7 +16,35 @@ const getCloudGovServerSecrets = () => {
};
};

const secrets = getCloudGovServerSecrets();
const getAppRunnerSecrets = async () => {
const secrets = {
dbHost: process.env.DB_HOST,
dbPort: process.env.DB_PORT,
dbName: process.env.DB_NAME,
dbSecretArn: process.env.DB_SECRET_ARN,
}
if (secrets.dbHost === undefined || secrets.dbPort === undefined || secrets.dbName === undefined || secrets.dbSecretArn === undefined) {
return;
}

const vault = getAWSSecretsManagerVault();
const dbSecret = await vault.getSecret(secrets.dbSecretArn);
if (dbSecret === undefined) {
console.error('Error getting secret:', secrets.dbSecretArn);
return;
}
const secret = JSON.parse(dbSecret);
return {
dbUri: `postgresql://${secret.username}:${secret.password}@${secret.dbHost}:${secret.dbPort}/${secret.dbName}`
};
};

const secrets = getCloudGovServerSecrets() || (await getAppRunnerSecrets());
if (secrets === undefined) {
console.error('Error getting secrets');
process.exit(1);
}

const db = await createPostgresDatabaseContext(secrets.dbUri, true);
const server = await createCustomServer(db);
server.listen(port, () => {
Expand Down
40 changes: 33 additions & 7 deletions infra/aws-cdk/lib/forms-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as apprunner from '@aws-cdk/aws-apprunner-alpha';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as changeCase from 'change-case';
import { Duration } from 'aws-cdk-lib';

import { getDatabaseSecretKey } from '@gsa-tts/forms-infra-core';

export class FormsPlatformStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
Expand Down Expand Up @@ -46,28 +50,49 @@ export class FormsPlatformStack extends cdk.Stack {
'Allow postgres access from App Runner'
);

const dbSecret = new secretsmanager.Secret(this, `${id}-rds-secret`, {
secretName: getDatabaseSecretKey(environment.valueAsString),
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: 'postgres' }),
generateStringKey: 'password',
},
});

// RDS database configuration
const databaseName = `${changeCase.snakeCase(environment.valueAsString)}_database`;
new rds.DatabaseInstance(this, `${id}-db`, {
const rdsInstance = new rds.DatabaseInstance(this, `${id}-db`, {
allocatedStorage: 20,
credentials: rds.Credentials.fromSecret(dbSecret),
databaseName,
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_15,
}),
vpc,
securityGroups: [rdsSecurityGroup],
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.BURSTABLE3,
ec2.InstanceSize.MICRO
),
allocatedStorage: 20,
maxAllocatedStorage: 100,
databaseName,
credentials: rds.Credentials.fromGeneratedSecret('postgres'),
removalPolicy: cdk.RemovalPolicy.DESTROY,
securityGroups: [rdsSecurityGroup],
vpc,
});

const appRunnerRole = new iam.Role(this, `${id}-iam-role-app-runner`, {
assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'),
});
dbSecret.grantRead(appRunnerRole);

const appRunnerService = new apprunner.Service(this, `${id}-app-runner`, {
source: apprunner.Source.fromEcrPublic({
imageConfiguration: { port: 4321 },
imageConfiguration: {
port: 4321,
environmentVariables: {
DB_SECRET_ARN: dbSecret.secretArn,
DB_HOST: rdsInstance.dbInstanceEndpointAddress,
DB_PORT: rdsInstance.dbInstanceEndpointPort,
DB_NAME: 'postgres'
},
},
imageIdentifier: dockerImagePath.valueAsString,
}),
healthCheck: apprunner.HealthCheck.http({
Expand All @@ -77,6 +102,7 @@ export class FormsPlatformStack extends cdk.Stack {
timeout: Duration.seconds(10),
unhealthyThreshold: 10,
}),
instanceRole: appRunnerRole,
observabilityConfiguration: new apprunner.ObservabilityConfiguration(
this,
`${id}-observability-configuration`,
Expand Down
3 changes: 2 additions & 1 deletion infra/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
"test": "vitest run --coverage"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/client-ssm": "^3.624.0",
"@gsa-tts/forms-common": "workspace:*",
"@gsa-tts/forms-core": "workspace:*",
"@aws-sdk/client-ssm": "^3.624.0",
"zod": "^3.23.8"
}
}
3 changes: 2 additions & 1 deletion infra/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as commands from './commands/index.js';
export { getSecretsVault } from './lib/index.js';
export { getSecretsVault, getAWSSecretsManagerVault } from './lib/index.js';
export { type DeployEnv } from './values.js';
export * from './lib/secrets/index.js';
134 changes: 134 additions & 0 deletions infra/core/src/lib/adapters/aws-secrets-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {
CreateSecretCommand,
DeleteSecretCommand,
GetSecretValueCommand,
ListSecretsCommand,
ResourceExistsException,
ResourceNotFoundException,
SecretsManagerClient,
UpdateSecretCommand,
} from '@aws-sdk/client-secrets-manager';

import type {
SecretKey,
SecretMap,
SecretValue,
SecretsVault,
} from '../types.js';

/**
* Provides an implementation of the SecretsVault interface leveraging
* AWS Secrets Manager to manage secrets securely.
*/
export class AWSSecretsManagerSecretsVault implements SecretsVault {
client: SecretsManagerClient;

constructor() {
this.client = new SecretsManagerClient();
}

async deleteSecret(key: SecretKey) {
try {
await this.client.send(
new DeleteSecretCommand({
SecretId: key,
ForceDeleteWithoutRecovery: true,
})
);
console.log(`Secret "${key}" deleted successfully.`);
} catch (error) {
console.warn('Skipped deleting secret due to error:', error);
}
}

async getSecret(key: SecretKey) {
try {
const response = await this.client.send(
new GetSecretValueCommand({
SecretId: key,
})
);
return response.SecretString || '';
} catch (error) {
if (error instanceof ResourceNotFoundException) {
return undefined;
}
console.error('Error getting secret:', error);
throw error;
}
}

async getSecrets(keys: SecretKey[]) {
const values: { [key: SecretKey]: SecretValue } = {};

for (const key of keys) {
try {
const value = await this.getSecret(key);
if (value !== undefined) {
values[key] = value;
}
} catch (error) {
console.error('Error getting secret:', error);
throw error;
}
}

return values;
}

async setSecret(key: SecretKey, value: SecretValue) {
try {
await this.client.send(
new CreateSecretCommand({
Name: key,
SecretString: value,
})
);
console.log(`Secret "${key}" created successfully.`);
} catch (error) {
if (error instanceof ResourceExistsException) {
await this.client.send(
new UpdateSecretCommand({
SecretId: key,
SecretString: value,
})
);
console.log(`Secret "${key}" updated successfully.`);
} else {
console.error('Error setting secret:', error);
throw error;
}
}
}

async setSecrets(secrets: SecretMap) {
const promises = Object.entries(secrets).map(([key, value]) =>
this.setSecret(key, value)
);
await Promise.all(promises);
}

async getSecretKeys() {
let keys: string[] = [];
let nextToken: string | undefined;
do {
try {
const response = await this.client.send(
new ListSecretsCommand({
NextToken: nextToken,
MaxResults: 50,
})
);
if (response.SecretList) {
keys.push(...response.SecretList.map(secret => secret.Name!));
}
nextToken = response.NextToken;
} catch (error) {
console.error('Error listing secrets:', error);
throw error;
}
} while (nextToken);

return keys;
}
}
5 changes: 5 additions & 0 deletions infra/core/src/lib/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as r from '@gsa-tts/forms-common';
import { AWSParameterStoreSecretsVault } from './aws-param-store.js';
import { getSecretMapFromJsonString, type SecretsVault } from '../types.js';
import { InMemorySecretsVault } from './in-memory.js';
import { AWSSecretsManagerSecretsVault } from './aws-secrets-manager.js';

/**
* Returns either a production vault or an in-memory vault initialized with the
Expand All @@ -31,6 +32,10 @@ export const getAWSSecretsVault = (): SecretsVault => {
return new AWSParameterStoreSecretsVault();
};

export const getAWSSecretsManagerVault = (): SecretsVault => {
return new AWSSecretsManagerSecretsVault();
};

export const createInMemorySecretsVault = (
jsonString?: any
): r.Result<SecretsVault> => {
Expand Down
5 changes: 5 additions & 0 deletions infra/core/src/lib/secrets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ export const getSecretKeys = (env: string) => [
`/tts-10x-forms-${env}/server-kansas/login.gov/private-key`,
`/tts-10x-forms-${env}/server-kansas/login.gov/public-key`,
];

const secretPrefix = (env: string) => `/tts-10x-forms-${env}`;

export const getDatabaseSecretKey = (env: string) =>
`/${secretPrefix(env)}/database`;
Loading

0 comments on commit ff3889f

Please sign in to comment.