Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

📊 Grafana Folder Terraform Module

Manages a Grafana folder and its full permission set — one grafana_folder_permission_item resource per principal — as one composite unit. Targets grafana/grafana (~> 4.0).


Badges

Terraform Provider Module Version Module Type Resources Posture


🧩 Overview

  • 📁 Creates and manages a grafana_folder — title, optional custom uid, org scope, optional nested- folder parent, and optional prevent_destroy_if_not_empty guard.
  • 🔐 Renders zero or more grafana_folder_permission_item bindings, for_each-keyed by a caller-chosen stable principal key — one Terraform resource per principal, never a single whole-list resource.
  • 🧭 Deliberately does not use grafana_folder_permission (the whole-list alternative that re-renders the entire permission set, and removes any binding absent from that render, on every apply) — see §6 Schema notes that bite.
  • 🆔 Emits uid as the primary output — the identity every sibling module placing content in this folder (terraform-grafana-dashboard, terraform-grafana-alerting-rule-group, terraform-grafana-synthetic-monitoring- check) and a subsequent nested-folder instance of this same module consume by reference.

💡 Why it matters: a folder's access boundary is a security-relevant property in a regulated environment. Managing the folder and its permission bindings as one composite unit means a caller who owns a folder's lifecycle also owns exactly who can access it — and adding or removing a single principal's binding never disturbs any other binding's state, unlike the whole-list alternative.


❤️ 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

flowchart TD
 team["terraform-grafana-team\n(team principal)"]:::sibling
 user["terraform-grafana-user\n(user principal)"]:::sibling
 svcacct["terraform-grafana-service-account\n(service account principal)"]:::sibling
 folder["terraform-grafana-folder"]:::this
 parentfolder["terraform-grafana-folder\n(parent instance, nested folders)"]:::sibling
 dashboard["terraform-grafana-dashboard"]:::sibling
 rulegroup["terraform-grafana-alerting-rule-group"]:::sibling
 smcheck["terraform-grafana-synthetic-monitoring-check"]:::sibling

 team -- "team_id (permission principal)" --> folder
 user -- "user_id (permission principal)" --> folder
 svcacct -- "id (permission principal)" --> folder
 parentfolder -- "uid (parent_folder_uid)" --> folder
 folder -- "uid (folder_uid)" --> dashboard
 folder -- "uid (folder_uid)" --> rulegroup
 folder -- "uid (folder_uid)" --> smcheck

 classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
 classDef sibling fill:#E8E8E8,color:#111217,stroke:#8A8A8A;
Loading

Validated via the Mermaid Chart MCP (valid: true) before embedding. terraform-grafana-team, terraform-grafana-user, and terraform-grafana-service-account feed permission-binding principal identities into this module; a separately-applied instance of this same module feeds parent_folder_uid for nested folders; this module's own uid output feeds three content-layer siblings that place dashboards, alert rule groups, and Synthetic Monitoring checks inside the folder.


🧬 What this builds

flowchart LR
 subgraph inputs["Inputs"]
 varfolder["var.folder\n(object)"]
 varperms["var.permissions\n(map(object), default {})"]
 end

 keystone["grafana_folder.this\n(keystone)"]:::this
 bindings["grafana_folder_permission_item.bindings\n(for_each, keyed map)"]:::this

 varfolder -- "title, uid, org_id, parent_folder_uid, prevent_destroy_if_not_empty" --> keystone
 varperms -- "permission, role, team, user, org_id (per key)" --> bindings
 keystone -- "uid (folder_uid)" --> bindings

 keystone --> outuid["uid\n(primary_output)"]
 keystone --> outid["id"]
 keystone --> outtitle["title"]
 keystone --> outurl["url"]
 bindings --> outbindings["permission_bindings\n(map, per key)"]

 classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
Loading

Validated via the Mermaid Chart MCP (valid: true) before embedding.

Resource inventory:

Resource Cardinality Role
grafana_folder.this 1 (keystone) The folder itself: title, optional custom uid, org scope, optional parent_folder_uid (nested folders), optional prevent_destroy_if_not_empty guard
grafana_folder_permission_item.bindings 0..N (for_each, keyed by var.permissions map key) One resource per principal binding — role, team, or user, each with its own permission level

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
grafana/grafana provider ~> 4.0 (confirmed latest at authoring time: 4.40.1, installed and locked during this session's terraform init)
Provider block None — the caller configures provider "grafana" {... } (with whichever alias this module's auth mechanism requires) at the root module and passes it in via providers = { grafana =... }

Schema notes that bite:

  • uid is listed as Optional in the live v4.40.1 schema, with no separate "Computed"/"Force new" annotation surfaced in the rendered docs page. This suite's accumulated Known schema gotchas record (cross-checked during prior authoring) confirms: when omitted, Grafana generates a uid server-side; once a uid exists (caller-set or generated), changing it forces replacement of the folder — it is this resource's stable identity everywhere else in the API and in every sibling module's folder_uid consumption. Treat as immutable-after-set.
  • parent_folder_uid requires the nestedFolders feature flag enabled on the target Grafana instance — the live schema's own field description states this explicitly. See §8 Grafana Prerequisites.
  • prevent_destroy_if_not_empty requires Grafana 10.2 or later per the live schema's own field description; defaults to false. See §8 Grafana Prerequisites.
  • grafana_folder.id is a String in the live schema ("The ID of this resource"), not a legacy numeric ID as earlier module planning assumed — this module's id output reflects that String type verbatim and is documented as informational-only; prefer uid for all cross-module wiring.
  • This module uses grafana_folder_permission_item (one resource per principal), not grafana_folder_permission (a single resource holding the folder's entire permission set as a repeating permissions block). The provider's own docs state the whole-list resource "manages the entire set of permissions for a folder. Permissions that aren't specified when applying this resource will be removed" — it does not compose safely across independent for_each iterations or independent callers adding principals over time. _item avoids this: each principal binding gets its own resource identity and lifecycle, and the two resources are documented by the provider as conflicting if both target the same folder.
  • Each grafana_folder_permission_item requires folder_uid and permission, plus exactly one of role / team / user — the live schema only enforces this "conflicts with" constraint at apply time; this module's variables.tf enforces it at terraform plan time instead (see §12 example 9).
  • Unresolved identity-type ambiguity for team: the provider's own example usage for grafana_folder_permission_item passes team = grafana_team.team.id (the team resource's own id attribute), not team_uid. Both grafana_folder_permission and grafana_folder_permission_item link to the legacy folder-permissions HTTP API in their own docs (api-legacy/folder_permissions/), which is evidence — not a schema-confirmed fact — that the team field expects the string form of the team's legacy numeric ID (tostring(team_id)), not the newer team_uid. Verify with a live plan against a real team before depending on this in production; see §12 example 6 and §15 Architecture Notes.
  • grafana_folder.id and grafana_team.id / grafana_user.id are all separately-documented id attributes on their respective resources — none of this library's sibling modules currently expose a plain id-named output for team, so this module's README recommends tostring(team_id) / tostring(user_id) / the service account's own id output as the practical wiring path (see §11).

🔑 Required Grafana Auth & Scope

Grafana core auth (see this suite's Authentication model, "Grafana core auth" table) — the auth provider argument (Service Account token). least-privilege convention for this module is an Editor/Admin-scoped Service Account token: Editor is sufficient to create and rename the folder itself; Admin is required if this module's permission bindings grant Admin-level access to any other principal, since a token cannot grant a permission level higher than its own. Choose the narrower Editor scope when this module's calling root module only ever grants Viewer/Editor bindings; step up to Admin only when an Admin-level binding is actually rendered. Configured once by the caller on whichever aliased grafana provider instance is passed into this module via providers = { grafana =... }; this module declares no alias or credential of its own.

Grafana Prerequisites

  • parent_folder_uid requires the nestedFolders feature flag enabled on the target Grafana instance. Only relevant when the caller sets this field — treat as feature-gated, not universally available.
  • prevent_destroy_if_not_empty requires Grafana 10.2 or later. Version-gate this field's use; do not assume every target instance is new enough to support it.
  • Folder identity (uid) is scoped per org/stack, not global — confirm the target org/stack context (e.g. via an org_id-scoping convention, where the calling root module wires one from terraform-grafana-organization) before applying.

📁 Module Structure

terraform-grafana-folder/
├── providers.tf # required_providers: grafana/grafana ~> 4.0, required_version >= 1.12.0. No provider {} block.
├── variables.tf # var.folder (keystone object) + var.permissions (for_each-keyed permission bindings map)
├── main.tf # grafana_folder.this (keystone) + grafana_folder_permission_item.bindings (for_each)
├── outputs.tf # uid (primary), id, title, url, permission_bindings
├── README.md # this file
├── SCOPE.md # cross-module contract, auth/scope, prerequisites, provider gotchas
└── examples/ # runnable example(s)

⚙️ Quick Start

module "platform_ops_folder" {
  source = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"

  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Platform Ops"
  }
}

The caller configures the grafana provider (with the alias this module's Editor/Admin-scoped Service Account token auth requires) once at the root module — this module never accepts its own url, auth, or org_id default.


🔌 Cross-Module Contract

Consumes

Input Type Source module
team (permission principal) string terraform-grafana-team — recommend tostring(team_id); see §6 Schema notes that bite for the unresolved team_id vs. team_uid ambiguity
user (permission principal) string terraform-grafana-user's user_id output (tostring(...), since it is a Number)
user (permission principal, service account) string terraform-grafana-service-account's id output (already a string)
parent_folder_uid (optional, nested folders) string terraform-grafana-folder (this module's own uid output, from a separately-applied parent instance)

Emits

Output Description Consumed by
uid Primary output. Folder's stable UID terraform-grafana-dashboard (folder_uid input), terraform-grafana-alerting-rule-group (folder_uid input), terraform-grafana-synthetic-monitoring-check (folder_uid input, where applicable), a subsequent instance of this module itself (as parent_folder_uid)
id The provider's own resource id (String) Informational only — prefer uid for all cross-module wiring
title Folder display title Informational
url Full URL of the folder in the Grafana UI Informational / linking
permission_bindings Map, keyed like var.permissions, of each binding's provider-assigned id Informational / drift-detection only

📚 Example Library

1 · Minimal folder
module "folder_minimal" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Observability"
  }
}

💡 The empty-call default: no explicit uid (Grafana generates one), no permission bindings beyond Grafana's own default folder ACL, no nesting, no destroy guard.

2 · Folder with an explicit, caller-chosen uid
module "folder_explicit_uid" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Shared Dashboards"
    uid   = "shared-dashboards"
  }
}

⚠️ Treat uid as immutable once applied — changing it on a later apply forces replacement of the folder (see §6 Schema notes that bite).

3 · Folder with an explicit org scope
module "folder_scoped_org" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title  = "Tenant B Reporting"
    org_id = "3"
  }
}

ℹ️ org_id is exposed explicitly here rather than left to whatever org the aliased grafana provider instance defaults to — this suite's narrowest-explicit-scope convention.

4 · Nested folder (requires nestedFolders feature flag)
module "parent_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Engineering"
  }
}

module "child_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title             = "Engineering / Platform"
    parent_folder_uid = module.parent_folder.uid
  }
}

🔒 Requires the nestedFolders feature flag enabled on the target Grafana instance — see §8 Grafana Prerequisites. Do not assume this is available on every instance.

5 · Folder with a Viewer role binding (least-privilege)
module "folder_least_privilege" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Read-Only Metrics"
  }

  permissions = {
    "role:viewer" = {
      role       = "Viewer"
      permission = "View"
    }
  }
}

💡 The narrowest binding this module can render: the built-in Viewer basic role, granted only View. Prefer this shape whenever a folder just needs to be readable org-wide without individually enumerating principals.

6 · Folder with a team permission binding
module "analytics_team_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Analytics"
  }

  permissions = {
    "team:analytics" = {
      team       = tostring(module.analytics_team.team_id)
      permission = "Edit"
    }
  }
}

⚠️ Wired here as tostring(module.analytics_team.team_id), not team_uid — see §6 Schema notes that bite for the evidence behind this choice, and verify against a live plan before depending on it in production. terraform-grafana-team is the source module; see its own README for team_id's exact type.

7 · Folder with a user permission binding
module "user_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Personal Sandbox"
  }

  permissions = {
    "user:analyst01" = {
      user       = tostring(module.analyst_user.user_id)
      permission = "Admin"
    }
  }
}

ℹ️ terraform-grafana-user's primary output user_id is a Number — tostring(...) is required since grafana_folder_permission_item.user is String-typed.

8 · Folder with a service account permission binding
module "automation_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "CI/CD Provisioned Dashboards"
  }

  permissions = {
    "svcacct:ci-bot" = {
      user       = module.ci_service_account.id
      permission = "Edit"
    }
  }
}

💡 terraform-grafana-service-account's primary output id is already a string and is that module's own documented principal-reference output — no conversion needed.

9 · ⚠️ What NOT to do — multiple principal fields on one binding
module "folder_bad_binding" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Conflicting Binding"
  }

  permissions = {
    "bad:entry" = {
      role       = "Viewer"
      team       = tostring(module.analytics_team.team_id) # fails validation — do not do this
      permission = "Edit"
    }
  }
}

⚠️ This module's variable "permissions" carries a validation {} block that fails terraform plan with an actionable error when a binding sets more than one (or none) of role/team/user, rather than letting the live API reject it at apply time.

10 · Multiple bindings at once, mixed principal types
module "mixed_principal_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Shared Ops Dashboards"
  }

  permissions = {
    "role:viewer" = { role = "Viewer", permission = "View" }
    "team:ops"    = { team = tostring(module.ops_team.team_id), permission = "Edit" }
    "user:oncall" = { user = tostring(module.oncall_user.user_id), permission = "Admin" }
  }
}

💡 Because bindings are for_each-keyed, adding a fourth entry to this map — or removing any one of these three — never disturbs the Terraform resource identity of the other two.

11 · Permission bindings at scale via caller-side locals
locals {
  reporting_team_ids = {
    finance  = "12"
    treasury = "18"
    audit    = "27"
  }
}

module "reporting_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Regulatory Reporting"
  }

  permissions = {
    for name, team_id in local.reporting_team_ids :
    "team:${name}" => {
      team       = team_id
      permission = "View"
    }
  }
}

💡 A caller-side for expression over a stable map produces a stable for_each key set for this module's own permissions variable — the module itself does not impose any ceiling on binding count, but very large permission sets are still one terraform plan diff per changed principal, not per whole folder.

12 · Folder with the Grafana 10.2+ destroy guard
module "protected_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title                        = "Production Dashboards"
    prevent_destroy_if_not_empty = true
  }
}

🔒 Requires Grafana 10.2 or later on the target instance — see §8 Grafana Prerequisites. Confirm the target version before enabling; the provider will otherwise silently accept the field with no effect on older instances (behavior for unsupported versions was not independently verified this session — treat as an inference from the schema's own documented minimum-version note).

13 · Reporting-only folder with no permission bindings
module "folder_reporting_only" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Audit Trail"
  }
}

🔒 No bindings rendered at all — the narrowest possible folder, relying entirely on Grafana's own default folder ACL until a sibling module or a later apply adds explicit bindings.

14 · Consuming `uid` vs. `id` downstream
module "network_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Network Engineering"
  }
}

output "network_folder_uid" {
  value = module.network_folder.uid
}

output "network_folder_id" {
  value = module.network_folder.id
}

⚠️ Always wire uid, not id, into any sibling module's folder_uid-shaped input — id is documented here only as an informational pass-through of the provider's own resource id.

15 · 🏗️ End-to-end composition — team + user + service account → folder → downstream content
provider "grafana" {
  alias = "stack_primary"
  url   = "https://casey-primary.grafana.net"
  auth  = var.grafana_stack_service_account_token
}

module "analytics_team" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-team.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  team = {
    name = "Analytics"
  }
}

module "analyst_user" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-user.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  # See that module's own README for its real variable names/schema.
  # email = "[email protected]" / login = "analyst01" / password = var.analyst_password
}

module "ci_service_account" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-service-account.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  # See that module's own README for its real variable names/schema.
  # service_account = { name = "ci-bot", role = "Editor" }
}

module "analytics_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Analytics"
  }

  permissions = {
    "team:analytics" = {
      team       = tostring(module.analytics_team.team_id)
      permission = "Edit"
    }
    "user:analyst01" = {
      user       = tostring(module.analyst_user.user_id)
      permission = "View"
    }
    "svcacct:ci-bot" = {
      user       = module.ci_service_account.id
      permission = "Edit"
    }
  }
}

module "analytics_dashboard" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-dashboard.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  # Illustrative only -- see that module's own README for its real variable names/schema.
  # folder_uid = module.analytics_folder.uid
  # config_json = file("${path.module}/dashboards/analytics-overview.json")
}

💡 This composition shows the full intended chain: a team, a human user, and a machine identity are each provisioned by their own owning module; this folder module grants each one a differentiated permission level on a shared folder; and a downstream content-layer module (illustrated, not asserted as verified — confirm terraform-grafana-dashboard's own README for its exact variable names) places a dashboard inside that folder via folder_uid = module.analytics_folder.uid.


📥 Inputs

Variable Type (summary) Default Required
folder object — keystone fields (see below) n/a Yes (title required inside)
permissions map(object(...)) — permission bindings {} No
Full object schemas
variable "folder" {
  type = object({
    title                        = string
    uid                          = optional(string)
    org_id                       = optional(string)
    parent_folder_uid            = optional(string)
    prevent_destroy_if_not_empty = optional(bool, false)
  })
}

variable "permissions" {
  type = map(object({
    permission = string           # "View" | "Edit" | "Admin"
    role       = optional(string) # "Viewer" | "Editor" | "Admin" -- mutually exclusive with team/user
    team       = optional(string) # mutually exclusive with role/user
    user       = optional(string) # mutually exclusive with role/team
    org_id     = optional(string) # per-binding override; falls back to var.folder.org_id
  }))
  default = {}
}

🧾 Outputs

Output Description Sensitive / Conditional
uid Primary output. Folder's stable UID No
id Provider's own resource id (String) No
title Pass-through of configured folder title No
url Full URL of the folder in the Grafana UI No
permission_bindings Map, keyed like var.permissions, of each binding's provider-assigned id No — empty map {} when var.permissions is empty

🧠 Architecture Notes

  • _item over the whole-list resource, deliberately. grafana_folder_permission_item.bindings is for_each-keyed over var.permissions, giving every principal binding independent Terraform resource identity. The rejected alternative, grafana_folder_permission, renders the entire permission set as one resource and removes any binding not present in that render on every apply — it does not compose safely when different callers or for_each iterations add/remove principals independently over time.
  • uid is immutable-after-set. Whether caller-supplied or Grafana-generated, changing an existing uid forces replacement of the folder — every sibling module's folder_uid consumption depends on this value staying stable across applies.
  • Per-binding org_id falls back to the folder's own org_id. main.tf renders org_id = each.value.org_id != null ? each.value.org_id: try(var.folder.org_id, null) — a ternary, not try, since a null per-binding org_id is a valid (non-erroring) value and try only falls through on evaluation errors, not on null. This keeps a binding in the same org/stack scope as the folder it protects unless the caller explicitly overrides it.
  • The team/user identity-type question is genuinely open. The provider's own example usage for grafana_folder_permission_item passes grafana_team.team.id / grafana_user.user.id, and both resources' docs reference the legacy folder-permissions HTTP API. This module's Example Library (§12 examples 6–8) wires tostring(team_id) / tostring(user_id) / the service account's own id output as the practical, evidence-based recommendation — but this was not independently confirmed with a live plan against a real Grafana instance this session. Verify before depending on it in production.
  • for_each, never count, for permission bindings — this suite's mandate, followed here so adding or removing one principal never re-indexes or disturbs any other binding's state.
  • Cloud vs. OSS applicability: grafana_folder and grafana_folder_permission_item both apply to Cloud-hosted and self-managed OSS/Enterprise Grafana instances alike; parent_folder_uid (nested folders) and prevent_destroy_if_not_empty are each separately feature/version-gated regardless of Cloud-vs-OSS (see §8 Grafana Prerequisites).

🧱 Design Principles

Concern Safe default (this module) Opt-out
Folder/dashboard visibility No public sharing rendered by this module at all — folder visibility is private to the org by default per Grafana's own platform behavior N/A — public sharing is a separate resource (grafana_dashboard_public) this module never renders
Team/role permissions Empty permissions map ({}) — no explicit non-owner bindings rendered unless the caller supplies them; every binding's permission level must be stated explicitly, none defaults to Admin Caller supplies var.permissions entries with explicit permission values
Nested folders Disabled by default — parent_folder_uid defaults to null (root-level folder) Caller sets var.folder.parent_folder_uid, and the target instance must have nestedFolders enabled
Destroy guard Disabled by default — prevent_destroy_if_not_empty defaults to false, matching the provider's own documented default Caller sets var.folder.prevent_destroy_if_not_empty = true (Grafana 10.2+ only)
Org/stack scope org_id defaults to null (provider's own default org) but is always an explicit, visible field on var.folder rather than an implicit dependency on the aliased provider's own defaults; per-binding org_id falls back to the folder's own scope, never a different implicit default Caller sets var.folder.org_id and/or a per-binding org_id override
Credentials None accepted or produced — this module has no credential-bearing inputs or outputs N/A

🚀 Runbook

cd C:\GitHubCode\newgrafanamodules\terraform-grafana-folder
terraform init -backend=false
terraform validate
terraform fmt -check

Pin consumers at ?ref=v1.0.0, never a branch. This module is plan-only in this authoring pipeline — no apply is ever run here; a human applies from CI against a real Grafana target.


🧪 Testing

terraform validate + terraform fmt -check together confirm: every field in var.folder and var.permissions type-checks against the schemas declared in variables.tf; the three validation {} blocks on permissions (permission enum, role enum, exactly-one-of role/team/user) are themselves syntactically valid; and the whole file set is canonically formatted. These checks run entirely offline — no GRAFANA_URL or Service Account token is required.

What this harness cannot catch: whether a uid value collides with an existing folder on a real instance, whether nestedFolders or Grafana 10.2+ are actually available on the target instance (both are platform-side checks, not Terraform-side ones), or whether team/user bindings actually expect the legacy numeric ID vs. team_uid as this README's Architecture Notes flags as unresolved — those surface only at plan/apply against a live Grafana instance.


💬 Example Output

$ terraform output

id = "3"
permission_bindings = {
 "team:analytics" = "12:team:12"
 "user:analyst01" = "12:user:47"
}
title = "Analytics"
uid = "adfhmxue45gnkb"
url = "/dashboards/f/adfhmxue45gnkb/analytics"

🔍 Troubleshooting

Symptom Cause Fix
plan fails: "Each permissions entry must set EXACTLY ONE of role, team, or user" A binding set two (or zero) of role/team/user Set exactly one mutually exclusive principal field per binding
plan fails: "permissions[*].permission must be one of..." An invalid/mistyped permission value Use exactly "View", "Edit", or "Admin"
plan fails: "permissions[*].role, when set, must be one of..." An invalid/mistyped role value Use exactly "Viewer", "Editor", or "Admin"
apply fails referencing parent_folder_uid with a feature-flag error nestedFolders is not enabled on the target Grafana instance Enable the feature flag, or omit parent_folder_uid
apply fails or silently no-ops referencing prevent_destroy_if_not_empty Target Grafana instance predates 10.2 Upgrade the instance, or omit the field
Changing folder.uid triggers an unexpected destroy/recreate uid is immutable-after-set; changing it forces replacement Treat uid as write-once; provision a new folder instead of renaming an existing uid
A team/user permission binding is rejected by the live API despite passing plan The team/user field may expect the legacy numeric ID rather than team_uid, or vice versa — this was not independently confirmed this session Try tostring(team_id) / tostring(user_id) first (see §6, §15); confirm with a live plan against the target instance
terraform init can't find provider version ~> 4.0 Local provider cache is stale or offline Re-run terraform init -backend=false -upgrade, or confirm network access to the Terraform Registry

🔗 Related Docs

  • Provider resource docs: grafana_folder, grafana_folder_permission_item, grafana_folder_permission (grafana/grafana provider, Terraform Registry)
  • This module's SCOPE.md — cross-module contract, required auth/scope, Grafana prerequisites, provider gotchas, design decisions
  • Sibling modules referenced above: terraform-grafana-team, terraform-grafana-user, terraform-grafana-service-account, terraform-grafana-dashboard, terraform-grafana-alerting-rule-group, terraform-grafana-synthetic-monitoring-check (dashboard/alerting-rule-group/synthetic-monitoring-check not yet authored as of this session — treat their example call shapes above as illustrative)

About

Terraform module: terraform-grafana-folder

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages