Skip to content

Commit df90082

Browse files
authored
DC-250: Provision rotation app-user across all target databases (#495)
1 parent e8cf415 commit df90082

6 files changed

Lines changed: 147 additions & 49 deletions

File tree

tofu/modules/sebt_application/locals.tf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ locals {
2121
DD_API_KEY = data.aws_secretsmanager_secret.datadog_key["this"].arn
2222
} : {}
2323

24+
# tofu-modules-aws-fargate-service (module.api) doesn't expose a
25+
# service_name output — only cluster_name. Its cluster and service happen
26+
# to share the same name today (HENNGE/ecs/aws: join("-", compact([project,
27+
# environment, service]))), but that's an implementation detail of the
28+
# upstream module, not a contract. Reconstruct it explicitly here so a
29+
# future naming-scheme change in that module doesn't silently break the
30+
# rotation Lambda's ecs:UpdateService call. If the module ever adds a
31+
# service_name output, switch to that instead.
32+
api_ecs_service_name = join("-", compact(["${var.project}-${var.state}", var.environment, "api"]))
33+
2434
# Allow the AWS Security Agent penetration test to bypass the WAF in the
2535
# development environment by matching its unique User-Agent. "allow" is a
2636
# terminating action, so matching requests skip all subsequent rules

tofu/modules/sebt_application/main.tf

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,10 @@ module "database" {
192192
ingress_security_groups = [module.api.security_group_id]
193193
ingress_cidrs = var.db_ingress_cidrs
194194

195-
db_name = "SebtPortal"
196-
ecs_cluster_name = module.api.cluster_name
197-
ecs_service_name = module.api.cluster_name
195+
db_name = "SebtPortal"
196+
additional_db_names = var.dc_source_db_name != "" ? [var.dc_source_db_name] : []
197+
ecs_cluster_name = module.api.cluster_name
198+
ecs_service_name = local.api_ecs_service_name
198199

199200
skip_final_snapshot = var.skip_final_snapshot
200201
apply_immediately = var.apply_immediately

tofu/modules/sebt_database/lambda/rotate_db_credentials.py

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
credential staleness window entirely.
88
99
Environment variables (set by OpenTofu):
10-
ADMIN_SECRET_ARN — ARN of the Secrets Manager secret holding RDS admin
11-
credentials (the RDS-managed master-user secret).
12-
DB_HOST — RDS SQL Server hostname.
13-
DB_PORT — SQL Server port (default: "1433").
14-
DB_NAME — Target database name for user creation and connection tests.
15-
ECS_CLUSTER — ECS cluster name to redeploy after finishSecret.
16-
ECS_SERVICE — ECS service name to redeploy after finishSecret.
10+
ADMIN_SECRET_ARN — ARN of the Secrets Manager secret holding RDS admin
11+
credentials (the RDS-managed master-user secret).
12+
DB_HOST — RDS SQL Server hostname.
13+
DB_PORT — SQL Server port (default: "1433").
14+
DB_NAME — Target database name for user creation and connection tests.
15+
ADDITIONAL_DB_NAMES — Comma-separated list of extra databases on the same RDS
16+
instance where the app user also needs a database-level
17+
user provisioned (e.g. DC's DcSource database). Empty/unset
18+
means no extras — the typical case for CO.
19+
ECS_CLUSTER — ECS cluster name to redeploy after finishSecret.
20+
ECS_SERVICE — ECS service name to redeploy after finishSecret.
1721
"""
1822

1923
import json
@@ -105,7 +109,6 @@ def set_secret(client, secret_arn, token):
105109

106110
host = os.environ["DB_HOST"]
107111
port = int(os.environ.get("DB_PORT", "1433"))
108-
dbname = pending["dbname"]
109112

110113
# Server-level operations (CREATE/ALTER LOGIN) require master db context.
111114
master_conn = pymssql.connect(
@@ -126,40 +129,44 @@ def set_secret(client, secret_arn, token):
126129
finally:
127130
master_conn.close()
128131

129-
# Database-level operations (CREATE USER, role grant) require the target db.
130-
db_conn = pymssql.connect(
131-
server=host,
132-
port=port,
133-
user=admin["username"],
134-
password=admin["password"],
135-
database=dbname,
136-
tds_version="7.4",
137-
)
138-
try:
139-
cursor = db_conn.cursor()
140-
_ensure_db_user_exists(cursor, pending["username"])
141-
db_conn.commit()
142-
finally:
143-
db_conn.close()
132+
# Database-level operations (CREATE USER, role grant) run against every
133+
# database the app user needs access to, not just the primary one.
134+
for dbname in _target_db_names(pending["dbname"]):
135+
db_conn = pymssql.connect(
136+
server=host,
137+
port=port,
138+
user=admin["username"],
139+
password=admin["password"],
140+
database=dbname,
141+
tds_version="7.4",
142+
)
143+
try:
144+
cursor = db_conn.cursor()
145+
_ensure_db_user_exists(cursor, pending["username"])
146+
db_conn.commit()
147+
logger.info("Ensured db user exists in %s", dbname)
148+
finally:
149+
db_conn.close()
144150

145151

146152
def test_secret(client, secret_arn, token):
147-
"""Verify the AWSPENDING credentials by opening a test connection."""
153+
"""Verify the AWSPENDING credentials by opening a test connection to every target database."""
148154
pending = _get_secret_dict(client, secret_arn, stage="AWSPENDING", version_id=token)
149155

150-
conn = pymssql.connect(
151-
server=os.environ["DB_HOST"],
152-
port=int(os.environ.get("DB_PORT", "1433")),
153-
user=pending["username"],
154-
password=pending["password"],
155-
database=pending["dbname"],
156-
tds_version="7.4",
157-
)
158-
try:
159-
conn.cursor().execute("SELECT 1")
160-
logger.info("Test connection succeeded for pending login")
161-
finally:
162-
conn.close()
156+
for dbname in _target_db_names(pending["dbname"]):
157+
conn = pymssql.connect(
158+
server=os.environ["DB_HOST"],
159+
port=int(os.environ.get("DB_PORT", "1433")),
160+
user=pending["username"],
161+
password=pending["password"],
162+
database=dbname,
163+
tds_version="7.4",
164+
)
165+
try:
166+
conn.cursor().execute("SELECT 1")
167+
logger.info("Test connection succeeded for pending login against %s", dbname)
168+
finally:
169+
conn.close()
163170

164171

165172
def finish_secret(client, secret_arn, token):
@@ -189,6 +196,19 @@ def finish_secret(client, secret_arn, token):
189196
# Private helpers
190197
# ---------------------------------------------------------------------------
191198

199+
def _target_db_names(primary_dbname):
200+
"""Return every database the app user login should be provisioned in.
201+
202+
Combines the primary database (from the secret) with any additional
203+
state-specific databases configured via ADDITIONAL_DB_NAMES (e.g. DC's
204+
DcSource database, which lives on the same RDS instance but doesn't
205+
exist for other states like CO).
206+
"""
207+
additional = os.environ.get("ADDITIONAL_DB_NAMES", "")
208+
extras = [name.strip() for name in additional.split(",") if name.strip()]
209+
return [primary_dbname] + extras
210+
211+
192212
def _other_user(username):
193213
"""Return the inactive app user (the one not currently active)."""
194214
if username not in _USERS:
@@ -278,10 +298,14 @@ def _restart_ecs_service():
278298
logger.info("Triggered rolling ECS restart for %s/%s", cluster, service)
279299
except Exception:
280300
# The secret is already rotated; running tasks remain on the active
281-
# user's valid credentials. A manual redeploy will pick up the new
282-
# secret at next launch.
301+
# user's valid credentials until the next natural redeploy. Not
302+
# fatal to rotation, but silent — log a distinct, alertable marker
303+
# so an external monitor (e.g. a Datadog log monitor) can page on
304+
# it instead of this failing invisibly.
283305
logger.exception(
284-
"Failed to trigger ECS restart for %s/%s — manual redeploy required",
306+
"DB_ROTATION_ECS_RESTART_FAILED cluster=%s service=%s — "
307+
"credentials rotated successfully but the ECS restart failed; "
308+
"manual redeploy required to pick up the new secret",
285309
cluster,
286310
service,
287311
)

tofu/modules/sebt_database/lambda/test_rotate_db_credentials.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ def _make_client(token, current_user="appuser"):
5454
# _other_user
5555
# ---------------------------------------------------------------------------
5656

57+
def test_target_db_names_returns_primary_only_when_no_additional():
58+
assert rot._target_db_names("SebtPortal") == ["SebtPortal"]
59+
60+
61+
@patch.dict(os.environ, {"ADDITIONAL_DB_NAMES": "DcSource"})
62+
def test_target_db_names_includes_additional_dbs():
63+
assert rot._target_db_names("SebtPortal") == ["SebtPortal", "DcSource"]
64+
65+
66+
@patch.dict(os.environ, {"ADDITIONAL_DB_NAMES": " DcSource , , OtherDb "})
67+
def test_target_db_names_strips_whitespace_and_drops_empties():
68+
assert rot._target_db_names("SebtPortal") == ["SebtPortal", "DcSource", "OtherDb"]
69+
70+
5771
def test_other_user_returns_clone_when_given_primary():
5872
assert rot._other_user("appuser") == "appuser_clone"
5973

@@ -147,6 +161,27 @@ def test_set_secret_opens_connections_and_commits(mock_pymssql):
147161
mock_conn.close.assert_called()
148162

149163

164+
@patch.dict(os.environ, {"ADMIN_SECRET_ARN": "admin-arn", "DB_HOST": "db", "DB_PORT": "1433",
165+
"DB_NAME": "SebtPortal", "ADDITIONAL_DB_NAMES": "DcSource",
166+
"ECS_CLUSTER": "cluster", "ECS_SERVICE": "svc"})
167+
@patch("rotate_db_credentials.pymssql")
168+
def test_set_secret_provisions_db_user_in_additional_databases(mock_pymssql):
169+
mock_conn = MagicMock()
170+
mock_pymssql.connect.return_value = mock_conn
171+
172+
client = MagicMock()
173+
client.get_secret_value.side_effect = [
174+
{"SecretString": json.dumps({"username": "appuser_clone", "password": "NewPw1!",
175+
"host": "db", "port": "1433", "dbname": "SebtPortal"})},
176+
{"SecretString": json.dumps({"username": "admin", "password": "AdminPw1!"})},
177+
]
178+
179+
rot.set_secret(client, "secret-arn", "new-token")
180+
181+
called_dbs = [kwargs["database"] for _, kwargs in mock_pymssql.connect.call_args_list]
182+
assert called_dbs == ["master", "SebtPortal", "DcSource"]
183+
184+
150185
# ---------------------------------------------------------------------------
151186
# test_secret
152187
# ---------------------------------------------------------------------------
@@ -170,6 +205,27 @@ def test_test_secret_succeeds_on_valid_connection(mock_pymssql):
170205
mock_conn.close.assert_called_once()
171206

172207

208+
@patch.dict(os.environ, {"ADMIN_SECRET_ARN": "admin-arn", "DB_HOST": "db", "DB_PORT": "1433",
209+
"DB_NAME": "SebtPortal", "ADDITIONAL_DB_NAMES": "DcSource",
210+
"ECS_CLUSTER": "cluster", "ECS_SERVICE": "svc"})
211+
@patch("rotate_db_credentials.pymssql")
212+
def test_test_secret_tests_connection_to_every_target_database(mock_pymssql):
213+
mock_conn = MagicMock()
214+
mock_pymssql.connect.return_value = mock_conn
215+
216+
client = MagicMock()
217+
client.get_secret_value.return_value = {
218+
"SecretString": json.dumps({"username": "appuser_clone", "password": "NewPw1!",
219+
"host": "db", "port": "1433", "dbname": "SebtPortal"})
220+
}
221+
222+
rot.test_secret(client, "secret-arn", "new-token")
223+
224+
called_dbs = [kwargs["database"] for _, kwargs in mock_pymssql.connect.call_args_list]
225+
assert called_dbs == ["SebtPortal", "DcSource"]
226+
assert mock_conn.cursor.return_value.execute.call_count == 2
227+
228+
173229
@patch.dict(os.environ, {"ADMIN_SECRET_ARN": "admin-arn", "DB_HOST": "db", "DB_PORT": "1433",
174230
"DB_NAME": "SebtPortal", "ECS_CLUSTER": "cluster", "ECS_SERVICE": "svc"})
175231
@patch("rotate_db_credentials.pymssql")

tofu/modules/sebt_database/rotation.tf

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,13 @@ resource "aws_lambda_function" "rotation" {
226226

227227
environment {
228228
variables = {
229-
ADMIN_SECRET_ARN = aws_db_instance.main.master_user_secret[0].secret_arn
230-
DB_HOST = aws_db_instance.main.address
231-
DB_PORT = tostring(local.port)
232-
DB_NAME = var.db_name
233-
ECS_CLUSTER = var.ecs_cluster_name
234-
ECS_SERVICE = var.ecs_service_name
229+
ADMIN_SECRET_ARN = aws_db_instance.main.master_user_secret[0].secret_arn
230+
DB_HOST = aws_db_instance.main.address
231+
DB_PORT = tostring(local.port)
232+
DB_NAME = var.db_name
233+
ADDITIONAL_DB_NAMES = join(",", var.additional_db_names)
234+
ECS_CLUSTER = var.ecs_cluster_name
235+
ECS_SERVICE = var.ecs_service_name
235236
}
236237
}
237238

tofu/modules/sebt_database/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
variable "additional_db_names" {
2+
type = list(string)
3+
description = "Additional databases on this RDS instance (beyond db_name) where the app-user login also needs a database-level user provisioned, e.g. DC's DcSource database. Empty means only db_name is provisioned — the typical case for CO, which has no equivalent database."
4+
default = []
5+
}
6+
17
variable "allocated_storage" {
28
type = number
39
description = "Allocated storage in GB."

0 commit comments

Comments
 (0)