diff --git a/README.md b/README.md index 72f0ac1..04af41d 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,28 @@ database_engine: POSTGRESQL database_engine: MYSQL ``` -**IAM Auth** +**User Authentication** -IAM authentication to the proxy is enabled by default, to disable this use the following config +RDS proxy takes a list of users that can access the database through the proxy. + +Each user requires a secret in AWS Secrets Manager which contains the username and password in a json format. +The Secrets Manager ARN is required to be passed through as a parameter to the component. [Setting up database credentials in Secrets Manager](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-setup.html#rds-proxy-secrets-arns) + +```json +{"username":"admin","password":"choose_your_own_password"} +``` + +A user is enabled by default with IAM authentication enabled. This user can be [disabled](tests/multiple_users.test.yaml) or [added](tests/multiple_users.test.yaml) onto. ```yaml -iam_auth: DISABLED +users: + default: + secret_arn_parameter: SecretCredentials + iam_auth: REQUIRED # REQUIRED | DISABLED ``` +IAM auth can also be [disabled](tests/disable_iam_auth.test.yaml) on the default user + **Security Group Rules** configure network access to the proxy, set a ingress rule on the security group. For further rule options see docs [here](https://github.com/theonestack/hl-component-lib-ec2#security-group-rules) diff --git a/rds-proxy.cfhighlander.rb b/rds-proxy.cfhighlander.rb index 91a7ee7..361a1f0 100644 --- a/rds-proxy.cfhighlander.rb +++ b/rds-proxy.cfhighlander.rb @@ -15,7 +15,13 @@ ComponentParam 'DBClusterSecurityGroup', type: 'AWS::EC2::SecurityGroup::Id' ComponentParam 'ProxyName', 'rdsproxy', description: 'name of the rds proxy required by cloudformation. this value prefixed with the environment name' - ComponentParam 'SecretCredentials', description: 'secrets manager arn of the secret. format of the secret must be json {"username": "user", "password": "pass"}' + + users.each do |name, config| + if config.has_key?('secret_arn_parameter') + ComponentParam config['secret_arn_parameter'], + description: "#{name} RDS username and password Secrets Manager ARN. format of the secret must be json {\"username\": \"user\", \"password\": \"pass\"}" + end + end ComponentParam 'IdleClientTimeout', 120, type: 'Number', description: 'proxy idle connection timeout in seconds' diff --git a/rds-proxy.cfndsl.rb b/rds-proxy.cfndsl.rb index 82d4e93..df75804 100644 --- a/rds-proxy.cfndsl.rb +++ b/rds-proxy.cfndsl.rb @@ -35,14 +35,31 @@ GroupId Ref(:DBClusterSecurityGroup) } + auth_config = [] + external_parameters[:users].each do |name,config| + if config.has_key?('disabled') && config['disabled'] + next + end + + user = { + AuthScheme: 'SECRETS', + IAMAuth: config.fetch('iam_auth', 'REQUIRED'), + SecretArn: Ref(config['secret_arn_parameter']) + } + + if config.has_key?('description') + user[:Description] = config['description'] + end + + if config.has_key?('username') + user[:UserName ] = config['username'] + end + + auth_config << user + end + RDS_DBProxy(:RdsProxy) { - Auth([ - { - AuthScheme: 'SECRETS', - IAMAuth: external_parameters[:iam_auth], - SecretArn: Ref(:SecretCredentials) - } - ]) + Auth auth_config EngineFamily database_engine IdleClientTimeout Ref(:IdleClientTimeout) RequireTLS Ref(:RequireTLS) diff --git a/rds-proxy.config.yaml b/rds-proxy.config.yaml index b0a7352..bebee8d 100644 --- a/rds-proxy.config.yaml +++ b/rds-proxy.config.yaml @@ -1,5 +1,9 @@ database_engine: MYSQL # POSTGRESQL | MYSQL -iam_auth: REQUIRED # REQUIRED | DISABLED dns_format: ${EnvironmentName}.${DnsDomain} -hostname: postgres-proxy \ No newline at end of file +hostname: postgres-proxy + +users: + default: + secret_arn_parameter: SecretCredentials + iam_auth: REQUIRED # REQUIRED | DISABLED \ No newline at end of file diff --git a/spec/multiple_users_spec.rb b/spec/multiple_users_spec.rb new file mode 100644 index 0000000..fed6033 --- /dev/null +++ b/spec/multiple_users_spec.rb @@ -0,0 +1,184 @@ +require 'yaml' + +describe 'compiled component rds-proxy' do + + context 'cftest' do + it 'compiles test' do + expect(system("cfhighlander cftest #{@validate} --tests tests/multiple_users.test.yaml")).to be_truthy + end + end + + let(:template) { YAML.load_file("#{File.dirname(__FILE__)}/../out/tests/multiple_users/rds-proxy.compiled.yaml") } + + context "Resource" do + + + context "SecurityGroup" do + let(:resource) { template["Resources"]["SecurityGroup"] } + + it "is of type AWS::EC2::SecurityGroup" do + expect(resource["Type"]).to eq("AWS::EC2::SecurityGroup") + end + + it "to have property VpcId" do + expect(resource["Properties"]["VpcId"]).to eq({"Ref"=>"VPCId"}) + end + + it "to have property GroupDescription" do + expect(resource["Properties"]["GroupDescription"]).to eq({"Fn::Join"=>[" ", [{"Ref"=>"EnvironmentName"}, "rds-proxy", "security group"]]}) + end + + it "to have property Tags" do + expect(resource["Properties"]["Tags"]).to eq([{"Key"=>"Name", "Value"=>{"Fn::Sub"=>"${EnvironmentName}-rds-proxy"}}, {"Key"=>"Environment", "Value"=>{"Ref"=>"EnvironmentName"}}, {"Key"=>"EnvironmentType", "Value"=>{"Ref"=>"EnvironmentType"}}]) + end + + end + + context "ProxyPortAccessToDBCluster" do + let(:resource) { template["Resources"]["ProxyPortAccessToDBCluster"] } + + it "is of type AWS::EC2::SecurityGroupIngress" do + expect(resource["Type"]).to eq("AWS::EC2::SecurityGroupIngress") + end + + it "to have property IpProtocol" do + expect(resource["Properties"]["IpProtocol"]).to eq("tcp") + end + + it "to have property FromPort" do + expect(resource["Properties"]["FromPort"]).to eq({"Ref"=>"TargetDBClusterPort"}) + end + + it "to have property ToPort" do + expect(resource["Properties"]["ToPort"]).to eq({"Ref"=>"TargetDBClusterPort"}) + end + + it "to have property SourceSecurityGroupId" do + expect(resource["Properties"]["SourceSecurityGroupId"]).to eq({"Fn::GetAtt"=>["SecurityGroup", "GroupId"]}) + end + + it "to have property GroupId" do + expect(resource["Properties"]["GroupId"]).to eq({"Ref"=>"DBClusterSecurityGroup"}) + end + + end + + context "RdsProxy" do + let(:resource) { template["Resources"]["RdsProxy"] } + + it "is of type AWS::RDS::DBProxy" do + expect(resource["Type"]).to eq("AWS::RDS::DBProxy") + end + + it "to have property Auth" do + expect(resource["Properties"]["Auth"]).to eq([{"AuthScheme"=>"SECRETS", "IAMAuth"=>"REQUIRED", "SecretArn"=>{"Ref"=>"WriterSecretCredentials"}, "Description"=>"write access to the whole database"}, {"AuthScheme"=>"SECRETS", "IAMAuth"=>"REQUIRED", "SecretArn"=>{"Ref"=>"ReadonlySecretCredentials"}, "Description"=>"readonly access to the whole database"}, {"AuthScheme"=>"SECRETS", "IAMAuth"=>"REQUIRED", "SecretArn"=>{"Ref"=>"AuditSecretCredentials"}, "Description"=>"read and write access to the audit tables"}]) + end + + it "to have property EngineFamily" do + expect(resource["Properties"]["EngineFamily"]).to eq("MYSQL") + end + + it "to have property IdleClientTimeout" do + expect(resource["Properties"]["IdleClientTimeout"]).to eq({"Ref"=>"IdleClientTimeout"}) + end + + it "to have property RequireTLS" do + expect(resource["Properties"]["RequireTLS"]).to eq({"Ref"=>"RequireTLS"}) + end + + it "to have property DBProxyName" do + expect(resource["Properties"]["DBProxyName"]).to eq({"Fn::Sub"=>"${EnvironmentName}-${ProxyName}"}) + end + + it "to have property RoleArn" do + expect(resource["Properties"]["RoleArn"]).to eq({"Fn::GetAtt"=>["SecretsManagerRole", "Arn"]}) + end + + it "to have property VpcSecurityGroupIds" do + expect(resource["Properties"]["VpcSecurityGroupIds"]).to eq([{"Ref"=>"SecurityGroup"}]) + end + + it "to have property VpcSubnetIds" do + expect(resource["Properties"]["VpcSubnetIds"]).to eq({"Ref"=>"SubnetIds"}) + end + + it "to have property Tags" do + expect(resource["Properties"]["Tags"]).to eq([{"Key"=>"Name", "Value"=>{"Fn::Sub"=>"${EnvironmentName}-rds-proxy"}}, {"Key"=>"Environment", "Value"=>{"Ref"=>"EnvironmentName"}}, {"Key"=>"EnvironmentType", "Value"=>{"Ref"=>"EnvironmentType"}}]) + end + + end + + context "SecretsManagerRole" do + let(:resource) { template["Resources"]["SecretsManagerRole"] } + + it "is of type AWS::IAM::Role" do + expect(resource["Type"]).to eq("AWS::IAM::Role") + end + + it "to have property AssumeRolePolicyDocument" do + expect(resource["Properties"]["AssumeRolePolicyDocument"]).to eq({"Version"=>"2012-10-17", "Statement"=>[{"Effect"=>"Allow", "Principal"=>{"Service"=>"rds.amazonaws.com"}, "Action"=>"sts:AssumeRole"}]}) + end + + it "to have property Policies" do + expect(resource["Properties"]["Policies"]).to eq([{"PolicyName"=>"getsecret", "PolicyDocument"=>{"Statement"=>[{"Sid"=>"getsecret", "Action"=>["secretsmanager:GetSecretValue"], "Resource"=>[{"Ref"=>"SecretCredentials"}], "Effect"=>"Allow"}]}}]) + end + + end + + context "ProxyTargetGroup" do + let(:resource) { template["Resources"]["ProxyTargetGroup"] } + + it "is of type AWS::RDS::DBProxyTargetGroup" do + expect(resource["Type"]).to eq("AWS::RDS::DBProxyTargetGroup") + end + + it "to have property ConnectionPoolConfigurationInfo" do + expect(resource["Properties"]["ConnectionPoolConfigurationInfo"]).to eq({"MaxConnectionsPercent"=>{"Ref"=>"MaxConnectionsPercent"}, "MaxIdleConnectionsPercent"=>{"Ref"=>"MaxIdleConnectionsPercent"}, "ConnectionBorrowTimeout"=>{"Ref"=>"ConnectionBorrowTimeout"}}) + end + + it "to have property DBProxyName" do + expect(resource["Properties"]["DBProxyName"]).to eq({"Ref"=>"RdsProxy"}) + end + + it "to have property DBClusterIdentifiers" do + expect(resource["Properties"]["DBClusterIdentifiers"]).to eq([{"Ref"=>"TargetDBClusterIdentifier"}]) + end + + it "to have property TargetGroupName" do + expect(resource["Properties"]["TargetGroupName"]).to eq("default") + end + + end + + context "ProxyRecord" do + let(:resource) { template["Resources"]["ProxyRecord"] } + + it "is of type AWS::Route53::RecordSet" do + expect(resource["Type"]).to eq("AWS::Route53::RecordSet") + end + + it "to have property HostedZoneName" do + expect(resource["Properties"]["HostedZoneName"]).to eq({"Fn::Sub"=>"${EnvironmentName}.${DnsDomain}."}) + end + + it "to have property Name" do + expect(resource["Properties"]["Name"]).to eq({"Fn::Sub"=>"postgres-proxy.${EnvironmentName}.${DnsDomain}."}) + end + + it "to have property Type" do + expect(resource["Properties"]["Type"]).to eq("CNAME") + end + + it "to have property TTL" do + expect(resource["Properties"]["TTL"]).to eq("60") + end + + it "to have property ResourceRecords" do + expect(resource["Properties"]["ResourceRecords"]).to eq([{"Fn::GetAtt"=>["RdsProxy", "Endpoint"]}]) + end + + end + + end + +end \ No newline at end of file diff --git a/tests/disable_iam_auth.test.yaml b/tests/disable_iam_auth.test.yaml index 2f34856..28a7122 100644 --- a/tests/disable_iam_auth.test.yaml +++ b/tests/disable_iam_auth.test.yaml @@ -3,4 +3,7 @@ test_metadata: name: disable_iam_auth description: disable the proxy IAM authentication -iam_auth: DISABLED \ No newline at end of file +users: + default: + secret_arn_parameter: SecretCredentials + iam_auth: DISABLED \ No newline at end of file diff --git a/tests/multiple_users.test.yaml b/tests/multiple_users.test.yaml new file mode 100644 index 0000000..d9d1579 --- /dev/null +++ b/tests/multiple_users.test.yaml @@ -0,0 +1,20 @@ +test_metadata: + type: config + name: multiple_users + description: set the description for your test + +users: + default: + disabled: true + writer: + secret_arn_parameter: WriterSecretCredentials + iam_auth: REQUIRED + description: write access to the whole database + readonly: + secret_arn_parameter: ReadonlySecretCredentials + iam_auth: REQUIRED + description: readonly access to the whole database + audit: + secret_arn_parameter: AuditSecretCredentials + iam_auth: REQUIRED + description: read and write access to the audit tables