Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions infra/deployments/forms/health/grafana.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
locals {
managed_grafana_count = var.environmental_settings.enable_managed_grafana ? 1 : 0
}

data "aws_iam_policy_document" "grafana_assume_role" {
statement {
actions = ["sts:AssumeRole"]

principals {
type = "Service"
identifiers = ["grafana.amazonaws.com"]
}
}
}

resource "aws_iam_role" "grafana" {
count = local.managed_grafana_count

name = "${var.environment_name}-grafana-workspace"
assume_role_policy = data.aws_iam_policy_document.grafana_assume_role.json
}

resource "aws_iam_role_policy_attachment" "grafana" {
for_each = var.environmental_settings.enable_managed_grafana ? toset([
"arn:aws:iam::aws:policy/service-role/AmazonGrafanaCloudWatchAccess",
"arn:aws:iam::aws:policy/service-role/AmazonGrafanaAthenaAccess",
"arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess",
]) : toset([])

role = aws_iam_role.grafana[0].name
policy_arn = each.value
}

resource "aws_grafana_workspace" "this" {
count = local.managed_grafana_count

name = "${var.environment_name}-forms"
description = "GOV.UK Forms ${var.environment_name} observability"
account_access_type = "CURRENT_ACCOUNT"
authentication_providers = ["AWS_SSO"]
permission_type = "SERVICE_MANAGED"
role_arn = aws_iam_role.grafana[0].arn
data_sources = ["ATHENA", "CLOUDWATCH", "XRAY"]
grafana_version = "12.4"

depends_on = [aws_iam_role_policy_attachment.grafana]
}
3 changes: 3 additions & 0 deletions infra/deployments/forms/health/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "grafana_workspace_endpoint" {
value = one(aws_grafana_workspace.this[*].endpoint)
}
Comment on lines +1 to +3
5 changes: 5 additions & 0 deletions infra/deployments/forms/health/submission-events.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module "submission_events" {
count = var.forms_runner_settings.enable_submission_events_analytics ? 1 : 0
source = "../../../modules/submission-events"
env_name = var.environment_name
}
Comment on lines +1 to +5
2 changes: 2 additions & 0 deletions infra/deployments/forms/inputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ variable "forms_runner_settings" {
govuk_one_login_base_url = string
queue_worker_capacity = string
disable_builtin_solidqueue_worker = bool
enable_submission_events_analytics = optional(bool, false)
})
}

Expand Down Expand Up @@ -206,6 +207,7 @@ variable "environmental_settings" {
enable_advanced_database_insights = bool
rds_enhanced_monitoring_interval_seconds = number # Enables RDS enhanced monitoring if value is 1, 5, 10, 15, 30 or 60. Disabled if 0
serve_assets_from_s3 = optional(bool, false)
enable_managed_grafana = optional(bool, false)
})
validation {
condition = contains([0, 1, 5, 10, 15, 30, 60], var.environmental_settings.rds_enhanced_monitoring_interval_seconds)
Expand Down
2 changes: 2 additions & 0 deletions infra/deployments/forms/tfvars/dev.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ environmental_settings = {
enable_advanced_database_insights = false
rds_enhanced_monitoring_interval_seconds = 0 # disabled
serve_assets_from_s3 = true
enable_managed_grafana = true
}
root_domain = "dev.forms.service.gov.uk"
additional_dns_records = [
Expand Down Expand Up @@ -114,6 +115,7 @@ forms_runner_settings = {
govuk_one_login_base_url = "https://oidc.integration.account.gov.uk/"
queue_worker_capacity = 1
disable_builtin_solidqueue_worker = true
enable_submission_events_analytics = true
}
scheduled_smoke_tests_settings = {
enable_scheduled_smoke_tests = true
Expand Down
1 change: 1 addition & 0 deletions infra/deployments/forms/tfvars/production.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ forms_runner_settings = {
govuk_one_login_base_url = "https://oidc.account.gov.uk/"
queue_worker_capacity = 6
disable_builtin_solidqueue_worker = true
enable_submission_events_analytics = false
}
scheduled_smoke_tests_settings = {
enable_scheduled_smoke_tests = true
Expand Down
1 change: 1 addition & 0 deletions infra/deployments/forms/tfvars/staging.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ forms_runner_settings = {
govuk_one_login_base_url = "https://oidc.integration.account.gov.uk/"
queue_worker_capacity = 1
disable_builtin_solidqueue_worker = true
enable_submission_events_analytics = false
}
scheduled_smoke_tests_settings = {
enable_scheduled_smoke_tests = true
Expand Down
37 changes: 36 additions & 1 deletion infra/modules/deployer-access/policy.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ data "aws_iam_policy_document" "forms_infra_1" {
data.aws_iam_policy_document.ecs.json,
data.aws_iam_policy_document.elasticache.json,
data.aws_iam_policy_document.events.json,
data.aws_iam_policy_document.grafana.json,
data.aws_iam_policy_document.guardduty.json,
]
}
Expand Down Expand Up @@ -449,6 +450,39 @@ data "aws_iam_policy_document" "events" {
}
}

data "aws_iam_policy_document" "grafana" {
statement {
# CreateWorkspace and the sso/organizations calls do not support resource-level permissions
actions = [
"grafana:CreateWorkspace",
"sso:CreateManagedApplicationInstance",
"sso:DeleteManagedApplicationInstance",
"sso:GetManagedApplicationInstance",
"sso:GetSharedSsoConfiguration",
"sso:DescribeRegisteredRegions",
"organizations:DescribeOrganization"
]
effect = "Allow"
resources = ["*"]
sid = "CreateGrafanaWorkspace"
}

statement {
actions = [
"grafana:DeleteWorkspace",
"grafana:UpdateWorkspace",
"grafana:UpdateWorkspaceConfiguration",
"grafana:TagResource",
"grafana:UntagResource"
]
Comment on lines +471 to +477
effect = "Allow"
resources = [
"arn:aws:grafana:eu-west-2:${var.account_id}:/workspaces/*"
]
sid = "ManageGrafanaWorkspaces"
}
}

data "aws_iam_policy_document" "guardduty" {
statement {
actions = [
Expand Down Expand Up @@ -525,7 +559,8 @@ data "aws_iam_policy_document" "iam" {
"arn:aws:iam::${var.account_id}:role/ecs-events-role",
"arn:aws:iam::${var.account_id}:role/deployer-${var.environment_name}",
"arn:aws:iam::${var.account_id}:role/malware-protection-for-s3",
"arn:aws:iam::${var.account_id}:role/RDSEnhancedMonitoring"
"arn:aws:iam::${var.account_id}:role/RDSEnhancedMonitoring",
"arn:aws:iam::${var.account_id}:role/${var.environment_name}-grafana-workspace"
]
sid = "ManageRoles"
}
Expand Down
1 change: 1 addition & 0 deletions infra/modules/submission-events/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
zip-files/*
44 changes: 44 additions & 0 deletions infra/modules/submission-events/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# submission-events

Streams `form_submission` log events from the forms-runner CloudWatch log
group to an Apache Iceberg table in S3 Tables, via a subscription filter and a
Kinesis Data Firehose stream.

```
/aws/ecs/forms-runner-<env>
→ subscription filter { $.event = "form_submission" }
→ Firehose (transform Lambda unwraps the CloudWatch Logs envelope)
→ S3 Table s3tablescatalog/govuk-forms-<env>-submission-events, forms.form_submissions
```

A transform Lambda (lambda/transform.rb) gunzips each CloudWatch Logs record
and maps its log events onto the table schema as newline-delimited JSON;
Firehose's built-in decompression processor only supports the S3, Splunk and
Snowflake destinations, not Iceberg. Firehose invokes the Lambda with batches
of records (up to 3 MB or 60 seconds of buffered data per invocation), not
once per event.

The table schema is `submitted_at` (timestamp, renamed from the log event's
`time` field and normalised to UTC), `form_id` and `form_name` (strings) and
`preview` (boolean). All other log fields are dropped by the Lambda. Records
Firehose cannot deliver land in the
`govuk-forms-<env>-submission-events-errors` bucket.

The table is unpartitioned so that it can be fully managed by Terraform (the
provider cannot yet set an Iceberg partition spec). At our volumes Iceberg's
per-file column statistics keep time-range queries cheap regardless.

## Querying

In Athena, select catalog `s3tablescatalog/govuk-forms-<env>-submission-events`
and database `forms`:

```sql
SELECT * FROM form_submissions ORDER BY submitted_at DESC LIMIT 10;
```

## Notes

- CloudWatch Logs sends a control message when the subscription filter is
created; the transform Lambda drops it.
- Preview submissions are included, with `preview = true`.
5 changes: 5 additions & 0 deletions infra/modules/submission-events/error-bucket.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Firehose requires an S3 bucket for records it fails to deliver to the table.
module "error_bucket" {
source = "../secure-bucket"
name = "govuk-forms-${var.env_name}-submission-events-errors"
}
65 changes: 65 additions & 0 deletions infra/modules/submission-events/firehose.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
resource "aws_kinesis_firehose_delivery_stream" "submission_events" {
name = local.stream_name
destination = "iceberg"

# No customer-managed key: the data is not sensitive, the AWS-owned key is
# free and needs no management.
#checkov:skip=CKV_AWS_241:An AWS-owned key is sufficient, no need for CM KMS.
server_side_encryption {
enabled = true
key_type = "AWS_OWNED_CMK"
}

iceberg_configuration {
role_arn = aws_iam_role.firehose_delivery.arn
catalog_arn = "arn:aws:glue:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:catalog/s3tablescatalog/${local.table_bucket_name}"
buffering_interval = var.firehose_buffering_interval_seconds

destination_table_configuration {
database_name = local.namespace_name
table_name = local.table_name
}

s3_configuration {
role_arn = aws_iam_role.firehose_delivery.arn
bucket_arn = module.error_bucket.arn
}

# Records arrive as gzipped CloudWatch Logs envelopes. The built-in
# Decompression processor is not supported for the Iceberg destination, so
# a Lambda unwraps the envelope instead (see lambda/transform.rb).
processing_configuration {
enabled = true

processors {
type = "Lambda"
parameters {
parameter_name = "LambdaArn"
parameter_value = "${aws_lambda_function.transform.arn}:$LATEST"
}
}
}

cloudwatch_logging_options {
enabled = true
log_group_name = aws_cloudwatch_log_group.firehose.name
log_stream_name = aws_cloudwatch_log_stream.firehose_delivery.name
}
}

# Firehose validates at creation time that the destination table exists and
# is reachable through the federated catalog
depends_on = [aws_glue_catalog.s3tablescatalog, aws_s3tables_table.form_submissions]
}

resource "aws_cloudwatch_log_group" "firehose" {
#checkov:skip=CKV_AWS_338:We're happy with 30 days retention for now
#checkov:skip=CKV_AWS_158:Default AWS SSE is sufficient, no need for CM KMS.
name = "/aws/kinesisfirehose/${local.stream_name}"
retention_in_days = 30
}

resource "aws_cloudwatch_log_stream" "firehose_delivery" {
name = "DestinationDelivery"
log_group_name = aws_cloudwatch_log_group.firehose.name
}
30 changes: 30 additions & 0 deletions infra/modules/submission-events/glue-catalog.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Integrates S3 Tables with AWS analytics services (Athena, Firehose, etc.) by
# federating all S3 table buckets in the account into the Glue Data Catalog.
# This is an account+region singleton: the name must be "s3tablescatalog" and
# it covers every table bucket in the account, not just the one in this module.
# Access control is IAM-based (IAM_ALLOWED_PRINCIPALS), so consumers need IAM
# permissions only, no Lake Formation grants.
resource "aws_glue_catalog" "s3tablescatalog" {
name = "s3tablescatalog"

federated_catalog {
connection_name = "aws:s3tables"
identifier = "arn:aws:s3tables:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:bucket/*"
}
Comment on lines +1 to +13

create_database_default_permissions {
permissions = ["ALL"]
principal {
data_lake_principal_identifier = "IAM_ALLOWED_PRINCIPALS"
}
}

create_table_default_permissions {
permissions = ["ALL"]
principal {
data_lake_principal_identifier = "IAM_ALLOWED_PRINCIPALS"
}
}

allow_full_table_external_data_access = "True"
}
Loading
Loading