IaC AWS

Terraform Module: Amazon Managed Grafana (AMG) — A Service-Managed Workspace With SSO, Roles, and Service-Account Tokens

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for Amazon Managed Grafana: a service-managed workspace wired to Prometheus/CloudWatch/X-Ray, AWS SSO or SAML auth, role associations, optional SAML config, an IAM read role, and modern service-account tokens replacing deprecated API keys. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "grafana" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"

  name = "..."  # Workspace name (e.g. "prod-observability").
  # ... auth defaults to AWS_SSO; data sources default to Prometheus + CloudWatch.
}

Then terraform init && terraform apply. The module creates the IAM role Grafana assumes to read AMP/CloudWatch automatically — see Inputs below to add SAML, role associations, and service-account tokens.

What this module is

Amazon Managed Grafana (AMG) is a fully managed Grafana service: AWS runs, scales, patches, and secures the Grafana servers while you build dashboards and alerts. The central resource is a workspace (aws_grafana_workspace), which declares how users authenticate (AWS_SSO, SAML, or both), how permissions are managed (SERVICE_MANAGED lets AWS generate the IAM plumbing), which data sources the workspace can reach (Prometheus, CloudWatch, X-Ray, and more), and the IAM role Grafana assumes to actually query those sources. Around the workspace, aws_grafana_role_association maps SSO users/groups to Grafana’s ADMIN/EDITOR/VIEWER roles, aws_grafana_workspace_saml_configuration configures SAML when you bring your own IdP, and the modern aws_grafana_workspace_service_account + aws_grafana_workspace_service_account_token pair issues automation credentials — the supported replacement for the now-deprecated workspace API key.

The fiddly parts this module absorbs: with SERVICE_MANAGED permissions, Grafana still needs an IAM role whose trust policy allows the grafana.amazonaws.com service and whose permissions let it read whatever you listed in data_sources — get the policy wrong and dashboards silently return “no data.” Role associations require you to know SSO user/group IDs, and mixing AWS_SSO with SAML has its own rules. Service-account tokens are short-lived by design (max 30 days) and must be regenerated. This module creates the workspace with safe defaults (current-account access, service-managed permissions, Prometheus + CloudWatch data sources), builds the correctly-trusted IAM read role with a least-privilege policy scoped to the chosen data sources, wires role associations from a simple map, gates SAML behind a flag, and optionally mints service accounts with tokens — exposing the endpoint and role ARN as outputs.

When to use it

Pair AMG with the Amazon Managed Prometheus module (AMG is its dashboard front end) and CloudWatch for AWS-native metrics and logs. Reach for self-hosted Grafana instead only when you need plugins or features AMG doesn’t support, or full control over the Grafana version and backend database.

Module structure

terraform-module-aws-managed-grafana/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # workspace, IAM read role, role associations, SAML, service accounts
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # workspace id, endpoint, role ARN, service-account token (sensitive)

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-managed-grafana"
    },
    var.tags,
  )

  # Map the requested data sources to the IAM actions Grafana needs to read them.
  ds_policy_statements = concat(
    contains(var.data_sources, "PROMETHEUS") ? [{
      Effect = "Allow"
      Action = [
        "aps:ListWorkspaces",
        "aps:DescribeWorkspace",
        "aps:QueryMetrics",
        "aps:GetLabels",
        "aps:GetSeries",
        "aps:GetMetricMetadata",
      ]
      Resource = "*"
    }] : [],
    contains(var.data_sources, "CLOUDWATCH") ? [{
      Effect = "Allow"
      Action = [
        "cloudwatch:DescribeAlarmsForMetric",
        "cloudwatch:DescribeAlarmHistory",
        "cloudwatch:DescribeAlarms",
        "cloudwatch:ListMetrics",
        "cloudwatch:GetMetricData",
        "cloudwatch:GetInsightRuleReport",
        "logs:DescribeLogGroups",
        "logs:GetLogGroupFields",
        "logs:StartQuery",
        "logs:StopQuery",
        "logs:GetQueryResults",
        "logs:GetLogEvents",
        "ec2:DescribeTags",
        "ec2:DescribeInstances",
        "ec2:DescribeRegions",
        "tag:GetResources",
      ]
      Resource = "*"
    }] : [],
    contains(var.data_sources, "XRAY") ? [{
      Effect = "Allow"
      Action = [
        "xray:BatchGetTraces",
        "xray:GetTraceSummaries",
        "xray:GetTraceGraph",
        "xray:GetGroups",
        "xray:GetTimeSeriesServiceStatistics",
        "xray:GetInsightSummaries",
        "xray:GetServiceGraph",
      ]
      Resource = "*"
    }] : [],
  )
}

# IAM role Grafana assumes to read the configured data sources. Required even
# with SERVICE_MANAGED permissions so dashboards can actually query AMP/CW/X-Ray.
resource "aws_iam_role" "grafana" {
  name = "${var.name}-grafana"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "grafana.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = local.tags
}

resource "aws_iam_role_policy" "grafana" {
  name = "${var.name}-grafana-datasources"
  role = aws_iam_role.grafana.id

  policy = jsonencode({
    Version   = "2012-10-17"
    Statement = local.ds_policy_statements
  })
}

resource "aws_grafana_workspace" "this" {
  name = var.name

  account_access_type      = var.account_access_type
  authentication_providers = var.authentication_providers
  permission_type          = var.permission_type
  role_arn                 = aws_iam_role.grafana.arn

  data_sources              = var.data_sources
  notification_destinations = var.notification_destinations
  grafana_version           = var.grafana_version
  description               = var.description

  # Lock the workspace behind a VPC when supplied (subnets in >=2 AZs).
  dynamic "vpc_configuration" {
    for_each = var.vpc_configuration == null ? [] : [var.vpc_configuration]
    content {
      security_group_ids = vpc_configuration.value.security_group_ids
      subnet_ids         = vpc_configuration.value.subnet_ids
    }
  }

  tags = local.tags
}

# Optional SAML configuration when authentication_providers includes "SAML".
resource "aws_grafana_workspace_saml_configuration" "this" {
  count = var.saml_configuration == null ? 0 : 1

  workspace_id       = aws_grafana_workspace.this.id
  editor_role_values = var.saml_configuration.editor_role_values
  admin_role_values  = var.saml_configuration.admin_role_values
  idp_metadata_url   = var.saml_configuration.idp_metadata_url
  idp_metadata_xml   = var.saml_configuration.idp_metadata_xml
  role_assertion     = var.saml_configuration.role_assertion
  email_assertion    = var.saml_configuration.email_assertion
  login_assertion    = var.saml_configuration.login_assertion
}

# Map SSO users/groups to Grafana roles (ADMIN/EDITOR/VIEWER).
resource "aws_grafana_role_association" "this" {
  for_each = var.role_associations

  workspace_id = aws_grafana_workspace.this.id
  role         = each.value.role
  user_ids     = each.value.user_ids
  group_ids    = each.value.group_ids
}

# Modern replacement for the deprecated workspace API key: a service account...
resource "aws_grafana_workspace_service_account" "this" {
  for_each = var.service_accounts

  name         = each.key
  grafana_role = each.value.grafana_role
  workspace_id = aws_grafana_workspace.this.id
}

# ...and a short-lived token for it (max 30 days), used for HTTP API automation.
resource "aws_grafana_workspace_service_account_token" "this" {
  for_each = var.service_accounts

  name               = "${each.key}-token"
  service_account_id = aws_grafana_workspace_service_account.this[each.key].service_account_id
  seconds_to_live    = each.value.seconds_to_live
  workspace_id       = aws_grafana_workspace.this.id
}

variables.tf

variable "name" {
  description = "Grafana workspace name."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-._~]{1,255}$", var.name))
    error_message = "name must be 1-255 chars of letters, digits, hyphen, period, underscore, or tilde."
  }
}

variable "account_access_type" {
  description = "Account access scope: CURRENT_ACCOUNT or ORGANIZATION."
  type        = string
  default     = "CURRENT_ACCOUNT"

  validation {
    condition     = contains(["CURRENT_ACCOUNT", "ORGANIZATION"], var.account_access_type)
    error_message = "account_access_type must be CURRENT_ACCOUNT or ORGANIZATION."
  }
}

variable "authentication_providers" {
  description = "Authentication providers: any of AWS_SSO, SAML."
  type        = list(string)
  default     = ["AWS_SSO"]

  validation {
    condition = length(var.authentication_providers) > 0 && alltrue([
      for p in var.authentication_providers : contains(["AWS_SSO", "SAML"], p)
    ])
    error_message = "authentication_providers must be a non-empty subset of [AWS_SSO, SAML]."
  }
}

variable "permission_type" {
  description = "SERVICE_MANAGED (AWS manages IAM) or CUSTOMER_MANAGED."
  type        = string
  default     = "SERVICE_MANAGED"

  validation {
    condition     = contains(["SERVICE_MANAGED", "CUSTOMER_MANAGED"], var.permission_type)
    error_message = "permission_type must be SERVICE_MANAGED or CUSTOMER_MANAGED."
  }
}

variable "data_sources" {
  description = "Data sources Grafana can query (drives the IAM read policy)."
  type        = list(string)
  default     = ["PROMETHEUS", "CLOUDWATCH"]

  validation {
    condition = alltrue([
      for d in var.data_sources : contains(
        ["AMAZON_OPENSEARCH_SERVICE", "ATHENA", "CLOUDWATCH", "PROMETHEUS",
        "REDSHIFT", "SITEWISE", "TIMESTREAM", "TWINMAKER", "XRAY"],
        d
      )
    ])
    error_message = "data_sources entries must be valid AMG data source names (e.g. PROMETHEUS, CLOUDWATCH, XRAY)."
  }
}

variable "notification_destinations" {
  description = "Notification destinations for Grafana alerting (only SNS is supported)."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for n in var.notification_destinations : n == "SNS"])
    error_message = "notification_destinations only supports SNS."
  }
}

variable "grafana_version" {
  description = "Grafana version to run (e.g. '9.4', '10.4', '12.4'). Null = latest."
  type        = string
  default     = null
}

variable "description" {
  description = "Workspace description."
  type        = string
  default     = "Managed Grafana workspace provisioned by Terraform."
}

variable "vpc_configuration" {
  description = "Optional VPC config so Grafana reaches in-VPC data sources. Null = public."
  type = object({
    security_group_ids = list(string)
    subnet_ids         = list(string)
  })
  default = null

  validation {
    condition     = var.vpc_configuration == null || length(var.vpc_configuration.subnet_ids) >= 2
    error_message = "vpc_configuration.subnet_ids must include subnets in at least two availability zones."
  }
}

variable "saml_configuration" {
  description = <<-EOT
    Optional SAML configuration (requires "SAML" in authentication_providers). Provide
    exactly one of idp_metadata_url / idp_metadata_xml.
      editor_role_values - SAML assertion values mapped to the Editor role (required)
      admin_role_values  - SAML assertion values mapped to the Admin role (optional)
      idp_metadata_url   - URL of the IdP metadata (optional)
      idp_metadata_xml   - inline IdP metadata XML (optional)
      role_assertion     - assertion attribute carrying the role (optional)
      email_assertion    - assertion attribute carrying the email (optional)
      login_assertion    - assertion attribute carrying the login (optional)
  EOT
  type = object({
    editor_role_values = list(string)
    admin_role_values  = optional(list(string))
    idp_metadata_url   = optional(string)
    idp_metadata_xml   = optional(string)
    role_assertion     = optional(string)
    email_assertion    = optional(string)
    login_assertion    = optional(string)
  })
  default = null
}

variable "role_associations" {
  description = <<-EOT
    Map keyed by an arbitrary label assigning SSO users/groups to a Grafana role.
      role      - ADMIN, EDITOR, or VIEWER (required)
      user_ids  - SSO user IDs (optional)
      group_ids - SSO group IDs (optional)
  EOT
  type = map(object({
    role      = string
    user_ids  = optional(list(string))
    group_ids = optional(list(string))
  }))
  default = {}

  validation {
    condition = alltrue([
      for r in values(var.role_associations) : contains(["ADMIN", "EDITOR", "VIEWER"], r.role)
    ])
    error_message = "Each role_associations role must be ADMIN, EDITOR, or VIEWER."
  }
}

variable "service_accounts" {
  description = <<-EOT
    Map of service account name => settings. Each creates a service account and a
    short-lived token (the modern replacement for the deprecated workspace API key).
      grafana_role    - ADMIN, EDITOR, or VIEWER (required)
      seconds_to_live - token lifetime in seconds, max 30 days (default 2592000)
  EOT
  type = map(object({
    grafana_role    = string
    seconds_to_live = optional(number, 2592000)
  }))
  default = {}

  validation {
    condition = alltrue([
      for s in values(var.service_accounts) :
      contains(["ADMIN", "EDITOR", "VIEWER"], s.grafana_role) &&
      s.seconds_to_live > 0 && s.seconds_to_live <= 2592000
    ])
    error_message = "service_accounts: grafana_role must be ADMIN/EDITOR/VIEWER and seconds_to_live in 1..2592000 (30 days)."
  }
}

variable "tags" {
  description = "Additional tags merged onto the workspace and IAM role."
  type        = map(string)
  default     = {}
}

outputs.tf

output "workspace_id" {
  description = "Identifier of the Grafana workspace (g-xxxx)."
  value       = aws_grafana_workspace.this.id
}

output "workspace_arn" {
  description = "ARN of the Grafana workspace."
  value       = aws_grafana_workspace.this.arn
}

output "endpoint" {
  description = "Grafana workspace endpoint (the URL users and the HTTP API hit)."
  value       = aws_grafana_workspace.this.endpoint
}

output "grafana_version" {
  description = "Grafana version running on the workspace."
  value       = aws_grafana_workspace.this.grafana_version
}

output "role_arn" {
  description = "ARN of the IAM role Grafana assumes to read AMP/CloudWatch/X-Ray."
  value       = aws_iam_role.grafana.arn
}

output "service_account_ids" {
  description = "Map of service account name => service account ID."
  value       = { for k, sa in aws_grafana_workspace_service_account.this : k => sa.service_account_id }
}

output "service_account_tokens" {
  description = "Map of service account name => token key for HTTP API auth. Sensitive."
  value       = { for k, t in aws_grafana_workspace_service_account_token.this : k => t.key }
  sensitive   = true
}

How to use it

module "grafana" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"

  name                     = "prod-observability"
  account_access_type      = "CURRENT_ACCOUNT"
  authentication_providers = ["AWS_SSO"]
  permission_type          = "SERVICE_MANAGED"

  # Drives the least-privilege IAM read role the module builds.
  data_sources              = ["PROMETHEUS", "CLOUDWATCH", "XRAY"]
  notification_destinations = ["SNS"]
  grafana_version           = "10.4"

  # Map SSO groups to Grafana roles.
  role_associations = {
    platform_admins = {
      role      = "ADMIN"
      group_ids = ["ssogroup-1111-admins"]
    }
    app_editors = {
      role      = "EDITOR"
      group_ids = ["ssogroup-2222-editors"]
    }
    everyone_view = {
      role      = "VIEWER"
      group_ids = ["ssogroup-3333-alleng"]
    }
  }

  # Service account + token for CI to push dashboards via the HTTP API.
  service_accounts = {
    ci-provisioner = {
      grafana_role    = "EDITOR"
      seconds_to_live = 604800 # 7 days
    }
  }

  tags = {
    Environment = "prod"
    Team        = "observability"
    CostCenter  = "OBS-3300"
  }
}

# Downstream: stash the service-account token in Secrets Manager for CI to read,
# instead of printing it or committing it. The value is marked sensitive.
resource "aws_secretsmanager_secret" "grafana_ci_token" {
  name = "grafana/prod/ci-provisioner-token"
}

resource "aws_secretsmanager_secret_version" "grafana_ci_token" {
  secret_id     = aws_secretsmanager_secret.grafana_ci_token.id
  secret_string = module.grafana.service_account_tokens["ci-provisioner"]
}

output "grafana_url" {
  value = module.grafana.endpoint
}

For a SAML IdP instead of AWS SSO, set authentication_providers = ["SAML"] and supply saml_configuration with your IdP metadata URL and role-value mappings.

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/grafana/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/grafana && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Grafana workspace name.
account_access_type string CURRENT_ACCOUNT No CURRENT_ACCOUNT or ORGANIZATION.
authentication_providers list(string) [“AWS_SSO”] No Subset of [AWS_SSO, SAML].
permission_type string SERVICE_MANAGED No SERVICE_MANAGED or CUSTOMER_MANAGED.
data_sources list(string) [“PROMETHEUS”,“CLOUDWATCH”] No Data sources to enable; drives the IAM read policy.
notification_destinations list(string) [] No Alerting destinations (only SNS supported).
grafana_version string null No Grafana version (e.g. 9.4, 10.4, 12.4); null = latest.
description string “Managed Grafana workspace…” No Workspace description.
vpc_configuration object null No Optional VPC config (security groups + subnets in ≥2 AZs).
saml_configuration object null No Optional SAML config (role values, IdP metadata, assertions).
role_associations map(object) {} No SSO user/group → Grafana role (ADMIN/EDITOR/VIEWER) mappings.
service_accounts map(object) {} No Service accounts + tokens for HTTP API automation.
tags map(string) {} No Additional tags merged onto the workspace and IAM role.

Outputs

Name Description
workspace_id Identifier of the Grafana workspace (g-xxxx).
workspace_arn ARN of the Grafana workspace.
endpoint Workspace endpoint URL (users and HTTP API).
grafana_version Grafana version running on the workspace.
role_arn IAM role ARN Grafana assumes to read AMP/CloudWatch/X-Ray.
service_account_ids Map of service account name → service account ID.
service_account_tokens Map of service account name → token key (sensitive).

Enterprise scenario

An observability team gives the whole engineering org a single pane of glass over metrics and traces. They deploy this module at v1.0.0 per environment: each workspace authenticates through AWS IAM Identity Center, exposes Prometheus (the AMP workspace from the companion module), CloudWatch, and X-Ray as data sources, and is backed by a least-privilege IAM role the module generates from the data_sources list — so dashboards never silently fail with “access denied,” and the role grants nothing beyond read access to the enabled sources. SSO groups map to Grafana roles via role_associations (platform admins get ADMIN, app teams get EDITOR, everyone else VIEWER), so access tracks directory group membership with no manual user management. A CI pipeline provisions golden dashboards through the Grafana HTTP API using a 7-day service-account token — the supported successor to the deprecated workspace API key — read from Secrets Manager at runtime. The result: dashboards, data-source IAM, and access control are all code, reviewed in PRs and identical across dev, staging, and prod.

Best practices

TerraformAWSManaged GrafanaModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading