Skip to content

microsoftexpert/tf-mod-aws-resource-groups

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS Resource Groups Terraform Module

Groups existing AWS resources for console visibility, tagging governance, and automation targeting — a classic tag/CloudFormation query group or an AppRegistry/service-configuration group, plus optional explicit resource attachments. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • 🗂️ Two ways to define a group, never both. A classic query-based group (resource_query — tag filters or a CloudFormation stack) or a service-configuration group (configuration — AppRegistry applications, generic groupings, Capacity Reservation pools, Network Firewall rule groups). The module enforces exactly one at plan time.
  • 📎 Optional explicit membership. resource_arns attaches specific resources to the group directly (aws_resourcegroups_resource) alongside — or instead of relying entirely on — a dynamic tag/CloudFormation query.
  • 🪶 Organizational, not data-plane. No encryption, no public-access surface, no network exposure — this module manages metadata about resources, never the resources themselves.
  • 🗺️ Map-driven configuration. Configuration items are map(object(...)) keyed by a stable caller string, each item's parameters collapsed to map(list(string)) — no redundant name fields.
  • 🏷️ Tags on the group only. var.tags flows to the group and merges with provider default_tags; aws_resourcegroups_resource membership records are not taggable (the AWS API has no tags for them).
  • 🌍 Regional service. No region variable, no us-east-1 constraint — group membership never crosses Regions.

💡 Why it matters: in a large, multi-account FI estate, "which resources back this application" is a governance question as much as a technical one — a clean group boundary is what lets Backup selections, Network Firewall rule groups, and SSM automations target the right blast radius without hand-maintained ARN lists drifting out of date.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

tf-mod-aws-resource-groups is a near-foundation, cross-cutting module — it takes ARNs from virtually any other module in the library and feeds its own arn into automation-targeting modules downstream.

flowchart LR
 EC2["tf-mod-aws-ec2-instance"]
 RDS["tf-mod-aws-rds"]
 S3["tf-mod-aws-s3-bucket"]
 LAMBDA["tf-mod-aws-lambda (Phase 7)"]
 CFN["tf-mod-aws-cloudformation (Phase 4)"]
 RG["tf-mod-aws-resource-groups"]
 NFW["tf-mod-aws-network-firewall (Phase 6)"]
 BACKUP["tf-mod-aws-backup"]
 SSM["tf-mod-aws-ssm (Phase 4)"]

 EC2 -- "resource_arns" --> RG
 RDS -- "resource_arns" --> RG
 S3 -- "resource_arns" --> RG
 LAMBDA -- "resource_arns" --> RG
 CFN -- "StackIdentifier (query)" --> RG
 RG -- "arn" --> NFW
 RG -- "arn" --> BACKUP
 RG -- "arn" --> SSM

 style RG fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
Loading

🧬 What this module builds

flowchart TB
 subgraph Module["tf-mod-aws-resource-groups"]
 GRP["aws_resourcegroups_group.this<br/>(keystone)"]
 RES["aws_resourcegroups_resource.this<br/>for_each var.resource_arns"]
 end

 RQ["var.resource_query<br/>(tag / CloudFormation query)"]
 CFG["var.configuration<br/>(AppRegistry / service config)"]
 ARN["var.resource_arns<br/>(explicit ARNs)"]

 RQ -. "XOR".-> GRP
 CFG -. "XOR".-> GRP
 ARN --> RES
 GRP -- "group_arn" --> RES

 style GRP fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
 style RES stroke-dasharray: 5 5
Loading
Resource Count Created when
aws_resourcegroups_group.this 1 always (keystone)
aws_resourcegroups_resource.this 0..N one per resource_arns entry

✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws >= 6.0, < 7.0

The module declares only a required_providers block (providers.tf) and inherits the configured provider. There is no provider {} block and no credential variable — credentials resolve through the standard AWS chain at the root/pipeline level (env vars → SSO/shared credentials → assume_role → instance profile / IRSA → OIDC web identity).


🔑 Required IAM Permissions

Least-privilege actions the Terraform execution identity needs to manage this module.

Action Required for Notes
resource-groups:CreateGroup, resource-groups:GetGroup, resource-groups:GetGroupQuery Group lifecycle / read-back
resource-groups:UpdateGroup, resource-groups:UpdateGroupQuery In-place update of description / resource_query
resource-groups:PutGroupConfiguration, resource-groups:GetGroupConfiguration Service-configuration (configuration-based) groups Only when configuration is set
resource-groups:DeleteGroup Destroy
resource-groups:Tag, resource-groups:Untag, resource-groups:GetTags Tag management
resource-groups:GroupResources, resource-groups:UngroupResources, resource-groups:ListGroupResources aws_resourcegroups_resource membership One per resource_arns entry
cloudformation:DescribeStacks Read-back when using CLOUDFORMATION_STACK_1_0 queries Only when resource_query.type = "CLOUDFORMATION_STACK_1_0"

No iam:PassRole is required — Resource Groups is a control-plane construct with no service role of its own, and there is no service-linked role to create.

🔒 Scope resource-groups:* actions to the group ARN pattern (arn:aws:resource-groups:<region>:<account>:group/casey-*) — a loosely-scoped grant lets a principal redefine which resources an automation (Backup selection, Network Firewall rule group, SSM target) applies to.


📋 AWS Prerequisites

  • No service-linked role is required for AWS Resource Groups.
  • Tag-based groups depend on an upstream tagging strategy. A TAG_FILTERS_1_0 query only surfaces resources that are actually tagged consistently — wire the same tag keys/values this module's query targets into the tags variable of the resources you expect to match.
  • configuration-based groups are a distinct path from resource_query. AppRegistry-style groups (AWS::AppRegistry::Application) are typically created for you when an AppRegistry application is registered; hand-authoring one here is mainly for AWS::ResourceGroups::Generic, AWS::EC2::CapacityReservationPool, or AWS::NetworkFirewall::RuleGroup configuration types.
  • Region: Resource Groups is regional — a group only contains resources in its own Region. No region variable; the module inherits the provider's Region.
  • Quotas: default 200 groups per account per Region (soft, raisable via Service Quotas).

📁 Module Structure

tf-mod-aws-resource-groups/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name → description → resource_query / configuration → resource_arns → tags → timeouts
├── main.tf # aws_resourcegroups_group.this + aws_resourcegroups_resource.this
├── outputs.tf # id + arn + name + resource_ids/resource_types maps + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas

⚙️ Quick Start

Smallest production-shaped call — a tag-based group with an explicit attachment:

module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-prod-app"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters = [
        { Key = "Environment", Values = ["prod"] },
        { Key = "Application", Values = ["core-banking"] }
      ]
    })
  }

  resource_arns = [
    module.rds.arn,          # from tf-mod-aws-rds
    module.ec2_instance.arn, # from tf-mod-aws-ec2-instance
  ]

  tags = {
    Environment = "prod"
    DataClass   = "confidential"
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_arns list(string) (any ARN-addressable resource) Any module — tf-mod-aws-ec2-instance, tf-mod-aws-rds, tf-mod-aws-s3-bucket, tf-mod-aws-lambda, etc.
resource_query.query (embedded StackIdentifier) string (CloudFormation stack ARN) tf-mod-aws-cloudformation (Phase 4)

Emits

Output Description Consumed by
id Group id (the group name) aws_resourcegroups_resource.group_arn (accepts name or ARN), CLI/console references
arn Group ARN arn:aws:resource-groups:<region>:<account>:group/<name> — the cross-resource reference type tf-mod-aws-network-firewall stateful rule groups, tf-mod-aws-backup selections targeting a group, IAM policies scoping access to this group
name Group name Console/CLI, tagging/reporting dashboards
resource_ids Map of membership record ids (group_arn,resource_arn) keyed by resource_arn Import/diagnostics
resource_types Map of AWS resource type strings (e.g. AWS::EC2::Instance) keyed by resource_arn Inventory/reporting tooling
tags_all All tags incl. provider default_tags (resource tags win) Governance/audit

📚 Example Library

1 · Minimal tag-based group
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-landing"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "Project", Values = ["landing-zone"] }]
    })
  }
}
2 · CloudFormation stack-based group
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-stack-group"

  resource_query = {
    type = "CLOUDFORMATION_STACK_1_0"
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      StackIdentifier     = "arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/abc123"
    })
  }
}
3 · Tags (merge with provider default_tags)
# Caller's provider block owns default_tags; the module never sets it.
provider "aws" {
  default_tags { tags = { Owner = "platform", ManagedBy = "terraform" } }
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-tagged"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "Environment", Values = ["prod"] }]
    })
  }

  tags = {
    Environment = "prod" # resource tag — wins over default_tags on key conflict
    DataClass   = "confidential"
  }
}

# module.rg.tags_all == { Owner, ManagedBy, Environment, DataClass }
4 · Secure-by-default opt-out — n/a (no data-plane defaults to loosen)
# Resource Groups has no encryption / public-access / logging posture to opt out
# of — it is a control-plane construct. The one non-negotiable the module
# enforces is the resource_query / configuration XOR, which mirrors the AWS
# API's own constraint rather than an secure-default choice, so there is
# no opt-out variable for it. This example instead shows the module's only
# real "loosening" lever: explicit resource_arns membership bypasses the
# tag/CloudFormation query discovery mechanism for a specific resource.
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-explicit-only"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "Owner", Values = ["never-matches"] }]
    })
  }

  resource_arns = [
    module.s3_bucket.arn, # attached explicitly regardless of tag drift
  ]
}
5 · Generic service-configuration group
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-generic-config"

  configuration = {
    generic = {
      type = "AWS::ResourceGroups::Generic"
      parameters = {
        AllowedResourceTypes = ["AWS::EC2::Instance", "AWS::RDS::DBInstance"]
      }
    }
  }
}
6 · AppRegistry application group
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-appregistry"

  configuration = {
    application = {
      type = "AWS::AppRegistry::Application"
    }
  }
}
7 · Network Firewall rule-group configuration
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-nfw-rulegroup"

  configuration = {
    nfw = {
      type = "AWS::NetworkFirewall::RuleGroup"
    }
  }

  resource_arns = [
    module.security_group.arn, # from tf-mod-aws-security-group, kept in sync with the rule group
  ]
}
8 · Explicit resource membership only (no query, config-driven)
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-explicit-inventory"

  configuration = {
    generic = { type = "AWS::ResourceGroups::Generic" }
  }

  resource_arns = [
    module.ec2_instance_a.arn,
    module.ec2_instance_b.arn,
    module.rds.arn,
  ]
}
9 · Custom timeouts
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-slow-account"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "Environment", Values = ["prod"] }]
    })
  }

  timeouts = {
    create = "30m"
    update = "30m"
    delete = "10m"
  }
}
10 · for_each pattern — one group per business unit
locals {
  business_units = {
    retail    = "Retail"
    ag_credit = "AgCredit"
    wholesale = "Wholesale"
  }
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
  for_each = local.business_units

  name = "casey-${each.key}"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "BusinessUnit", Values = [each.value] }]
    })
  }

  tags = { BusinessUnit = each.value }
}
11 · import block — adopt an existing group
import {
  to = module.rg.aws_resourcegroups_group.this
  id = "casey-existing-group"
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-existing-group"

  resource_query = {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      TagFilters          = [{ Key = "Environment", Values = ["prod"] }]
    })
  }
}
12 · Multiple configuration items on one group
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-multi-config"

  configuration = {
    generic = {
      type       = "AWS::ResourceGroups::Generic"
      parameters = { AllowedResourceTypes = ["AWS::AllSupported"] }
    }
    application = {
      type = "AWS::AppRegistry::ApplicationResourceGroup"
    }
  }
}
13 · End-to-end composition — CloudFormation stack group feeding Backup + Network Firewall
# The application's CloudFormation stack (managed elsewhere / imported)
data "aws_cloudformation_stack" "app" {
  name = "core-banking-app"
}

# Resource group scoped to that stack's resources
module "rg" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"

  name = "casey-core-banking"

  resource_query = {
    type = "CLOUDFORMATION_STACK_1_0"
    query = jsonencode({
      ResourceTypeFilters = ["AWS::AllSupported"]
      StackIdentifier     = data.aws_cloudformation_stack.app.id
    })
  }

  tags = { Environment = "prod", Application = "core-banking" }
}

# Backup selection targeting the group's resources
module "backup" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-backup?ref=v1.0.0"

  name         = "casey-core-banking-backup"
  iam_role_arn = module.backup_role.arn

  plans = { daily = { rules = [{ rule_name = "daily", schedule = "cron(0 5 ? * * *)" }] } }

  selections = {
    core_banking = {
      plan_key  = "daily"
      resources = ["*"]
      conditions = {
        string_equals = [{ key = "aws:ResourceTag/Application", value = "core-banking" }]
      }
    }
  }
}

# Network Firewall stateful rule group kept in sync with the same group
module "network_firewall" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-network-firewall?ref=v1.0.0"
  #... references module.rg.arn in a stateful rule group referencing the group
}

📥 Inputs

  • Core: name (required, FORCE-NEW, keystone), description (optional).
  • Group definition (mutually exclusive): resource_query (object({ query, type }), tag/CloudFormation queries), configuration (map(object({ type, parameters })), AppRegistry/service-configuration items).
  • Membership: resource_arns (list(string), explicit aws_resourcegroups_resource attachments).
  • Tagging: tags (map(string), default {}).
  • Timeouts: timeouts (object({ create, update, delete }), default {} — only the fields each resource supports are applied).

See variables.tf for full heredoc schemas and validation rules.


🧾 Outputs

  • id — the resource group id (the group name).
  • arn — the resource group ARN (cross-resource reference type).
  • name — the resource group name.
  • resource_ids — map of aws_resourcegroups_resource membership record ids keyed by resource_arn.
  • resource_types — map of AWS resource type strings keyed by resource_arn.
  • tags_all — all tags including those inherited from provider default_tags.

No output is sensitive = true — this module carries no secret-bearing attributes.


🧠 Architecture Notes

  • ARN format: arn:aws:resource-groups:<region>:<account-id>:group/<name>. ID format: the group id is the group name. Membership record id is a comma-delimited composite: group_arn,resource_arn — this is the format terraform import expects.
  • Force-new fields: name on the group forces replacement — renaming destroys and recreates the group (tag-based membership is re-evaluated against the new group; explicit aws_resourcegroups_resource attachments are recreated against it).
  • tagstags_alldefault_tags: var.tags is applied to the group only. tags_all is the provider-computed merge of resource tags over provider default_tags, with resource tags winning on key conflict. default_tags is the caller's provider-block concern — never set inside this module. aws_resourcegroups_resource membership records are not taggable.
  • Eventual consistency: a TAG_FILTERS_1_0 group's membership is evaluated live by the Resource Groups service at read time — newly tagged resources can take a short time to appear in ListGroupResources/console views; this is not a Terraform-managed state and causes no plan drift.
  • Destroy ordering: Terraform's dependency graph removes aws_resourcegroups_resource records (which reference group_arn) before the group itself — no manual depends_on is needed.
  • resource_query vs configuration is enforced at plan time, not just documented: var.configuration's validation block asserts (var.resource_query != null) != (length(var.configuration) > 0), mirroring the AWS API's own mutually-exclusive constraint. The provider's markdown docs page marks resource_query as "(Required)" — this is stale relative to the live v6.54.0 schema (terraform providers schema -json shows it as an optional list block, max_items = 1), which is what actually permits configuration-only groups.
  • us-east-1 globals: N/A. AWS Resource Groups is a regional service — there is no us-east-1 constraint and no region variable.

🧱 Design Principles

Secure-by-default posture and every opt-out, explicitly:

Posture Default Opt-out
Group definition exactly one of resource_query / configuration enforced at plan time n/a — non-negotiable (matches the AWS API constraint, not a module-specific tightening)
Explicit membership opt-in via resource_arns (empty list = query-driven membership only) supply resource_arns to add explicit attachments

Resource Groups has no encryption-at-rest, public-access, or network-exposure surface — the real "secure by default" concern is least-privilege IAM on who can redefine group membership, since a loosely-scoped resource-groups:* grant is a control-plane privilege-escalation vector for any downstream automation (Backup, Network Firewall, SSM) that targets resources by group. Scope the actions in 🔑 Required IAM Permissions to the group ARN pattern in your account.

Other principles:

  • One composite, one keystone. The group owns only what is meaningless without it (explicit membership records). The actual protected/organized resources are referenced by ARN, never created here.
  • for_each, never count, for the membership child collection — keyed on the ARN itself (toset(var.resource_arns)), since each membership record carries no metadata beyond group_arn + resource_arn.
  • configuration as map(object({...})) keyed by a caller-supplied stable string, with parameters flattened to map(list(string)) — avoids a redundant name field per our block-mirroring-without-needless-repetition principle.
  • Primary outputs id + arn, plus name, the membership maps, and tags_all.

🚀 Runbook

# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check

plan / apply require valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain and a configured Region. There is no destructive state here beyond the group and its membership records — no VPC/ENI/NAT-style destroy ordering to worry about.

⚠️ Always pin the module source with ?ref=v1.0.0 — never a branch — so a caller's plan doesn't silently pick up unreviewed changes.


🧪 Testing

  • terraform init -backend=false && terraform validate — schema + reference integrity, including the resource_query/configuration XOR validation.
  • terraform fmt -check — canonical formatting.
  • terraform plan against a sandbox account to confirm the group and any resource_arns memberships materialize as expected.
  • Assert module.<name>.arn, resource_ids, resource_types, and tags_all in your root-module test harness.
  • Test both branches of the XOR validation deliberately (a resource_query-only call and a configuration-only call) to confirm neither regresses to accepting both or neither.

💬 Example Output

module.rg.aws_resourcegroups_group.this: Creation complete after 2s [id=casey-prod-app]
module.rg.aws_resourcegroups_resource.this["arn:aws:rds:us-east-1:123456789012:db:core-db"]: Creation complete after 1s

Outputs:
arn = "arn:aws:resource-groups:us-east-1:123456789012:group/casey-prod-app"
id = "casey-prod-app"
resource_ids = { "arn:aws:rds:us-east-1:123456789012:db:core-db" = "arn:aws:resource-groups:us-east-1:123456789012:group/casey-prod-app,arn:aws:rds:us-east-1:123456789012:db:core-db" }
resource_types = { "arn:aws:rds:us-east-1:123456789012:db:core-db" = "AWS::RDS::DBInstance" }
tags_all = { "DataClass" = "confidential", "Environment" = "prod" }

🔍 Troubleshooting

Symptom Likely cause Fix
Exactly one of resource_query or configuration must be supplied (validation) Both or neither were set Set exactly one — resource_query for tag/CloudFormation groups, configuration for AppRegistry/service-configuration groups
Group shows zero matching resources Tag keys/values in resource_query don't match what's actually applied upstream Confirm the tagged resources' tags variable matches the query's TagFilters exactly (case-sensitive)
resource_query.query must be a valid JSON-encoded string (validation) Hand-built JSON string with a syntax error Build the query with jsonencode rather than a raw string literal
InvalidParameterException on CLOUDFORMATION_STACK_1_0 query StackIdentifier is not a full stack ARN, or the stack doesn't exist in this Region Use the stack's ARN (not just its name); confirm the stack is in the same Region as the group
AccessDeniedException on group create/update Terraform identity missing a resource-groups:* action Add the specific action from 🔑 Required IAM Permissions, least-privilege scoped to the group ARN pattern
PutGroupConfiguration fails for an AppRegistry configuration type The referenced AppRegistry application doesn't exist yet, or the type string is misspelled Create/verify the AppRegistry application first; AWS may add new configuration types over time — this field is intentionally not a closed enum
Tag drift on every plan A tag also set by provider default_tags with a different value Let resource tags win, or remove the overlap from default_tags
terraform import fails on aws_resourcegroups_resource Wrong id format Use the comma-delimited group_arn,resource_arn format, not a single ARN
Credential-chain failure (NoCredentialProviders / ExpiredToken) No valid AWS credentials resolved by the provider chain Confirm AWS_PROFILE / SSO session / OIDC role is active; this module has no credential variables of its own

🔗 Related Docs


🧡 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages