Skip to content

fix(rds): set StorageEncrypted=true on mysql + postgres instances#239

Merged
Cre-eD merged 4 commits intomainfrom
fix/rds-storage-encrypted
May 8, 2026
Merged

fix(rds): set StorageEncrypted=true on mysql + postgres instances#239
Cre-eD merged 4 commits intomainfrom
fix/rds-storage-encrypted

Conversation

@Cre-eD
Copy link
Copy Markdown
Contributor

@Cre-eD Cre-eD commented May 8, 2026

Summary

Adds opt-in encryption-at-rest for AWS RDS instances managed by SC. Existing customers get zero behaviour change unless they explicitly set the new field.

File Change
pkg/clouds/aws/rds_mysql.go MysqlConfig.StorageEncrypted *bool (new optional field)
pkg/clouds/aws/rds_postgres.go PostgresConfig.StorageEncrypted *bool (same)
pkg/clouds/pulumi/aws/rds_mysql.go StorageEncrypted: sdk.Bool(lo.FromPtr(dbConfig.StorageEncrypted)) + sdk.IgnoreChanges([]string{"storageEncrypted"})
pkg/clouds/pulumi/aws/rds_postgres.go same
pkg/clouds/aws/rds_storage_encrypted_test.go new — unit tests for the opt-in semantics

Opt-in behaviour (per Ilya's review feedback)

Customer config dbConfig.StorageEncrypted Pulumi StorageEncrypted Effect
Field omitted (legacy stacks) nil false UNENCRYPTED — preserves pre-2026.5 behaviour
storageEncrypted: true &true true Encrypted (AWS-managed aws/rds KMS key)
storageEncrypted: false &false false Explicit unencrypted (respect the call)

This means no customer gets encryption silently — they have to opt in.

Why IgnoreChanges is still required

storage_encrypted is immutable post-creation in AWS RDS. Without IgnoreChanges, the moment a customer flips the new field to true on a stack with a pre-existing unencrypted instance, Pulumi would propose a destructive replacement (drop + recreate, data loss + downtime).

sdk.IgnoreChanges([]string{"storageEncrypted"}) silences that drift. Behaviour:

  • ✅ NEW instance + storageEncrypted: true → created encrypted from the start.
  • ✅ EXISTING unencrypted instance + customer flips to true → no diff, no replacement; the customer's data is safe but the existing instance stays unencrypted. They have to migrate out-of-band (see below).
  • ✅ EXISTING unencrypted instance + field stays nil → no diff. Pre-2026.5 behaviour preserved exactly.

Migration path for existing unencrypted instances

sc deploy will not migrate automatically — the AWS encryption switch is destructive. The standard AWS path:

  1. Take a snapshot of the unencrypted instance.
  2. CopyDBSnapshot with encryption enabled (KmsKeyId set).
  3. Restore from the encrypted snapshot to a new instance.
  4. Cut traffic over (DNS / app config), then delete the old instance.
  5. Re-import the new instance into the SC Pulumi stack.

Documented inline next to the IgnoreChanges line so a future maintainer doesn't accidentally remove the safety.

Why surfaced now

simple-container-com/actions PR #7 adds a new semgrep rule go-aws-rds-no-storage-encryption (ERROR severity). Walking the api codebase to write that rule revealed two pre-existing instances with unset encryption defaults. Originally I went with hard-coded StorageEncrypted: true; per review feedback this is now opt-in.

The semgrep rule on actions repo treats StorageEncrypted: pulumi.Bool(true) literal as "OK", and StorageEncrypted: pulumi.Bool(false) literal as "still ERROR". Our generated value uses lo.FromPtr which the rule sees textually as pulumi.Bool(...) — but it's sdk.Bool here. Verified: rule's pattern-not-regex: 'StorageEncrypted:\s*\w+\.Bool\(true\)' does NOT match sdk.Bool(lo.FromPtr(...)) because the function inside Bool(...) isn't true. So this code WILL still trigger the rule when scanned with PR #7's ruleset — by design. The rule is conservative; it can't statically prove lo.FromPtr resolves to true. Acceptable trade-off: code is conservatively flagged, reviewer eyeballs the call site, confirms the opt-in plumbing is correct, dismisses or fixes.

KMS

StorageEncrypted: sdk.Bool(true) without KmsKeyId uses AWS-managed aws/rds. Customer-managed key per stack is a follow-up — the immediate-controls bar is encrypt-at-all when the customer opts in.

Test plan

Unit (in this PR — green)

  • go test ./pkg/clouds/aws/...TestReadRdsMysqlConfig_StorageEncrypted and TestReadRdsPostgresConfig_StorageEncrypted, 6 subtests total covering all three states for both engines
  • Full go test ./... — clean

Integration (manual, after merge)

  • Brand-new stack, no opt-in: pulumi preview shows + Instance with storage_encrypted: false (legacy behaviour preserved).
  • Brand-new stack with opt-in: same preview shows storage_encrypted: true, KmsKeyId resolves to the AWS-managed default.
  • Existing customer post-upgrade, no config change: pulumi preview shows no diff on the RDS instance (this is the critical migration-safety test — proves IgnoreChanges + nil default both kick in).
  • Existing customer flipping the field to true: pulumi preview shows no diff (silenced by IgnoreChanges — explicit demonstration of the safety net).

E2E (manual, sandbox AWS account)

  • Variant A (no opt-in): create RDS, aws rds describe-db-instances --query '[*].StorageEncrypted'false.
  • Variant B (opt-in true): same query → true.
  • Variant C (legacy → upgrade simulation): create with old code unencrypted, then sc deploy with new code (no config change) → describe → StorageEncrypted still false, instance ID unchanged.
  • Tear down.

Out of scope

  • Customer-managed KMS key (KmsKeyId) — follow-up.
  • Schema-version-style bundling of multiple hardening defaults — only one knob today; mongodb-atlas precedent (NamingStrategyVersion) exists if the count grows.
  • Migrating already-deployed unencrypted instances on customer fleets — manual, out-of-band, documented above.

AWS RDS instances default to UNENCRYPTED storage at rest unless
`StorageEncrypted: pulumi.Bool(true)` is explicitly set. Both
`pkg/clouds/pulumi/aws/rds_mysql.go:108` and
`pkg/clouds/pulumi/aws/rds_postgres.go:101` were missing the field,
which is a CIS AWS Foundations Benchmark RDS.3 violation.

Setting it now is also a one-shot decision: encryption cannot be
toggled on an existing RDS instance without snapshot-restore.

`StorageEncrypted: sdk.Bool(true)` uses the AWS-managed RDS KMS key
(`aws/rds`) by default. A future change can supply a customer-managed
`KmsKeyId` if SC adopts CMK-per-stack.

Surfaced by simple-container-com/actions PR #7 (new
`go-aws-rds-no-storage-encryption` semgrep rule) — analysing this
codebase was what produced the rule.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

Semgrep Scan Results

Repository: api | Commit: 7a6616e

Check Status Details
⚠️ Semgrep Warning 26 warning(s), 50 total

Scanned at 2026-05-08 14:50 UTC

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

Security Scan Results

Repository: api | Commit: 7a6616e

Check Status Details
✅ Secret Scan Pass No secrets detected
⚠️ Dependencies (Trivy) High 1 high, 2 total
⚠️ Dependencies (Grype) High 1 high, 2 total
📦 SBOM Generated 469 components (CycloneDX)

Scanned at 2026-05-08 14:51 UTC

Cre-eD added 3 commits May 8, 2026 12:50
Without this, the StorageEncrypted: sdk.Bool(true) added in this PR
triggers a REPLACEMENT of any pre-existing unencrypted RDS instance
when the customer next runs `sc deploy` after upgrading SC. AWS RDS's
`storage_encrypted` is immutable — Pulumi can only enforce a change
by destroying the instance and creating a new one, which means data
loss + downtime.

Adding `sdk.IgnoreChanges([]string{"storageEncrypted"})` on both
mysql and postgres RDS resource opts so:

  * NEW instances created after this lands → get StorageEncrypted: true
  * EXISTING pre-encryption instances → state-vs-code drift on this
    field is silently ignored; pulumi does NOT propose a replacement

Customers who want to encrypt an existing instance must follow the
standard AWS migration path:
  1. Take a snapshot of the unencrypted instance.
  2. Copy the snapshot with encryption enabled (CopyDBSnapshot,
     KmsKeyId set).
  3. Restore from the encrypted snapshot to a new instance.
  4. Cut traffic over (DNS / app config), then delete the old.
  5. (If using Pulumi state) re-import the new instance into the SC
     stack so subsequent diffs match.

Documented inline as a comment on the IgnoreChanges line so future
maintainers don't accidentally remove it and break customer fleets.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
Per PR #239 review feedback (Ilya Sadykov): the previous version forced
encryption on every NEW RDS instance after upgrade — a backwards-
compatibility break for customers who hadn't asked for it. Even with
the IgnoreChanges safety net for existing instances, brand-new stacks
created post-upgrade would silently get encryption.

Refactor to make encryption an explicit opt-in:

  * MysqlConfig and PostgresConfig get a new optional field:
      StorageEncrypted *bool `json:"storageEncrypted,omitempty"`
  * Pulumi resource uses `sdk.Bool(lo.FromPtr(cfg.StorageEncrypted))`.
    nil   → false (legacy default, AWS-side default = unencrypted)
    true  → encrypted (uses AWS-managed `aws/rds` KMS key by default)
    false → unencrypted (caller asked for it explicitly; respect that)
  * `sdk.IgnoreChanges([]string{"storageEncrypted"})` is RETAINED on
    the resource opts. It's still load-bearing: once a customer flips
    the bit on a stack with a pre-existing unencrypted instance, the
    AWS-side `storage_encrypted` attribute is immutable, so without
    IgnoreChanges Pulumi would propose a destructive replacement.

Same change in both rds_mysql.go and rds_postgres.go on both the api-
config side and the pulumi-resource side.

Tests added in pkg/clouds/aws/rds_storage_encrypted_test.go covering
all three states (omitted / explicit-true / explicit-false) for both
mysql and postgres. Verifies:
  - Round-trips through `api.ConvertConfig` with the right pointer
    semantics (omitted YAML → nil pointer in struct).
  - The resolved `lo.FromPtr` flag matches the documented contract,
    which is what gets passed to `rds.NewInstance`.

Migration path for existing unencrypted instances stays the same and
is documented inline next to the IgnoreChanges line: snapshot →
encrypted-copy → restore → re-import.

Future-proofing: if more RDS hardening fields land (MultiAZ,
DeletionProtection, BackupRetentionPeriod...), each new field should
follow the same opt-in shape until the count grows enough to justify
a `SchemaVersion`-style bundle (mongodb-atlas precedent in
pkg/clouds/mongodb/mongodb.go).

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
Tests in pkg/clouds/aws/rds_storage_encrypted_test.go covering all
three states (omitted / explicit-true / explicit-false) for both
MysqlConfig and PostgresConfig. Verifies:
  - Round-trips through `api.ConvertConfig` with correct pointer
    semantics (omitted YAML → nil pointer).
  - The resolved `lo.FromPtr` flag matches the documented contract
    (nil → false / true → true / false → false), which is exactly
    what gets passed to `rds.NewInstance(StorageEncrypted: ...)`.

Tests hit the same code path the pulumi handlers use, so any future
rename / serialization breakage surfaces here without needing a
Pulumi mock-resource harness. Replacement-safety + IgnoreChanges
behaviour is integration-only and documented separately on the PR.

Should have been in the previous commit.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@Cre-eD Cre-eD merged commit 524064d into main May 8, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants