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.
- 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, andaccount-env-read. The last one is needed to refresh environment-scoped policies. Without it the plan fails with a 403 even though creation worked. - Create a Spacelift stack pointing at your repository, with the branch you merge to as the tracked branch.
- Set stack environment variables:
DT_CLIENT_ID,DT_CLIENT_SECRET(mark it secret),DT_ACCOUNT_ID, andTF_VAR_account_id(the same account ID). - 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_eachstructure 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
MATCHfor a list parameter and Dynatrace rejected it; the documented form isIN ("${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, andterraform testif 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:
- Spacelift posts the plan on the PR. Reviewers see exactly which bindings and policies change.
- Reviewers approve, and you merge.
- 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 usedocument:documents:*. Sharing usesdocument:environment-shares:*anddocument:direct-shares:*. automation:workflows:runcannot 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.