IaC AWS

Terraform Module: Amazon QuickSight — Account Subscription, Namespaces, Groups, Folders & Data Sources Without the Footguns

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for Amazon QuickSight: Enterprise account subscription, a QUICKSIGHT-backed namespace, RBAC groups, a shared folder, and an Athena/S3 data source with scoped permissions — production defaults baked in. 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 "quicksight" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-quicksight?ref=v1.0.0"

  account_name       = "..."  # Globally-unique QuickSight account name (shown at sign-in)…
  notification_email = "..."  # Email QuickSight uses for account/subscription notifications.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

Heads-up: aws_quicksight_account_subscription is account-wide and effectively one-time — there is one QuickSight subscription per AWS account. Treat the account-subscription part of this module as a foundational, rarely-changed stack, and read Best practices below before running destroy.

What this module is

Amazon QuickSight is AWS’s managed, serverless business-intelligence service: dashboards, paginated reports, and ML-powered insights over data in Athena, S3, Redshift, RDS, and more. Unlike most AWS services, QuickSight is account-scoped — you subscribe the account to an edition once, and everything else (namespaces, users, groups, folders, data sources, datasets, analyses, dashboards) lives under that single subscription.

That account-scoped model is exactly where the footguns hide. aws_quicksight_account_subscription is not idempotent the way a normal resource is: there is one subscription per account, the DescribeAccountSettings API does not return every argument, and importing or re-creating it can trigger a planned replacement — which, on a destroy, can tear down BI for the entire account. The remaining building blocks are individually simple but easy to get inconsistent: namespaces default to the QUICKSIGHT identity store but are silent about it, groups land in the wrong namespace, folders default to SHARED but are created without permissions, and data sources are routinely shipped with no permission block at all — leaving them invisible to the very analysts who need them.

This module wires the whole foundation together with safe defaults: an ENTERPRISE edition subscription with IAM_AND_QUICKSIGHT authentication, a QUICKSIGHT-backed namespace, a set of RBAC groups, a SHARED folder with explicit permissions, and an Athena (or S3) data source whose permission blocks grant exactly the right groups read/use access. The account-subscription resource is guarded with lifecycle settings and clear documentation so a stray terraform destroy does not nuke your analytics estate.

When to use it

Reach for a thinner setup — just aws_quicksight_user/aws_quicksight_group — when the account is already subscribed and you only need to manage access. Use this full module when you own the QuickSight foundation end to end. For embedded analytics in a customer-facing app, layer namespaces and capacity on top of what this module establishes.

Module structure

terraform-module-aws-quicksight/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # subscription, namespace, groups, folder, data source
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # subscription status, namespace arn, group/folder/data source ids

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}

locals {
  account_id = data.aws_caller_identity.current.account_id

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

  # ARN of a group, used to grant folder/data-source permissions.
  group_arn = { for g in var.groups : g => "arn:${data.aws_partition.current.partition}:quicksight:${data.aws_region.current.name}:${local.account_id}:group/${var.namespace}/${g}" }
}

# Account-wide, effectively one-time subscription. See best practices before
# destroying — replacement/deletion affects QuickSight for the whole account.
resource "aws_quicksight_account_subscription" "this" {
  count = var.create_account_subscription ? 1 : 0

  account_name          = var.account_name
  authentication_method = var.authentication_method
  edition               = var.edition
  notification_email    = var.notification_email

  # The Describe API does not return every argument, so suppress spurious diffs
  # that would otherwise plan a destructive replacement on the next apply.
  lifecycle {
    ignore_changes = [
      authentication_method,
    ]
  }
}

# A QUICKSIGHT-backed namespace isolates users/groups/assets for a tenant or
# environment. The default "default" namespace always exists; this adds another.
resource "aws_quicksight_namespace" "this" {
  count = var.create_namespace ? 1 : 0

  namespace      = var.namespace
  identity_store = var.identity_store

  tags = local.tags

  depends_on = [aws_quicksight_account_subscription.this]
}

# RBAC groups — manage BI access by group, never per-user.
resource "aws_quicksight_group" "this" {
  for_each = toset(var.groups)

  group_name  = each.value
  namespace   = var.namespace
  description = "Managed by terraform (${each.value})"

  depends_on = [aws_quicksight_namespace.this]
}

# A SHARED folder organizes dashboards/analyses and carries group permissions.
resource "aws_quicksight_folder" "this" {
  count = var.create_folder ? 1 : 0

  folder_id   = var.folder_id
  name        = var.folder_name
  folder_type = "SHARED"

  dynamic "permissions" {
    for_each = var.folder_reader_groups
    content {
      principal = local.group_arn[permissions.value]
      actions = [
        "quicksight:DescribeFolder",
        "quicksight:CreateFolderMembership",
        "quicksight:DeleteFolderMembership",
        "quicksight:DescribeFolderPermissions",
      ]
    }
  }

  tags = local.tags

  depends_on = [aws_quicksight_group.this]
}

# An Athena (or S3) data source feeding analyses/datasets, with scoped perms.
resource "aws_quicksight_data_source" "this" {
  count = var.create_data_source ? 1 : 0

  data_source_id = var.data_source_id
  name           = var.data_source_name
  type           = var.data_source_type

  parameters {
    dynamic "athena" {
      for_each = var.data_source_type == "ATHENA" ? [1] : []
      content {
        work_group = var.athena_work_group
      }
    }

    dynamic "s3" {
      for_each = var.data_source_type == "S3" ? [1] : []
      content {
        manifest_file_location {
          bucket = var.s3_manifest_bucket
          key    = var.s3_manifest_key
        }
      }
    }
  }

  dynamic "permission" {
    for_each = var.data_source_user_groups
    content {
      principal = local.group_arn[permission.value]
      actions = [
        "quicksight:DescribeDataSource",
        "quicksight:DescribeDataSourcePermissions",
        "quicksight:PassDataSource",
      ]
    }
  }

  tags = local.tags

  depends_on = [aws_quicksight_group.this]
}

variables.tf

variable "create_account_subscription" {
  description = "Whether to create the account-wide QuickSight subscription. Keep false if the account is already subscribed."
  type        = bool
  default     = true
}

variable "account_name" {
  description = "Globally-unique QuickSight account name (shown when users sign in)."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9]$", var.account_name))
    error_message = "account_name must be 2-63 chars: alphanumeric and hyphens, not starting/ending with a hyphen."
  }
}

variable "authentication_method" {
  description = "Authentication method: IAM_AND_QUICKSIGHT, IAM_ONLY, IAM_IDENTITY_CENTER, or ACTIVE_DIRECTORY."
  type        = string
  default     = "IAM_AND_QUICKSIGHT"

  validation {
    condition     = contains(["IAM_AND_QUICKSIGHT", "IAM_ONLY", "IAM_IDENTITY_CENTER", "ACTIVE_DIRECTORY"], var.authentication_method)
    error_message = "authentication_method must be IAM_AND_QUICKSIGHT, IAM_ONLY, IAM_IDENTITY_CENTER, or ACTIVE_DIRECTORY."
  }
}

variable "edition" {
  description = "QuickSight edition: STANDARD, ENTERPRISE, or ENTERPRISE_AND_Q."
  type        = string
  default     = "ENTERPRISE"

  validation {
    condition     = contains(["STANDARD", "ENTERPRISE", "ENTERPRISE_AND_Q"], var.edition)
    error_message = "edition must be STANDARD, ENTERPRISE, or ENTERPRISE_AND_Q."
  }
}

variable "notification_email" {
  description = "Email QuickSight uses for account/subscription notifications."
  type        = string

  validation {
    condition     = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.notification_email))
    error_message = "notification_email must be a valid email address."
  }
}

variable "create_namespace" {
  description = "Whether to create a non-default namespace."
  type        = bool
  default     = false
}

variable "namespace" {
  description = "Namespace for groups/folders/data sources. Use 'default' unless isolating a tenant/environment."
  type        = string
  default     = "default"
}

variable "identity_store" {
  description = "Namespace identity store. Currently only QUICKSIGHT is valid."
  type        = string
  default     = "QUICKSIGHT"

  validation {
    condition     = var.identity_store == "QUICKSIGHT"
    error_message = "identity_store must be QUICKSIGHT (the only supported value)."
  }
}

variable "groups" {
  description = "QuickSight group names to create for RBAC, e.g. ['bi-admins','bi-authors','bi-readers']."
  type        = list(string)
  default     = []
}

variable "create_folder" {
  description = "Whether to create a SHARED folder."
  type        = bool
  default     = false
}

variable "folder_id" {
  description = "Identifier for the SHARED folder."
  type        = string
  default     = null
}

variable "folder_name" {
  description = "Display name for the SHARED folder."
  type        = string
  default     = null
}

variable "folder_reader_groups" {
  description = "Group names (subset of var.groups) granted permissions on the folder."
  type        = list(string)
  default     = []
}

variable "create_data_source" {
  description = "Whether to create a data source."
  type        = bool
  default     = false
}

variable "data_source_id" {
  description = "Identifier for the data source."
  type        = string
  default     = null
}

variable "data_source_name" {
  description = "Display name for the data source (max 128 chars)."
  type        = string
  default     = null
}

variable "data_source_type" {
  description = "Data source type: ATHENA or S3."
  type        = string
  default     = "ATHENA"

  validation {
    condition     = contains(["ATHENA", "S3"], var.data_source_type)
    error_message = "data_source_type must be ATHENA or S3 (extend the module for other engines)."
  }
}

variable "athena_work_group" {
  description = "Athena workgroup to connect to (used when data_source_type = ATHENA)."
  type        = string
  default     = "primary"
}

variable "s3_manifest_bucket" {
  description = "S3 bucket holding the manifest file (used when data_source_type = S3)."
  type        = string
  default     = null
}

variable "s3_manifest_key" {
  description = "S3 key of the manifest file (used when data_source_type = S3)."
  type        = string
  default     = null
}

variable "data_source_user_groups" {
  description = "Group names (subset of var.groups) granted use/describe on the data source."
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags merged onto namespace, folder, and data source."
  type        = map(string)
  default     = {}
}

outputs.tf

output "account_subscription_status" {
  description = "Status of the QuickSight account subscription, or null when not managed here."
  value       = try(aws_quicksight_account_subscription.this[0].account_subscription_status, null)
}

output "namespace_arn" {
  description = "ARN of the created namespace, or null when not managed here."
  value       = try(aws_quicksight_namespace.this[0].arn, null)
}

output "group_arns" {
  description = "Map of group name => group ARN."
  value       = { for k, g in aws_quicksight_group.this : k => g.arn }
}

output "folder_arn" {
  description = "ARN of the SHARED folder, or null when not created."
  value       = try(aws_quicksight_folder.this[0].arn, null)
}

output "folder_id" {
  description = "ID of the SHARED folder, or null when not created."
  value       = try(aws_quicksight_folder.this[0].folder_id, null)
}

output "data_source_arn" {
  description = "ARN of the data source, or null when not created."
  value       = try(aws_quicksight_data_source.this[0].arn, null)
}

output "data_source_id" {
  description = "ID of the data source, or null when not created."
  value       = try(aws_quicksight_data_source.this[0].data_source_id, null)
}

How to use it

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

  # Account foundation (run once per AWS account).
  create_account_subscription = true
  account_name                = "kloudvin-analytics"
  authentication_method       = "IAM_AND_QUICKSIGHT"
  edition                     = "ENTERPRISE"
  notification_email          = "bi-ops@kloudvin.com"

  # RBAC groups in the default namespace.
  groups = ["bi-admins", "bi-authors", "bi-readers"]

  # A governed shared folder readers/authors can use.
  create_folder        = true
  folder_id            = "finance-dashboards"
  folder_name          = "Finance Dashboards"
  folder_reader_groups = ["bi-authors", "bi-readers"]

  # An Athena data source over the analytics workgroup, usable by authors.
  create_data_source      = true
  data_source_id          = "lakehouse-athena"
  data_source_name        = "Lakehouse (Athena)"
  data_source_type        = "ATHENA"
  athena_work_group       = aws_athena_workgroup.analytics.name
  data_source_user_groups = ["bi-admins", "bi-authors"]

  tags = {
    Environment = "prod"
    Team        = "data-platform"
    CostCenter  = "BI-1180"
  }
}

# Downstream: assign an existing IAM user into the readers group via membership.
resource "aws_quicksight_group_membership" "analyst" {
  group_name  = "bi-readers"
  member_name = "jane.doe"

  depends_on = [module.quicksight]
}

Pin the module with ?ref=<tag>. Keep create_account_subscription = false in every stack except the single platform-owned one that bootstraps the account — there is only one subscription per account, and managing it from multiple stacks invites a destructive replacement.

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/quicksight/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  account_name = "..."
  notification_email = "..."
}

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

cd live/prod/quicksight && 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
account_name string Yes Globally-unique QuickSight account name (shown at sign-in).
notification_email string Yes Email for account/subscription notifications.
create_account_subscription bool true No Create the account-wide subscription (false if already subscribed).
authentication_method string IAM_AND_QUICKSIGHT No IAM_AND_QUICKSIGHT, IAM_ONLY, IAM_IDENTITY_CENTER, or ACTIVE_DIRECTORY.
edition string ENTERPRISE No STANDARD, ENTERPRISE, or ENTERPRISE_AND_Q.
create_namespace bool false No Create a non-default namespace.
namespace string default No Namespace for groups/folders/data sources.
identity_store string QUICKSIGHT No Namespace identity store (QUICKSIGHT only).
groups list(string) [] No Group names to create for RBAC.
create_folder bool false No Create a SHARED folder.
folder_id string null No Identifier for the SHARED folder.
folder_name string null No Display name for the SHARED folder.
folder_reader_groups list(string) [] No Groups granted permissions on the folder.
create_data_source bool false No Create a data source.
data_source_id string null No Identifier for the data source.
data_source_name string null No Display name for the data source.
data_source_type string ATHENA No ATHENA or S3.
athena_work_group string primary No Athena workgroup (ATHENA type).
s3_manifest_bucket string null No S3 bucket holding the manifest (S3 type).
s3_manifest_key string null No S3 key of the manifest (S3 type).
data_source_user_groups list(string) [] No Groups granted use/describe on the data source.
tags map(string) {} No Tags merged onto namespace, folder, and data source.

Outputs

Name Description
account_subscription_status Status of the account subscription, or null.
namespace_arn ARN of the created namespace, or null.
group_arns Map of group name → group ARN.
folder_arn ARN of the SHARED folder, or null.
folder_id ID of the SHARED folder, or null.
data_source_arn ARN of the data source, or null.
data_source_id ID of the data source, or null.

Enterprise scenario

A finance organization standardizes BI on QuickSight Enterprise. The platform team owns a single Terraform stack that calls this module with create_account_subscription = true to subscribe the analytics account once, then defines bi-admins, bi-authors, and bi-readers groups, a Finance Dashboards SHARED folder, and a Lakehouse (Athena) data source pointed at the curated-data workgroup. Analysts are added to groups via aws_quicksight_group_membership in lightweight per-team stacks that set create_account_subscription = false, so no application repo can accidentally manage — or destroy — the account subscription. Because the data source ships with permission blocks granting only bi-admins and bi-authors use access, readers see published dashboards but cannot wire new analyses to raw lakehouse tables, and a quarterly access review reads cleanly off the module’s group_arns and data_source_arn outputs.

Best practices


Part of the KloudVin Terraform module library. Pair this with the Athena Workgroup, Glue Catalog, and IAM modules — they supply the workgroup, catalog, and identities this module grants QuickSight access to.

TerraformAWSQuickSightModuleIaC
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