Commit 0baf6f8e authored by Craig Andrews's avatar Craig Andrews

Use AWS Secrets Manager to provide storage, retrieval, and rotation of...

Use AWS Secrets Manager to provide storage, retrieval, and rotation of WordPress database credentials
parent 93a09924
Pipeline #91709818 passed with stages
in 7 minutes and 26 seconds
......@@ -3,7 +3,7 @@ From wordpress:php7.3-apache
ENV VERSIONPRESS_VERSION=4.0-beta2
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install --no-install-recommends -y sudo openssh-client git less default-mysql-client unzip && \
apt-get install --no-install-recommends -y sudo awscli openssh-client git less default-mysql-client unzip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
......@@ -24,6 +24,9 @@ RUN chmod +x /bin/wp
# Download the VersionPress zip for later use
RUN curl -L -o /usr/src/versionpress.zip https://github.com/versionpress/versionpress/releases/download/${VERSIONPRESS_VERSION}/versionpress-${VERSIONPRESS_VERSION}.zip
COPY db.php /usr/local
COPY wp-cli-secrets-manager.php /usr/local
ENV WP_CLI_PHP_ARGS="--require=/usr/local/wp-cli-secrets-manager.php"
COPY health.php /usr/local
COPY vp-pull.php /usr/local
COPY git-hooks/ /usr/local/git-hooks
......
......@@ -5,6 +5,7 @@ cd /var/www/html
wp config set WP_ENV "${WP_ENV}" --type=constant
wp config set VP_ENVIRONMENT "${WP_ENV}" --type=constant
wp config set VP_PULL_SECRET "${VP_PULL_SECRET-}" --type=constant
cp /usr/local/db.php wp-content/
if [[ -d wp-content/vpdb && $(ls -A wp-content/vpdb) ]]; then
if ! wp core is-installed || [[ "$(wp option get siteurl)" != "${SITE_URL}" ]] ; then
......
<?php
if ( empty ( getenv('WORDPRESS_DB_SECRET_ID') ) ) {
// if the secret id isn't set, don't install this approach
return;
}
/*
Plugin Name: Extended wpdb to use AWS Secrets Manager credentials
Description: Get the database username/password from an AWS Secrets Manager
Version: 1.0
Author: Craig Andrews
*/
class wpdb_aws_secrets_manager_extended extends wpdb {
/**
* Path to the cache file
*
* @var string
*/
private $secretCacheFile;
public function __construct() {
$this->dbname = defined( 'DB_NAME' ) ? DB_NAME : '';
$this->dbhost = defined( 'DB_HOST' ) ? DB_HOST : '';
$this->secretCacheFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5(getenv('WORDPRESS_DB_SECRET_ID'));
$this->_load_credentials();
parent::__construct( $this->dbuser, $this->dbpassword, $this->dbname, $this->dbhost );
}
public function db_connect( $allow_bail = true ) {
$ret = parent::db_connect( false );
if (! $ret ) {
// connection failed, refresh the credentials
$this->_refresh_credentials();
$ret = parent::db_connect( $allow_bail );
}
return $ret;
}
/**
* Load the credentials from cached storage
* If no credentials are cached, refresh credentials
*/
private function _load_credentials() {
if ( file_exists ( $this->secretCacheFile ) ) {
$data = json_decode ( file_get_contents ( $this->secretCacheFile ) );
$this->dbuser = $data->username;
$this->dbpassword = $data->password;
} else {
$this->_refresh_credentials();
}
}
/**
* Refresh the credentials from Secrets Mananager
* and write to cached storage
*/
private function _refresh_credentials() {
exec('aws secretsmanager get-secret-value --secret-id ' . escapeshellarg(getenv('WORDPRESS_DB_SECRET_ID')) . ' --query SecretString --output text > ' . escapeshellarg($this->secretCacheFile), $retArr, $status);
chmod($this->secretCacheFile, 0600); // Read and write for owner, nothing for everybody else
if ( $status != 0 ) {
$this->bail("Could not refresh the AWS Secrets Manager secret");
die();
}
$this->_load_credentials();
}
}
global $wpdb;
$wpdb = new wpdb_aws_secrets_manager_extended();
<?php
if ( empty ( getenv('WORDPRESS_DB_SECRET_ID') ) ) {
// if the secret id isn't set, don't install this approach
return;
}
class aws_secrets_manager_utility {
/**
* Database user name
*
* @var string
*/
public $dbuser;
/**
* Database password
*
* @var string
*/
public $dbpassword;
public function __construct() {
exec('aws secretsmanager get-secret-value --secret-id ' . escapeshellarg(getenv('WORDPRESS_DB_SECRET_ID')) . ' --query SecretString --output text', $retArr, $status);
if ( $status != 0 ) {
die("Could not retrieve the AWS Secrets Manager secret");
}else{
$data = json_decode ( $retArr[0] );
$this->dbuser = $data->username;
$this->dbpassword = $data->password;
}
}
}
$aws_secrets_manager_utility = new aws_secrets_manager_utility();
# These constants have to be defined here before WP-CLI loads wp-config.php
define('DB_USER', $aws_secrets_manager_utility->dbuser);
define('DB_PASSWORD', $aws_secrets_manager_utility->dbpassword);
#!/bin/sh
# This is a wrapper so that wp-cli can run as the www-data user so that permissions
# remain correct
sudo -E -u www-data /bin/wp-cli.phar "$@"
sudo -E -u www-data "${WP_CLI_PHP-php}" /bin/wp-cli.phar "${WP_CLI_PHP_ARGS}" "$@"
---
AWSTemplateFormatVersion: '2010-09-09'
Description: Wordpress
Transform: AWS::Serverless-2016-10-31
Parameters:
WordpressEnvironment:
Description: Value to use for WP_ENV environment variable
......@@ -61,23 +62,6 @@ Parameters:
located.
Type: String
MinLength: 1
DBUsername:
Default: wordpress
NoEcho: true
Description: The database admin account username
Type: String
MinLength: 2
MaxLength: 41
AllowedPattern: '[a-zA-Z0-9]*'
ConstraintDescription: must contain only alphanumeric characters.
DBPassword:
NoEcho: true
Description: The database admin account password
Type: String
MinLength: 8
MaxLength: 41
AllowedPattern: '[a-zA-Z0-9]*'
ConstraintDescription: must contain only alphanumeric characters.
DBInstanceClass:
Description: The database instance type
Type: String
......@@ -777,6 +761,24 @@ Resources:
reason: Beanstalk instances need to reach the Internet to install plugins,
check for updates, etc so this security group should have cidr open
to world on egress
DatabaseMasterSecretLambdaSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow DatabaseMasterSecretLambda access to the database and the Internet
VpcId: !Ref 'VPC'
SecurityGroupEgress:
- IpProtocol: '-1'
CidrIpv6: ::/0
- IpProtocol: '-1'
CidrIp: '0.0.0.0/0'
Metadata:
cfn_nag:
rules_to_suppress:
- id: W40
reason: IpProtocol -1 is used to allow all traffic
- id: W5
reason: This security group grants Internet access so it should have cidr
open to world on egress
NATSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
......@@ -791,6 +793,10 @@ Resources:
FromPort: 443
ToPort: 443
SourceSecurityGroupId: !Ref 'BeanstalkSecurityGroup'
- IpProtocol: tcp
FromPort: 443
ToPort: 443
SourceSecurityGroupId: !Ref 'DatabaseMasterSecretLambdaSecurityGroup'
SecurityGroupEgress:
- IpProtocol: '-1'
CidrIpv6: ::/0
......@@ -850,6 +856,14 @@ Resources:
FromPort: !GetAtt 'Database.Endpoint.Port'
ToPort: !GetAtt 'Database.Endpoint.Port'
SourceSecurityGroupId: !Ref 'BeanstalkSecurityGroup'
DatabaseMasterSecretLambdaDBSecurityGroupUpdate:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !Ref 'DBSecurityGroup'
IpProtocol: tcp
FromPort: !GetAtt 'Database.Endpoint.Port'
ToPort: !GetAtt 'Database.Endpoint.Port'
SourceSecurityGroupId: !Ref 'DatabaseMasterSecretLambdaSecurityGroup'
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
......@@ -858,6 +872,58 @@ Resources:
- !Ref 'PrivateSubnet1'
- !Ref 'PrivateSubnet2'
- !Ref 'PrivateSubnet3'
DatabaseMasterSecret:
Type: AWS::SecretsManager::Secret
Properties:
Description: Secret containing the master database credentials
GenerateSecretString:
SecretStringTemplate: '{"username":"wordpress"}'
GenerateStringKey: password
PasswordLength: 16
ExcludeCharacters: '"@/\'
Tags:
- Key: Application
Value: !Ref 'AWS::StackName'
DatabaseMasterSecretTargetAttachment:
Type: AWS::SecretsManager::SecretTargetAttachment
Properties:
SecretId: !Ref 'DatabaseMasterSecret'
TargetId: !Ref 'Database'
TargetType: AWS::RDS::DBInstance
# This is a RotationSchedule resource. It configures rotation of password for the referenced secret using a rotation lambda
# The first rotation happens at resource creation time, with subsequent rotations scheduled according to the rotation rules
# We explicitly depend on the SecretTargetAttachment resource being created to ensure that the secret contains all the
# information necessary for rotation to succeed
DatabaseMasterSecretRotationSchedule:
Type: AWS::SecretsManager::RotationSchedule
DependsOn:
- DatabaseMasterSecretTargetAttachment
- DatabaseMasterSecretLambdaInvokePermission
Properties:
SecretId: !Ref DatabaseMasterSecret
RotationLambdaARN: !GetAtt SecretsManagerRDSMariaDBRotationSingleUser.Outputs.RotationLambdaARN
RotationRules:
AutomaticallyAfterDays: 7
SecretsManagerRDSMariaDBRotationSingleUser:
DependsOn:
- DatabaseMasterSecretLambdaDBSecurityGroupUpdate
Type: AWS::Serverless::Application
Properties:
Location:
ApplicationId: arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMariaDBRotationSingleUser
SemanticVersion: 1.0.89
Parameters:
endpoint: !Sub 'https://secretsmanager.${AWS::Region}.${AWS::URLSuffix}'
functionName: !Sub "${AWS::StackName}-db-rotate"
vpcSecurityGroupIds: !Sub "${DatabaseMasterSecretLambdaSecurityGroup}"
vpcSubnetIds: !Sub "${PrivateSubnet1},${PrivateSubnet2},${PrivateSubnet3}"
# This is a lambda Permission resource which grants Secrets Manager permission to invoke the rotation lambda function
DatabaseMasterSecretLambdaInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt SecretsManagerRDSMariaDBRotationSingleUser.Outputs.RotationLambdaARN
Action: 'lambda:InvokeFunction'
Principal: secretsmanager.amazonaws.com
Database:
Type: AWS::RDS::DBInstance
DeletionPolicy: Snapshot
......@@ -869,8 +935,8 @@ Resources:
DBInstanceClass: !Ref 'DBInstanceClass'
Engine: MariaDB
EngineVersion: '10.3'
MasterUsername: !Ref 'DBUsername'
MasterUserPassword: !Ref 'DBPassword'
MasterUsername: !Sub '{{resolve:secretsmanager:${DatabaseMasterSecret}:SecretString:username}}'
MasterUserPassword: !Sub '{{resolve:secretsmanager:${DatabaseMasterSecret}:SecretString:password}}'
MultiAZ: !Ref 'DBMultiAZ'
AllowMajorVersionUpgrade: true
StorageType: gp2
......@@ -1059,6 +1125,17 @@ Resources:
- ec2.amazonaws.com
Action:
- sts:AssumeRole
Policies:
- PolicyName: access-database-secret
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
- secretsmanager:DescribeSecret
- secretsmanager:ListSecretVersionIds
Resource: !Ref 'DatabaseMasterSecret'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
- arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier
......@@ -1281,10 +1358,16 @@ Resources:
Value: wordpress
- Namespace: aws:elasticbeanstalk:application:environment
OptionName: WORDPRESS_DB_USER
Value: !Ref 'DBUsername'
Value: "Using AWS Secrets Manager for User"
- Namespace: aws:elasticbeanstalk:application:environment
OptionName: WORDPRESS_DB_PASSWORD
Value: !Ref 'DBPassword'
Value: "Using AWS Secrets Manager for Password"
- Namespace: aws:elasticbeanstalk:application:environment
OptionName: AWS_DEFAULT_REGION
Value: !Sub "${AWS::Region}"
- Namespace: aws:elasticbeanstalk:application:environment
OptionName: WORDPRESS_DB_SECRET_ID
Value: !Ref DatabaseMasterSecret
Tags:
- Key: Application
Value: !Ref 'AWS::StackName'
......
......@@ -7,6 +7,7 @@ else
exit 1
fi
RDS_IDENTIFIER=$(aws cloudformation describe-stack-resource --stack-name "${STACK_NAME}" --logical-resource-id Database --output text --query 'StackResourceDetail.PhysicalResourceId')
RDS_SECRET_IDENTIFIER=$(aws cloudformation describe-stack-resource --stack-name "${STACK_NAME}" --logical-resource-id DatabaseMasterSecret --output text --query 'StackResourceDetail.PhysicalResourceId')
RDS_ADDRESS=$(aws rds describe-db-instances --db-instance-identifier "${RDS_IDENTIFIER}" --output text --query 'DBInstances[0].Endpoint.Address')
RDS_PORT=$(aws rds describe-db-instances --db-instance-identifier "${RDS_IDENTIFIER}" --output text --query 'DBInstances[0].Endpoint.Port')
ENVIRONMENT_NAME=$(aws cloudformation describe-stack-resource --stack-name "${STACK_NAME}" --logical-resource-id BeanstalkEnvironment --output text --query 'StackResourceDetail.PhysicalResourceId')
......@@ -45,4 +46,7 @@ aws ssm send-command \
--comment "Grant ssh access for ${SSH_PUBLIC_KEY_TIMEOUT} seconds" > /dev/null
echo "Connecting to instance ${INSTANCE_ID}"
echo "Connect to localhost:13306 to access the database."
echo "To get the database username/password credentials, run: "
echo "aws secretsmanager get-secret-value --region $(aws configure get region) --secret-id ${RDS_SECRET_IDENTIFIER} | jq --raw-output '.SecretString' | jq -r '(\"username: \"+.username,\"password: \"+.password)'"
ssh -oProxyCommand="sh -c \"aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'\"" "${SSH_USER}"@"${INSTANCE_ID}" -L13306:"${RDS_ADDRESS}":"${RDS_PORT}"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment