Managing Dynatrace IAM as code with VS Code, GitHub Copilot, pull requests and Spacelift

Access control in Dynatrace gets complicated quickly: groups, policies, bindings, environments and data-scoping parameters. This post shows a workflow that keeps it reviewable and repeatable. You describe access in Terraform, work on it in VS Code with GitHub Copilot, review it in a pull request, and let a Spacelift stack apply it. Nobody applies changes from a laptop.

All names, IDs and groups below are placeholders.

The workflow at a glance

VS Code + Copilot  ->  branch + commit  ->  Pull request  ->  Spacelift plan on PR
                                                                    |
                                          merge to main  ->  Spacelift apply  ->  Dynatrace
Step Tool What it gives you
Author VS Code + GitHub Copilot (agent mode) Fast drafting and refactoring, plus a way to explore the live tenant read-only
Review GitHub pull request A reviewable diff of every permission change
Plan Spacelift (proposed run) A Terraform plan attached to the PR before anything changes
Apply Spacelift (tracked run on merge) A single, audited path to production

1. The IAMv2 model in Terraform

Dynatrace IAMv2 has four building blocks. Each has a Terraform counterpart in the dynatrace-oss/dynatrace provider.

IAMv2 concept Terraform
Group (usually synced from your identity provider with SCIM) data "dynatrace_iam_groups" (lookup by name)
Policy (ALLOW ... statements) resource "dynatrace_iam_policy"
Policy binding (group + policy, at account or environment level) resource "dynatrace_iam_policy_bindings_v2"
Binding parameter (one policy reused with different values) parameters block inside the binding

The idea behind the design: SCIM groups say who a person is, policies say what they may do, and a binding parameter says which data. A single reusable policy can serve many teams, because the team value is passed in at binding time.

2. Sample Terraform

Provider

terraform {
  required_version = ">= 1.5.7"

  required_providers {
    dynatrace = {
      source  = "dynatrace-oss/dynatrace"
      version = "~> 1.100"
    }
  }
}

# Credentials come from environment variables set on the Spacelift stack:
#   DT_CLIENT_ID, DT_CLIENT_SECRET, DT_ACCOUNT_ID
provider "dynatrace" {}

variable "account_id" {
  type        = string
  description = "Dynatrace account UUID (needed for account-scoped policies)"
}

variable "environment_id" {
  type        = string
  description = "Dynatrace environment ID, e.g. abc12345"
}

A persona policy

resource "dynatrace_iam_policy" "app_users" {
  name        = "app-users-read"
  description = "Run apps and read dashboards"
  environment = var.environment_id

  statement_query = <<-EOT
    ALLOW settings:objects:read, settings:schemas:read;
    ALLOW app-engine:apps:run;
    ALLOW document:documents:read;
    ALLOW davis:analyzers:read, davis:analyzers:execute;
  EOT
}

A data-scoping policy with a parameter

The ${bindParam:...} placeholder is replaced by Dynatrace when the policy is evaluated. In Terraform, escape the dollar sign as $$.

resource "dynatrace_iam_policy" "scoped_read" {
  name        = "security-context-read"
  description = "Read Grail data for the bound security context"
  environment = var.environment_id

  statement_query = <<-EOT
    ALLOW storage:buckets:read;
    ALLOW storage:logs:read, storage:metrics:read, storage:events:read,
          storage:spans:read, storage:entities:read, storage:bizevents:read
    WHERE storage:dt.security_context IN ("$${bindParam:security_context}");
  EOT
}

Binding groups to policies

Groups arrive from your identity provider, so look them up by name rather than creating them.

data "dynatrace_iam_groups" "all" {}

locals {
  group_ids = { for id, name in data.dynatrace_iam_groups.all.groups : name => id }
}

resource "dynatrace_iam_policy_bindings_v2" "app_users" {
  group       = local.group_ids["example-app-users"]
  environment = var.environment_id

  policy {
    id = dynatrace_iam_policy.app_users.id
  }
}

# Same policy, a different value per team
resource "dynatrace_iam_policy_bindings_v2" "team_a" {
  group       = local.group_ids["example-team-a"]
  environment = var.environment_id

  policy {
    id         = dynatrace_iam_policy.scoped_read.id
    parameters = { security_context = "TEAM_A" }
  }
}

resource "dynatrace_iam_policy_bindings_v2" "team_b" {
  group       = local.group_ids["example-team-b"]
  environment = var.environment_id

  policy {
    id         = dynatrace_iam_policy.scoped_read.id
    parameters = { security_context = "TEAM_B" }
  }
}

Built-in policies at account level

data "dynatrace_iam_policy" "admin_user" {
  name = "Admin User"
}

resource "dynatrace_iam_policy_bindings_v2" "admins" {
  group   = local.group_ids["example-platform-admins"]
  account = var.account_id

  policy {
    id = data.dynatrace_iam_policy.admin_user.id
  }
}

Drive it from data

As the number of groups grows, move the definitions into variables and loop over them. Adding a team then becomes a small data change instead of new resources.

variable "scope_bindings" {
  type = map(object({
    group_name       = string
    security_context = string
  }))
  default = {
    team-a = { group_name = "example-team-a", security_context = "TEAM_A" }
    team-b = { group_name = "example-team-b", security_context = "TEAM_B" }
  }
}

resource "dynatrace_iam_policy_bindings_v2" "scope" {
  for_each    = var.scope_bindings
  group       = local.group_ids[each.value.group_name]
  environment = var.environment_id

  policy {
    id         = dynatrace_iam_policy.scoped_read.id
    parameters = { security_context = each.value.security_context }
  }
}

3. The Spacelift stack

The stack is the only thing that talks to Dynatrace with write access.

  1. Create an OAuth client for IAM in your Dynatrace account. Give it the scopes it needs to manage policies and bindings, for example iam:policies:write, iam:bindings:write, account-idm-read, and account-env-read. The last one is needed to refresh environment-scoped policies. Without it the plan fails with a 403 even though creation worked.
  2. Create a Spacelift stack pointing at your repository, with the branch you merge to as the tracked branch.
  3. Set stack environment variables: DT_CLIENT_ID, DT_CLIENT_SECRET (mark it secret), DT_ACCOUNT_ID, and TF_VAR_account_id (the same account ID).
  4. Turn on pull request previews. Spacelift runs a proposed run on every PR and posts the plan as a check. Merging triggers a tracked run, which applies.

You can also define the stack itself in Terraform with the Spacelift provider:

resource "spacelift_stack" "dynatrace_iam" {
  name              = "dynatrace-iam"
  repository        = "your-iac-repo"
  branch            = "main"
  project_root      = "."
  terraform_version = "1.9.0"
  autodeploy        = true
  description       = "Dynatrace IAMv2 policies and bindings"
}

resource "spacelift_environment_variable" "client_id" {
  stack_id   = spacelift_stack.dynatrace_iam.id
  name       = "DT_CLIENT_ID"
  value      = var.dt_client_id
  write_only = true
}

Keep secrets out of the repository. Set them in Spacelift, or read them from a secret manager.

4. Working in VS Code with GitHub Copilot

I used Copilot in agent mode with Claude as the model. Any capable model works; pick one that is good at code and tool use.

Where it helped most:

  • Drafting and refactoring. Turning a table of groups and permissions into the for_each structure above, and keeping several environments in sync.
  • Explaining errors. Pasting a Spacelift failure into chat and getting a likely cause. For example, my first attempt used MATCH for a list parameter and Dynatrace rejected it; the documented form is IN ("${bindParam:name}").
  • Read-only checks against the live tenant. I had Copilot write a small script that calls the account IAM API and compares live policies and bindings with what Terraform declares. That found no drift and highlighted missing permissions.
  • Writing the PR description and documentation.

Some practical habits:

  • Give it rules. A short instructions file in the repo (for example “never apply from the terminal; always open a PR”) keeps the assistant on your process.
  • Validate locally before pushing. terraform fmt -check, terraform validate, and terraform test if you have unit tests. A local plan against an empty state shows everything as “to add” when state lives in Spacelift; use it only as a sanity check.
  • Review what it writes. Treat generated code as a draft. Check permission names against the Dynatrace IAM reference, because the assistant can produce plausible permissions that do not exist.
  • Never paste secrets into chat or commit them.

5. The pull request loop

git switch -c add-team-c-scope
# edit variables / tfvars with Copilot
terraform fmt && terraform validate
git commit -am "Add team C data scope"
git push -u origin add-team-c-scope
gh pr create --fill

Then:

  1. Spacelift posts the plan on the PR. Reviewers see exactly which bindings and policies change.
  2. Reviewers approve, and you merge.
  3. Spacelift applies. If the apply fails, fix forward with a new PR, so the history shows what happened.

6. Lessons learned

  • Wildcard permissions are invalid. ALLOW *; is rejected. List permissions explicitly.
  • There is no problem: permission service, and dashboards and notebooks both use document:documents:*. Sharing uses document:environment-shares:* and document:direct-shares:*.
  • automation:workflows:run cannot be limited by workflow name or category, only by workflow type, so give it to a dedicated group.
  • Parameterised bindings only work through the API or Terraform, not in the Group Management UI.
  • List parameters need IN (...), with multiple values passed as one comma-separated string.
  • Do not let Terraform manage groups that SCIM owns. Look them up by name instead.
  • If a PR merges before a fix lands, follow up with a second PR. Do not apply by hand.

Wrap-up

Treating IAM as code gives you a review trail, repeatable environments and a single apply path. Copilot speeds up the authoring and the investigation, the pull request keeps people in the loop, and Spacelift enforces that nothing reaches Dynatrace without a plan and a merge.

If you are doing something similar, I would be interested to hear how you structure your groups and parameters.

4 Likes