infra(seed): dump encrypted secret columns as NULL, restore via CLI

ASP.NET Data Protection ciphertext is non-deterministic and bound to the
source key ring, so encrypted secret columns (ExternalSystemDefinitions
.AuthConfiguration, SmtpConfigurations.Credentials, DatabaseConnection
Definitions.ConnectionString) cannot be replayed from a static SQL dump —
the app would fail to decrypt them. dump_seed.py now emits those columns
as NULL; reseed.sh adds a post-seed stage that recreates the values
through the ScadaLink CLI so the EF value converter re-encrypts against
the target cluster's key ring.
This commit is contained in:
Joseph Doherty
2026-05-21 01:29:51 -04:00
parent 5492c94e2f
commit 932fda5594
3 changed files with 62 additions and 2 deletions

View File

@@ -13,6 +13,12 @@ Excluded by design (per-environment, not design-time): Sites (seeded via
seed-sites.sh), Instances + InstanceConnectionBindings + InstanceOverrides,
NotificationLists/Recipients, SmtpConfigurations, ApiKeys, Areas,
SiteScopeRules, LdapGroupMappings, DataProtectionKeys, audit, deployment.
Encrypted secret columns (see ENCRYPTED_COLUMNS) are emitted as NULL: they
hold ASP.NET Data Protection ciphertext, which is non-deterministic and bound
to the source key ring, so a raw SQL dump can never replay a valid value.
Re-populate them through the application after the seed runs (infra/reseed.sh
does this via the ScadaLink CLI).
"""
import argparse
@@ -45,6 +51,18 @@ INSERT_ORDER = [
# the column list. All listed tables happen to use Id as their identity.
IDENTITY_TABLES = set(INSERT_ORDER)
# (table, column) pairs encrypted at rest via ASP.NET Data Protection
# (EncryptedStringConverter in ScadaLink.ConfigurationDatabase). Ciphertext is
# non-deterministic and key-ring-bound, so it cannot be replayed from a static
# SQL dump — the application would fail to decrypt it on read. These columns
# are dumped as NULL; re-seed their values through the app (CLI / API) so the
# value converter encrypts them against the target key ring.
ENCRYPTED_COLUMNS = {
("ExternalSystemDefinitions", "AuthConfiguration"),
("SmtpConfigurations", "Credentials"),
("DatabaseConnectionDefinitions", "ConnectionString"),
}
# Templates has self-FK Templates.ParentTemplateId; emit a single batch that
# inserts shallow rows first then deeper ones. pymssql returns rows in Id order
# from our ORDER BY, which matches insertion order for this schema (parent Id
@@ -175,6 +193,16 @@ def dump(args):
rows = cursor.fetchall()
out.append("-- " + table + " (" + str(len(rows)) + " rows)")
# Columns encrypted at rest cannot be dumped verbatim; emit NULL and
# note it so the secret value is restored through the app afterwards.
nulled = [c for c in columns if (table, c) in ENCRYPTED_COLUMNS]
for c in nulled:
out.append(
"-- NOTE: [{}] is an encrypted secret column — dumped as NULL. "
"Restore via the app (CLI/API) post-seed.".format(c)
)
if not rows:
continue
@@ -183,7 +211,10 @@ def dump(args):
if identity:
out.append("SET IDENTITY_INSERT [{}] ON;".format(table))
for row in rows:
values = ", ".join(quote(v) for v in row)
values = ", ".join(
"NULL" if (table, c) in ENCRYPTED_COLUMNS else quote(v)
for c, v in zip(columns, row)
)
out.append(
"INSERT INTO [{}] ({}) VALUES ({});".format(table, col_list, values)
)