Skip to content

Commit

Permalink
Generate a connection string from existing RDS Secret (#712)
Browse files Browse the repository at this point in the history
Creates a new secret which is formed of the secrets in the original RDS Secret.
Exposes the value as an environment variable.
  • Loading branch information
JohnPreston committed Nov 15, 2023
1 parent 0d91541 commit df66b95
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 19 deletions.
22 changes: 22 additions & 0 deletions docs/syntax/compose_x/rds.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Access
Access:
DBCluster: RO
GenerateConnectionStringSecret: <string>
The only valid key for Access is DBCluster. The only valid value is ``RO`` for read-only, which allows IAM calls to RDS
to describe the cluster.
Expand Down Expand Up @@ -77,6 +78,27 @@ with the ARN of the secret
Access: RW
GrantTaskAccess: True # Grants access to the secret, not setting an env var
.. _rds_generate_connection_string:

GenerateConnectionStringSecret
---------------------------------

This option enables to create a new secret that will be generated from the RDS Secret.
The feature works for both newly created DBs and existing DBs (using Lookup).

.. warning::

Once the secret has been created, if the root secret of the DB has changed, the value in the generated secret will
not be updated! Use at your own risks

.. hint::

Avoid to require the DB Connection string and have a separate environment variable for each part of the
connection.

Example: ``postgres://username:password@cluster-hostname:port/dbname``


.. _rds_db_secrets_mappings:

SecretsMapping
Expand Down
13 changes: 13 additions & 0 deletions ecs_composex/common/troposphere_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ def add_resource(template, resource, replace=False) -> AWSObject:
return resource


def set_get_resource(template, resource) -> (AWSObject, bool):
"""
Function to add resource to template if the resource does not already exist
Returns the resource if it already does.
"""
if (
resource not in template.resources.values()
and resource.title not in template.resources.keys()
):
return template.add_resource(resource), False
return template.resources[resource.title], True


def add_defaults(template):
"""Function to CFN parameters and conditions to the template which are used
across ECS ComposeX
Expand Down
2 changes: 2 additions & 0 deletions ecs_composex/rds/rds_db_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ def create_from_properties(db_template: Template, db: Rds) -> None:
rds_class = determine_resource_type(db.name, db.properties)
if rds_class:
rds_props = import_record_properties(db.properties, rds_class)
if not keyisset("DatabaseName", rds_props):
rds_props["DatabaseName"] = Ref(DB_NAME)
override_set_properties(rds_props, db)
db.cfn_resource = rds_class(db.logical_name, **rds_props)
add_resource(db_template, db.cfn_resource)
Expand Down
4 changes: 4 additions & 0 deletions ecs_composex/rds/x-rds.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"SecretsMappings": {
"$ref": "#/definitions/SecretsMappingsDef"
},
"GenerateConnectionStringSecret": {
"type": "string",
"description": "If set, creates an additional secret that will represent the connection string to the DB, and sets the environment variable"
},
"ReturnValues": {
"type": "object",
"description": "Set the CFN Return Value and the environment variable name you want to expose to the service",
Expand Down
82 changes: 70 additions & 12 deletions ecs_composex/rds_resources_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@

from ecs_composex.common.aws import find_aws_resource_arn_from_tags_api
from ecs_composex.common.logging import LOG
from ecs_composex.common.troposphere_tools import add_resource, add_update_mapping
from ecs_composex.common.troposphere_tools import (
add_resource,
add_update_mapping,
set_get_resource,
)
from ecs_composex.compose.compose_services.helpers import (
extend_container_envvars,
extend_container_secrets,
Expand Down Expand Up @@ -222,6 +226,61 @@ def define_secrets_keys_mappings(mappings_definition):
return rendered_mappings


def generate_secret_string(
secret_var_name: str, secret_import, db: DatabaseXResource, family: ComposeFamily
) -> list:
"""
Generates an additional secret that will put together the connection string that some services require in order
to connect to the DB. Generally, not recommended.
"""
from troposphere.secretsmanager import Secret

param_name = secret_import.data["Ref"]
secret, already_set = set_get_resource(
family.template,
Secret(
f"{db.logical_name}ConnectionStringSecret",
Description=Sub(f"Connection string secret for {db.logical_name}"),
SecretString=Sub(
"${ENGINE}://${USERNAME}:${PASSWORD}@${HOST}:${PORT}/${DBNAME}",
ENGINE=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:engine}}",
),
USERNAME=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:username}}",
),
PASSWORD=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:password}}",
),
HOST=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:host}}",
),
PORT=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:port}}",
),
DBNAME=Sub(
"{{resolve:secretsmanager:"
+ f"${{{param_name}}}"
+ ":SecretString:dbname}}",
),
),
),
)
if not already_set:
add_secrets_access_policy(family, Ref(secret), db, False)
return [EcsSecret(Name=secret_var_name, ValueFrom=Ref(secret))]


def generate_secrets_from_secrets_mappings(
db, secrets_list, secret_definition, mappings_definition
):
Expand Down Expand Up @@ -292,6 +351,10 @@ def define_db_secrets(db: DatabaseXResource, secret_import, target: tuple) -> li
" - No SecretsMappings set. Exposing the secrets content as-is."
)
secrets.append(EcsSecret(Name=db.name, ValueFrom=secret_import))
if keyisset("GenerateConnectionStringSecret", target[-1]):
secrets += generate_secret_string(
target[-1]["GenerateConnectionStringSecret"], secret_import, db, target[0]
)
return secrets


Expand Down Expand Up @@ -341,27 +404,22 @@ def generate_rds_secrets_permissions(resources, db_name: str) -> dict:
:return:
"""
return {
"Sid": f"AccessTo{db_name}Secret",
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "secretsmanager:GetSecret"],
"Resource": resources if isinstance(resources, list) else [resources],
}


def add_secrets_access_policy(
target: tuple,
service_family: ComposeFamily,
secret_import,
db,
db: DatabaseXResource,
use_task_role: Union[bool, dict] = False,
):
) -> None:
"""
Function to add or append policy to access DB Secret for the Execution Role
:param tuple target:
:param secret_import:
:return:
If the use_task_role true, also allows the task role access to the secret.
"""
service_family = target[0]
db_policy_statement = generate_rds_secrets_permissions(
secret_import, db.logical_name
)
Expand Down Expand Up @@ -455,7 +513,7 @@ def handle_db_secret_to_services(
grant_task_role_access = set_else_none(
"GrantTaskAccess", target[-1], alt_value=False
)
add_secrets_access_policy(target, secret_import, db, grant_task_role_access)
add_secrets_access_policy(target[0], secret_import, db, grant_task_role_access)


def handle_import_dbs_to_services(
Expand All @@ -481,7 +539,7 @@ def handle_import_dbs_to_services(
"GrantTaskAccess", target[-1], alt_value=False
)
add_secrets_access_policy(
target,
target[0],
db.attributes_outputs[db.db_secret_arn_parameter]["ImportValue"],
db,
use_task_role=grant_task_role_access,
Expand Down
16 changes: 9 additions & 7 deletions ecs_composex/secrets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
Package to handle recurring Secrets tasks
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from troposphere import Template

from troposphere import Parameter, Ref, Sub
from troposphere.docdb import DBCluster as DocdbCluster
from troposphere.docdb import DBInstance as DocdbInstance
Expand All @@ -19,13 +26,8 @@
from ecs_composex.common.troposphere_tools import add_parameters


def add_db_secret(template, resource_title):
"""
Function to add a Secrets Manager secret that will be associated with the DB
:param template.Template template: The template to add the secret to.
:param str resource_title: The Logical name of the resource associated to that secret
"""
def add_db_secret(template: Template, resource_title: str) -> Secret:
"""Function to add a Secrets Manager secret that will be associated with the DB"""
username = Parameter(
f"{resource_title}Username",
Type="String",
Expand Down
2 changes: 2 additions & 0 deletions use-cases/rds/rds_basic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ x-rds:
Services:
app01:
Access: RW
GenerateConnectionStringSecret: APPO1_DB_B_CONNECTION_STRING
app03:
Access: RW
GrantTaskAccess:
SecretEnvName: DB_B_SECRET
GenerateConnectionStringSecret: APP03_DB_B_CONN
youtoo:
Access: RW
GrantTaskAccess: True
2 changes: 2 additions & 0 deletions use-cases/rds/rds_import.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ x-rds:
Access:
DBCluster: RO
GrantTaskAccess: true
GenerateConnectionStringSecret: DB_CONN_STRING
Lookup:
cluster:
Name: database-1
Expand All @@ -46,6 +47,7 @@ x-rds:
DBCluster: RO
GrantTaskAccess:
SecretEnvName: DB_C_SECRET
GenerateConnectionStringSecret: DB_CONN_STRING
Lookup:
cluster:
Name: database-1
Expand Down

0 comments on commit df66b95

Please sign in to comment.