From 932fda55943634fc07d21504b17582d3017f7b02 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 01:29:51 -0400 Subject: [PATCH] infra(seed): dump encrypted secret columns as NULL, restore via CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- infra/mssql/seed-config.sql | 3 ++- infra/reseed.sh | 28 ++++++++++++++++++++++++++++ infra/tools/dump_seed.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/infra/mssql/seed-config.sql b/infra/mssql/seed-config.sql index 7632c7d..aa00344 100644 --- a/infra/mssql/seed-config.sql +++ b/infra/mssql/seed-config.sql @@ -183,8 +183,9 @@ INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], SET IDENTITY_INSERT [DataConnections] OFF; -- ExternalSystemDefinitions (1 rows) +-- NOTE: [AuthConfiguration] is an encrypted secret column — dumped as NULL. Restore via the app (CLI/API) post-seed. SET IDENTITY_INSERT [ExternalSystemDefinitions] ON; -INSERT INTO [ExternalSystemDefinitions] ([Id], [Name], [EndpointUrl], [AuthType], [AuthConfiguration], [MaxRetries], [RetryDelay]) VALUES (1, N'Test REST API', N'http://scadalink-restapi:5200', N'ApiKey', N'scadalink-test-key-1', 0, '00:00:00.000000'); +INSERT INTO [ExternalSystemDefinitions] ([Id], [Name], [EndpointUrl], [AuthType], [AuthConfiguration], [MaxRetries], [RetryDelay]) VALUES (1, N'Test REST API', N'http://scadalink-restapi:5200', N'ApiKey', NULL, 0, '00:00:00.000000'); SET IDENTITY_INSERT [ExternalSystemDefinitions] OFF; -- ExternalSystemMethods (1 rows) diff --git a/infra/reseed.sh b/infra/reseed.sh index 34fb0c1..d5a0fc0 100755 --- a/infra/reseed.sh +++ b/infra/reseed.sh @@ -114,6 +114,34 @@ docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ -S localhost -U sa -P 'ScadaLink_Dev1#' -C -d ScadaLinkConfig -b < "$SEED_FILE" echo " Seed replayed." +echo "" +echo "--- Stage 6d/6: restore encrypted secret config (CLI) ---" +# Configuration that lives in encrypted secret columns cannot be replayed from +# raw SQL: ASP.NET Data Protection ciphertext is non-deterministic and bound to +# the source key ring. Create/restore it through the app so the EF value +# converter encrypts against this cluster's key ring. +CLI="dotnet run --project $PROJECT_ROOT/src/ScadaLink.CLI --" +AUTH="--username multi-role --password password" + +# ExternalSystemDefinitions Id 1 ("Test REST API") is inserted by the seed with +# a fixed identity but a NULL AuthConfiguration; set the API key here. +$CLI --url "$MGMT_URL" $AUTH external-system update \ + --id 1 \ + --name "Test REST API" \ + --endpoint-url "http://scadalink-restapi:5200" \ + --auth-type ApiKey \ + --auth-config "scadalink-test-key-1" +echo " External-system auth config restored (encrypted)." + +# The "Machine Data DB" database connection is referenced by name from the +# seeded TestDatabaseQuery script. It is not in seed-config.sql (its +# ConnectionString is an encrypted secret column); create it through the app. +$CLI --url "$MGMT_URL" $AUTH db-connection create \ + --name "Machine Data DB" \ + --connection-string "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true" \ +|| echo " (Machine Data DB connection may already exist)" +echo " Database connection created (encrypted)." + echo "" echo "=== Reseed complete ===" echo "" diff --git a/infra/tools/dump_seed.py b/infra/tools/dump_seed.py index 5fd75bb..29cb4c9 100755 --- a/infra/tools/dump_seed.py +++ b/infra/tools/dump_seed.py @@ -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) )