Files
Claude ac24938108 AWS-basen: allt eget, inget GitHub
Guidad Felsökning får en egen AWS-grund i felsokning/infra/aws — nät,
kluster, databas, objektlagring, register, hemligheter, domän och
observation. Inget GHCR, ingen extern byggtjänst.

Två lager i ordning, och uppdelningen är inte smak: en enda apply som
både skapar ett EKS-kluster och schemalägger in i det är en känd fälla,
eftersom kubernetes-leverantören måste konfigureras med uppgifter som
inte finns förrän klustret existerar.

Nätet ligger i tre lager. Publikt bara för lastbalanserare och NAT,
privat för noderna, och ett eget datalager för Aurora utan routing ut
alls — att databasen inte kan nå internet hänger då inte på att en
säkerhetsgrupp är rätt konfigurerad. VPC-endpoints för S3, ECR, loggar,
Secrets Manager, STS och ELB gör att bilddragningar och loggar aldrig
lämnar nätet, vilket både är säkrare och sänker NAT-notan rejält.

IRSA i stället för nodroller: plattformens tjänstekonto har en roll
bunden till exakt en namnrymd och ett tjänstekonto, så grannpodden på
samma nod får ingenting på köpet. IMDSv2 med hoppgräns 1 hindrar
dessutom en pod från att låna nodens roll via metadatatjänsten.

Bygge och drift har delade rättigheter. Byggrollen får publicera till
ECR men inte röra klustret; driftrollen tvärtom. Ett komprometterat
bygge kan därför inte driftsätta.

Aurora PostgreSQL Serverless v2 med 30 dagars säkerhetskopiering och
PITR ned till sekunden — det som gör den duglig för händelseloggen är
inte prestandan utan att förlorad logg annars hade tagit varje ärendes
bevisvärde med sig. S3-hinken för bilagor har versionshantering,
SSE-KMS, TLS-krav och blockerad publik åtkomst; plattformens roll får
läsa och skriva men aldrig radera, eftersom bilagorna hör till en
append-only logg.

Larmen är fyra och ett av dem larmar på saknad data: uteblir mätvärdet
för säkerhetskopiering finns ingen backup, och en backup man tror finns
är värre än ingen.

Verifierat: terraform fmt och en statisk referenskontroll (88 resurser,
5 datakällor, inga dinglande referenser). terraform validate kunde inte
köras här — registry.terraform.io är blockerad av sessionens
egress-policy — så CI-jobbet kör den i stället.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-04 12:39:42 +00:00

303 lines
8.5 KiB
Terraform

# Data: händelseloggen, bilagorna och bilderna.
# ---- Nycklar ------------------------------------------------------------
#
# Egna KMS-nycklar i stället för AWS delade. Det som vinns är
# åtkomstkontroll och revisionsspår per nyckel — och möjligheten att
# rotera eller spärra utan att röra något annat.
resource "aws_kms_key" "eks" {
description = "${local.namn} — hemligheter i etcd"
enable_key_rotation = true
deletion_window_in_days = 30
}
resource "aws_kms_alias" "eks" {
name = "alias/${local.namn}-eks"
target_key_id = aws_kms_key.eks.key_id
}
resource "aws_kms_key" "data" {
description = "${local.namn} — databas och bilagor"
enable_key_rotation = true
deletion_window_in_days = 30
}
resource "aws_kms_alias" "data" {
name = "alias/${local.namn}-data"
target_key_id = aws_kms_key.data.key_id
}
resource "aws_kms_key" "loggar" {
description = "${local.namn} — loggar"
enable_key_rotation = true
deletion_window_in_days = 30
# CloudWatch måste kunna kryptera med nyckeln.
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { AWS = "arn:${data.aws_partition.denna.partition}:iam::${data.aws_caller_identity.denna.account_id}:root" }
Action = "kms:*"
Resource = "*"
},
{
Effect = "Allow"
Principal = { Service = "logs.${var.region}.amazonaws.com" }
Action = ["kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*"]
Resource = "*"
Condition = {
ArnLike = {
"kms:EncryptionContext:aws:logs:arn" = "arn:${data.aws_partition.denna.partition}:logs:${var.region}:${data.aws_caller_identity.denna.account_id}:log-group:*"
}
}
},
]
})
}
resource "aws_kms_alias" "loggar" {
name = "alias/${local.namn}-loggar"
target_key_id = aws_kms_key.loggar.key_id
}
# ---- Aurora PostgreSQL --------------------------------------------------
#
# Serverless v2 skalar med lasten. Det som gör att den här databasen
# duger till händelseloggen är inte prestandan utan
# säkerhetskopieringen: automatisk backup med PITR ned till sekunden
# inom retentionsfönstret. Går loggen förlorad är varje ärendes
# bevisvärde borta, och det går inte att återskapa i efterhand.
resource "aws_db_subnet_group" "denna" {
name = local.namn
subnet_ids = aws_subnet.data[*].id
tags = { Name = local.namn }
}
resource "aws_security_group" "databas" {
name = "${local.namn}-databas"
description = "Aurora — tar bara emot från noderna."
vpc_id = aws_vpc.denna.id
ingress {
description = "PostgreSQL från klustrets noder"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.noder.id]
}
# Ingen egress-regel: databasen ska inte kunna ringa någonstans.
tags = { Name = "${local.namn}-databas" }
}
resource "random_password" "databas" {
length = 40
special = false # undviker citeringsproblem i anslutningssträngar
}
resource "aws_rds_cluster" "denna" {
cluster_identifier = local.namn
engine = "aurora-postgresql"
engine_mode = "provisioned"
engine_version = var.databas.version
database_name = "felsokning"
master_username = "plattform"
master_password = random_password.databas.result
db_subnet_group_name = aws_db_subnet_group.denna.name
vpc_security_group_ids = [aws_security_group.databas.id]
storage_encrypted = true
kms_key_id = aws_kms_key.data.arn
backup_retention_period = var.databas.backup_dagar
preferred_backup_window = "02:00-03:00"
preferred_maintenance_window = "sun:03:30-sun:04:30"
copy_tags_to_snapshot = true
deletion_protection = var.databas.raderingsskydd
skip_final_snapshot = false
final_snapshot_identifier = "${local.namn}-slutlig"
# Loggarna behövs för att i efterhand kunna se vad som kördes.
enabled_cloudwatch_logs_exports = ["postgresql"]
serverlessv2_scaling_configuration {
min_capacity = var.databas.min_kapacitet
max_capacity = var.databas.max_kapacitet
}
tags = { Name = local.namn }
lifecycle {
ignore_changes = [master_password] # roteras via Secrets Manager
}
}
resource "aws_rds_cluster_instance" "denna" {
count = var.databas.instanser
identifier = "${local.namn}-${count.index}"
cluster_identifier = aws_rds_cluster.denna.id
instance_class = "db.serverless"
engine = aws_rds_cluster.denna.engine
engine_version = aws_rds_cluster.denna.engine_version
publicly_accessible = false
performance_insights_enabled = true
performance_insights_kms_key_id = aws_kms_key.data.arn
monitoring_interval = 30
monitoring_role_arn = aws_iam_role.rds_overvakning.arn
tags = { Name = "${local.namn}-${count.index}" }
}
resource "aws_iam_role" "rds_overvakning" {
name = "${local.namn}-rds-overvakning"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "monitoring.rds.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "rds_overvakning" {
role = aws_iam_role.rds_overvakning.name
policy_arn = "arn:${data.aws_partition.denna.partition}:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
}
# ---- S3 för bilagor -----------------------------------------------------
#
# Foton och videoklipp. Innehållsadresserade — nyckeln är innehållets
# SHA-256 — så versionshantering skyddar mot oavsiktlig radering snarare
# än mot överskrivning: samma nyckel har alltid samma innehåll.
resource "aws_s3_bucket" "bilagor" {
bucket = "${local.namn}-bilagor-${data.aws_caller_identity.denna.account_id}"
tags = { Name = "${local.namn}-bilagor" }
}
resource "aws_s3_bucket_public_access_block" "bilagor" {
bucket = aws_s3_bucket.bilagor.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "bilagor" {
bucket = aws_s3_bucket.bilagor.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "bilagor" {
bucket = aws_s3_bucket.bilagor.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
bucket_key_enabled = true # färre KMS-anrop, lägre kostnad
}
}
resource "aws_s3_bucket_lifecycle_configuration" "bilagor" {
bucket = aws_s3_bucket.bilagor.id
rule {
id = "stada-gamla-versioner"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
resource "aws_s3_bucket_policy" "bilagor" {
bucket = aws_s3_bucket.bilagor.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "KravTLS"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [aws_s3_bucket.bilagor.arn, "${aws_s3_bucket.bilagor.arn}/*"]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
}]
})
depends_on = [aws_s3_bucket_public_access_block.bilagor]
}
# ---- ECR ----------------------------------------------------------------
#
# Vårt eget register. Oföränderliga taggar: en tagg som en gång pekat på
# ett bygge kan inte peka på ett annat, så "vilken kod kör i produktion"
# har ett entydigt svar.
resource "aws_ecr_repository" "denna" {
for_each = toset([
"guidad-felsokning-web",
"guidad-felsokning-plattform",
"guidad-felsokning-ai-orkester",
])
name = each.key
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "KMS"
kms_key = aws_kms_key.data.arn
}
tags = { Name = each.key }
}
resource "aws_ecr_lifecycle_policy" "denna" {
for_each = aws_ecr_repository.denna
repository = each.value.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Behåll de 30 senaste bilderna."
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 30
}
action = { type = "expire" }
}]
})
}