Skip to content

Commit 20afd56

Browse files
committed
Merge branch 'fix/enforce-encryption-at-rest' into 'main'
Verify and enforce encryption at rest (AES-256) Closes #136 See merge request postgres-ai/postgresai!216
2 parents 5defe5c + 9ca059b commit 20afd56

7 files changed

Lines changed: 334 additions & 1 deletion

File tree

.gitlab-ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,31 @@ vm-auth:config:tests:
219219
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
220220
- if: '$CI_COMMIT_BRANCH == "main"'
221221

222+
terraform:aws:tests:
223+
stage: test
224+
image:
225+
name: hashicorp/terraform:1.9
226+
entrypoint: [""]
227+
variables:
228+
TF_IN_AUTOMATION: "true"
229+
script:
230+
# file("~/.ssh/test-key.pem") in terraform_data connection block is
231+
# evaluated eagerly during config parsing even though the resource is
232+
# overridden in tests. Create a dummy file so file() resolves; the
233+
# content is never used for an actual SSH connection.
234+
- mkdir -p ~/.ssh && printf 'dummy-key-for-testing\n' > ~/.ssh/test-key.pem
235+
- cd terraform/aws
236+
- terraform init -backend=false
237+
- terraform validate
238+
- terraform test -filter=tests/kms_encryption.tftest.hcl
239+
rules:
240+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
241+
changes:
242+
- terraform/aws/**/*
243+
- if: '$CI_COMMIT_BRANCH == "main"'
244+
changes:
245+
- terraform/aws/**/*
246+
222247
cli:node:smoke:
223248
stage: test
224249
image: node:20-alpine

postgres_ai_helm/values.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ storage:
2222
victoriaMetricsSize: 150Gi
2323
grafanaSize: 5Gi
2424
accessModes: ["ReadWriteOnce"]
25+
# Storage class for persistent volumes.
26+
# For encryption at rest (required for DPA/SOC2 compliance), use an
27+
# encrypted storage class. Examples:
28+
# AWS EKS: create a StorageClass with "encrypted: true" parameter,
29+
# or enable account-level default EBS encryption
30+
# GCP GKE: "standard" or "premium-rwo" (encrypted at rest by default with Google-managed keys)
31+
# Azure AKS: "managed-premium" (encrypted by default with platform-managed keys)
32+
# Hetzner HCloud: "hcloud-volumes" (encrypted at rest by default)
33+
# Leave empty to use the cluster default storage class.
2534
storageClassName: ""
2635

2736
global:

terraform/aws/README.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Single EC2 instance with Docker Compose.
99
Terraform creates:
1010
- VPC with public subnet
1111
- EC2 instance (Ubuntu 22.04 LTS)
12-
- EBS volumes (configurable types: gp3, st1, sc1, encrypted)
12+
- EBS volumes (configurable types: gp3, st1, sc1; AES-256 encryption at rest enabled)
1313
- Security Group (SSH + Grafana ports)
1414
- Elastic IP (optional)
1515

@@ -223,6 +223,37 @@ When enabled, all VictoriaMetrics API endpoints require authentication. The heal
223223

224224
Grafana, the Flask backend, and the Reporter are automatically configured to use the credentials.
225225

226+
227+
### Encryption at rest
228+
229+
All monitoring data is encrypted at rest using AES-256:
230+
231+
- **EBS root volume** (30 GiB): `encrypted = true` in `main.tf`
232+
- **EBS data volume** (configurable size): `encrypted = true` in `main.tf`
233+
- **Encryption key**: AWS-managed `aws/ebs` key by default. To use a custom KMS key, set `encryption_kms_key_arn` in `terraform.tfvars`:
234+
```hcl
235+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
236+
```
237+
> **WARNING — data loss risk:** Changing `encryption_kms_key_arn` in **any direction** (empty → ARN, ARN → empty, or ARN → different ARN) on an existing deployment will cause Terraform to **destroy and recreate** both the EBS data volume and the EC2 instance (`kms_key_id` is a `ForceNew` attribute). Take a snapshot of **both** the data volume and the root volume before applying this change to a live environment, then restore from snapshots after Terraform completes.
238+
239+
Postgres (sink-postgres) and VictoriaMetrics store data on the encrypted EBS data volume mounted at `/data`. No application-level encryption is needed — the storage layer handles it transparently.
240+
241+
To verify encryption on a running instance:
242+
243+
```bash
244+
# Data volume
245+
aws ec2 describe-volumes \
246+
--volume-ids $(terraform output -raw data_volume_id) \
247+
--query 'Volumes[0].{Encrypted:Encrypted,KmsKeyId:KmsKeyId}'
248+
249+
# Root volume
250+
aws ec2 describe-volumes \
251+
--volume-ids $(terraform output -raw root_volume_id) \
252+
--query 'Volumes[0].{Encrypted:Encrypted,KmsKeyId:KmsKeyId}'
253+
```
254+
255+
**Note:** This covers encryption at rest only. For encryption in transit, configure TLS for Postgres connections and HTTPS for Grafana endpoints.
256+
226257
### Recommendations
227258

228259
1. **Most secure setup (SSH tunnel only)**:
@@ -434,6 +465,29 @@ For production deployments:
434465
- Configure monitoring instances manually after deployment (Method 2)
435466
- Store state in private repositories only
436467

468+
#### Remote state with encryption (recommended for production)
469+
470+
Add a backend block inside `terraform {}` in `main.tf`:
471+
472+
```hcl
473+
backend "s3" {
474+
bucket = "your-terraform-state-bucket"
475+
key = "postgres-ai-monitoring/terraform.tfstate"
476+
region = "us-east-1"
477+
encrypt = true # AES-256 encryption at rest (SSE-S3)
478+
dynamodb_table = "terraform-locks" # State locking (Terraform < 1.10)
479+
# use_lockfile = true # Native S3 locking (Terraform >= 1.10, replaces dynamodb_table)
480+
}
481+
```
482+
483+
This ensures the state file (which contains credentials) is encrypted at rest in S3.
484+
485+
**Important:** The S3 bucket should have versioning enabled, public access blocked, and a bucket policy enforcing TLS (`aws:SecureTransport`). For SSE-KMS instead of SSE-S3, add `kms_key_id` to the backend configuration.
486+
487+
**Note:** For existing deployments with local state, run `terraform init -migrate-state` after adding the backend block.
488+
489+
**Note:** The application (Postgres, VictoriaMetrics) does not use S3 buckets for data storage — all data resides on the encrypted EBS data volume at `/data`.
490+
437491
### IMDSv2
438492

439493
EC2 instances are configured to require IMDSv2 (Instance Metadata Service v2) for enhanced security against SSRF attacks.

terraform/aws/main.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ resource "aws_ebs_volume" "data" {
145145
size = var.data_volume_size
146146
type = var.data_volume_type
147147
encrypted = true
148+
kms_key_id = var.encryption_kms_key_arn != "" ? var.encryption_kms_key_arn : null
148149

149150
tags = {
150151
Name = "${var.environment}-postgres-ai-data"
@@ -165,6 +166,7 @@ resource "aws_instance" "main" {
165166
volume_size = 30
166167
volume_type = var.root_volume_type
167168
encrypted = true
169+
kms_key_id = var.encryption_kms_key_arn != "" ? var.encryption_kms_key_arn : null
168170
}
169171

170172
user_data = templatefile("${path.module}/user_data.sh", {

terraform/aws/outputs.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ output "data_volume_id" {
88
value = aws_ebs_volume.data.id
99
}
1010

11+
output "root_volume_id" {
12+
description = "EBS root volume ID (for snapshots)"
13+
value = one(aws_instance.main.root_block_device).volume_id
14+
}
15+
1116
output "public_ip" {
1217
description = "Public IP address"
1318
value = var.use_elastic_ip ? aws_eip.main[0].public_ip : aws_instance.main.public_ip
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Tests for encryption_kms_key_arn variable validation and KMS key propagation.
2+
# Requires Terraform >= 1.7 (mock_provider support).
3+
# Run with: terraform test -filter=tests/kms_encryption.tftest.hcl
4+
5+
mock_provider "aws" {
6+
mock_data "aws_availability_zones" {
7+
defaults = {
8+
names = ["us-east-1a", "us-east-1b", "us-east-1c"]
9+
}
10+
}
11+
12+
mock_data "aws_ami" {
13+
defaults = {
14+
id = "ami-0123456789abcdef0"
15+
}
16+
}
17+
}
18+
mock_provider "local" {}
19+
20+
# Shared base variables for all runs (required variables with mock values)
21+
variables {
22+
aws_region = "us-east-1"
23+
environment = "test"
24+
instance_type = "t3.micro"
25+
data_volume_size = 100
26+
data_volume_type = "gp3"
27+
root_volume_type = "gp3"
28+
ssh_key_name = "test-key"
29+
allowed_ssh_cidr = ["10.0.0.0/8"]
30+
allowed_cidr_blocks = []
31+
use_elastic_ip = false
32+
grafana_password = "test-password"
33+
}
34+
35+
# --- Validation: valid inputs must pass ---
36+
37+
run "empty_string_passes_validation" {
38+
command = plan
39+
40+
variables {
41+
encryption_kms_key_arn = ""
42+
}
43+
44+
assert {
45+
condition = aws_ebs_volume.data.encrypted == true
46+
error_message = "EBS data volume must be encrypted at rest"
47+
}
48+
49+
# kms_key_id is Computed+Optional in the AWS provider schema, so its planned
50+
# value is "known after apply" even when we set it to null in the config.
51+
# The null-path cannot be asserted at plan time with a mock provider.
52+
# Non-null propagation (ARN → kms_key_id) is verified in
53+
# standard_key_arn_passes_validation and kms_key_propagates_to_* runs.
54+
55+
assert {
56+
condition = one(aws_instance.main.root_block_device).encrypted == true
57+
error_message = "EC2 root block device must be encrypted at rest"
58+
}
59+
}
60+
61+
run "standard_key_arn_passes_validation" {
62+
command = plan
63+
64+
variables {
65+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
66+
}
67+
68+
assert {
69+
condition = aws_ebs_volume.data.kms_key_id == "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
70+
error_message = "EBS data volume kms_key_id must equal encryption_kms_key_arn"
71+
}
72+
73+
assert {
74+
condition = one(aws_instance.main.root_block_device).kms_key_id == "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
75+
error_message = "Root block device kms_key_id must equal encryption_kms_key_arn"
76+
}
77+
}
78+
79+
run "uppercase_hex_key_arn_passes_validation" {
80+
command = plan
81+
82+
variables {
83+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-ABCD-1234-1234-123456789012"
84+
}
85+
}
86+
87+
run "multi_region_key_arn_passes_validation" {
88+
command = plan
89+
90+
variables {
91+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/mrk-1234abcd12ab34cd56ef1234567890ab"
92+
}
93+
}
94+
95+
run "alias_arn_passes_validation" {
96+
command = plan
97+
98+
variables {
99+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:alias/my-ebs-key"
100+
}
101+
}
102+
103+
run "govcloud_key_arn_passes_validation" {
104+
command = plan
105+
106+
variables {
107+
encryption_kms_key_arn = "arn:aws-us-gov:kms:us-gov-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
108+
}
109+
}
110+
111+
# --- Validation: invalid inputs must fail ---
112+
113+
run "plain_string_fails_validation" {
114+
command = plan
115+
116+
variables {
117+
encryption_kms_key_arn = "not-an-arn"
118+
}
119+
120+
expect_failures = [var.encryption_kms_key_arn]
121+
}
122+
123+
run "wrong_service_fails_validation" {
124+
command = plan
125+
126+
variables {
127+
encryption_kms_key_arn = "arn:aws:s3:::my-bucket"
128+
}
129+
130+
expect_failures = [var.encryption_kms_key_arn]
131+
}
132+
133+
run "truncated_arn_fails_validation" {
134+
command = plan
135+
136+
variables {
137+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/..."
138+
}
139+
140+
expect_failures = [var.encryption_kms_key_arn]
141+
}
142+
143+
run "short_account_id_fails_validation" {
144+
command = plan
145+
146+
variables {
147+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:12345678901:key/12345678-1234-1234-1234-123456789012"
148+
}
149+
150+
expect_failures = [var.encryption_kms_key_arn]
151+
}
152+
153+
run "malformed_key_id_fails_validation" {
154+
command = plan
155+
156+
variables {
157+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234"
158+
}
159+
160+
expect_failures = [var.encryption_kms_key_arn]
161+
}
162+
163+
run "long_account_id_fails_validation" {
164+
command = plan
165+
166+
variables {
167+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:1234567890123:key/12345678-1234-1234-1234-123456789012"
168+
}
169+
170+
expect_failures = [var.encryption_kms_key_arn]
171+
}
172+
173+
run "alias_arn_with_dots_passes_validation" {
174+
command = plan
175+
176+
variables {
177+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:alias/my.project.ebs-key"
178+
}
179+
}
180+
181+
run "short_key_id_fails_validation" {
182+
command = plan
183+
184+
variables {
185+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/abc"
186+
}
187+
188+
expect_failures = [var.encryption_kms_key_arn]
189+
}
190+
191+
# --- Propagation: KMS key ARN must be forwarded to EBS resources ---
192+
193+
run "kms_key_propagates_to_ebs_data_volume" {
194+
command = plan
195+
196+
variables {
197+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
198+
}
199+
200+
assert {
201+
condition = aws_ebs_volume.data.kms_key_id == "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
202+
error_message = "EBS data volume kms_key_id must equal encryption_kms_key_arn when non-empty"
203+
}
204+
205+
assert {
206+
condition = aws_ebs_volume.data.encrypted == true
207+
error_message = "EBS data volume must be encrypted at rest"
208+
}
209+
}
210+
211+
run "kms_key_propagates_to_root_block_device" {
212+
command = plan
213+
214+
variables {
215+
encryption_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
216+
}
217+
218+
assert {
219+
condition = one(aws_instance.main.root_block_device).kms_key_id == "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
220+
error_message = "EC2 root block device kms_key_id must equal encryption_kms_key_arn when non-empty"
221+
}
222+
223+
assert {
224+
condition = one(aws_instance.main.root_block_device).encrypted == true
225+
error_message = "EC2 root block device must be encrypted at rest"
226+
}
227+
}

terraform/aws/variables.tf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ variable "postgres_ai_version" {
8585
default = "0.10"
8686
}
8787

88+
variable "encryption_kms_key_arn" {
89+
description = "KMS key ARN or alias ARN for EBS encryption. Leave empty to use the default AWS-managed aws/ebs key (AES-256). Accepts key ARNs (arn:aws:kms:REGION:ACCOUNT:key/UUID) and alias ARNs (arn:aws:kms:REGION:ACCOUNT:alias/NAME). WARNING: changing this value in any direction (empty to ARN, ARN to empty, or ARN to different ARN) on existing infrastructure will destroy and recreate EBS volumes and the EC2 instance, causing data loss. Snapshot both volumes before making this change on a live deployment."
90+
type = string
91+
default = ""
92+
93+
validation {
94+
condition = var.encryption_kms_key_arn == "" || can(regex("^arn:aws(-[a-z-]+)?:kms:[a-z0-9-]+:[0-9]{12}:(key/((mrk-[a-fA-F0-9]{32})|([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}))|alias/[a-zA-Z0-9/._-]+)$", var.encryption_kms_key_arn))
95+
error_message = "Must be a valid KMS key ARN (e.g., 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012') or alias ARN (e.g., 'arn:aws:kms:us-east-1:123456789012:alias/my-ebs-key'), or empty string to use the AWS-managed aws/ebs key."
96+
}
97+
}
98+
8899
variable "bind_host" {
89100
description = "Bind host for internal service ports (127.0.0.1: for localhost only, empty for all interfaces)"
90101
type = string

0 commit comments

Comments
 (0)