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

**URL:** <https://community.dynatrace.com/t/managing-dynatrace-iam-as-code-with-vs-code-github-copilot-pull-requests-and-spacelift/57267>\
**Category:** Dynatrace Tips\
**Tags:** terraform, access-management, best-practices\
**Created:** [October 27, 2026, 2:22pm UTC](https://community.dynatrace.com/t/managing-dynatrace-iam-as-code-with-vs-code-github-copilot-pull-requests-and-spacelift/57267 "2026-10-27T14:22:07Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![Georgi\_V](https://dub1.discourse-cdn.com/dynatrace/user_avatar/community.dynatrace.com/georgi_v/32/15323_2.png) [@Georgi\_V](https://community.dynatrace.com/u/Georgi_V)\
**Post date:** [October 27, 2026, 2:22pm UTC](https://community.dynatrace.com/t/managing-dynatrace-iam-as-code-with-vs-code-github-copilot-pull-requests-and-spacelift/57267/1 "2026-10-27T14:22:07Z")

</div>

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

```auto
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

```hcl
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

```hcl
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 `$$`.

```hcl
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.

```hcl
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

```hcl
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.

```hcl
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:

```hcl
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

```bash
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.
