Defines an organization's custom-property schema and sets the per-repository values that populate it — the governance/classification layer that tags every repo with compliance metadata (data-classification, owning-team, sox-in-scope, …) behind one secure-by-default boundary. Deeply-typed
map(object)collections,validation-enforced enums, governance defaults (required = true, org-owner-only edits), and adepends_oncontract so definitions land before values in a single apply. Built for integrations/github v6.x.
This composite owns the GitHub custom-property feature end to end, across two planes:
- 🏷️ Property definitions (the schema) —
github_organization_custom_properties.this: the org-level definition for each property (value_type,required,default_value,allowed_values, who may edit). One instance per property, keyed by property name. - 📌 Per-repository values (the data) —
github_repository_custom_property.values: the value each repository carries for a property (one entry per repo × property), keyed by a stable"<repo>:<property>"handle. - 🧬 Both planes are deeply-typed
map(object)collections keyed on stable strings, so adds/removes never churn unrelated entries. - 🛡️ Governance defaults — properties are
required = trueand editable only byorg_actors(org owners) unless you opt out;single_select/multi_selectarevalidation-checked to carryallowed_values. - 🔗 Self-wiring lifecycle —
property_typeon a value is derived from its definition (declare the type once), anddepends_onforces definitions to exist before any value is written.
💡 Why it matters: In a regulated estate every repository should declare what it holds and who owns it. This module makes that classification declarative and auditable — and it is the keystone that org rulesets key on to govern repositories by their compliance metadata instead of by hand-maintained name lists.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
flowchart LR
repo["tf-mod-github-repository<br/>(keystone — emits id = repo name)"]
cp["tf-mod-github-custom-properties<br/>(THIS module)"]
ruleset["tf-mod-github-organization-ruleset<br/>(targets repos by custom property)"]
orgset["tf-mod-github-organization-settings"]
repo -- "repository = id (repo name)" --> cp
cp -- "property_names" --> ruleset
orgset -. "org custom-property feature / plan".-> cp
style cp fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes repository names from the keystone tf-mod-github-repository (the repos whose values are set), and emits the set of defined property_names that tf-mod-github-organization-ruleset uses to target repositories by classification. See the Cross-Module Contract.
flowchart TD
props["var.properties<br/>map(object) keyed by property name"]
vals["var.repository_values<br/>map(object) keyed by repo:property"]
props --> this["github_organization_custom_properties.this<br/>(PRIMARY · for_each · org property DEFINITIONS)"]
vals --> values["github_repository_custom_property.values<br/>(for_each · per-repository VALUES)"]
this -- "depends_on · value_type → property_type" --> values
style this fill:#8957E5,color:#fff
Resource inventory
github_organization_custom_properties.this— the primary plane: one organization custom-property definition perpropertiesentry,for_eachover the map keyed by property name. (Despite the resource's plural name, each instance manages exactly one property.)github_repository_custom_property.values— the role-named child collection: one per-repository value perrepository_valuesentry,for_eachover the map keyed by"<repo>:<property>",depends_onthe definitions so a single apply defines then populates.
ℹ️ The primary resource is a
for_eachset, not a singleton — the provider'sgithub_organization_custom_propertiesis single-property-per-instance (see Architecture Notes).
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | integrations/github ~> 6.0 (current 6.12.1) |
Schema notes that bite (verified against the v6 provider binary schema):
- Provider source is
integrations/github— never the deprecatedhashicorp/github. - Single-property arity.
github_organization_custom_propertiesmanages one property per instance (imported by a single property name); itsidis the property name. There is no whole-set "definitions" singleton. - Different argument names per plane. The definition uses
value_type(string | single_select | multi_select | true_false, defaultstring); the repository value usesproperty_type(the same set plusurl) andproperty_valueis aset(string)(a single-element set for non-multi types). - No
repository_id.github_repository_custom_propertyexposes onlyid,property_name,property_type,property_value,repositoryin v6.x — there is norepository_idattribute (older docs list one; it does not exist).
tf-mod-github-custom-properties/
├── providers.tf # terraform{} + integrations/github ~> 6.0 (no provider block)
├── variables.tf # properties (definitions) + repository_values (values) — typed maps
├── main.tf # github_organization_custom_properties.this + github_repository_custom_property.values
├── outputs.tf # property_ids / property_names / properties + repository_value_ids / repository_values
├── SCOPE.md # design contract: scope, consumes/emits, token scopes, prerequisites
└── README.md # this file
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential" # required properties should carry a default
description = "Data sensitivity classification for the repository."
# required defaults to true; values_editable_by defaults to org_actors
}
}
}
⚠️ Always pin the module source to a tag (?ref=v1.0.0) — never a branch — so applies are reproducible.
| Input | Type | Source |
|---|---|---|
repository_values[].repository |
string (repo name) |
tf-mod-github-repository → id output |
repository_values[].property_name |
string |
a property defined in this module's properties (or an existing org property) |
| Output | Description | Consumed by |
|---|---|---|
property_ids |
Map of property name → definition resource id (the property name) | Audit / drift reporting |
property_names |
Set of defined custom-property names | tf-mod-github-organization-ruleset (repository_property conditions), audit |
properties |
Map of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description) |
Governance dashboards, compliance reporting |
repository_value_ids |
Map of "<repo>:<property>" handle → value resource id |
Audit / drift reporting |
repository_values |
Map of handle → applied value (repository, property_name, property_type, value) |
Compliance reporting — which repos carry which classification |
Sourced from this repo's
SCOPE.md. There is no scalaridoutput — the provider has no org-level custom-properties singleton (see Architecture Notes).
1️⃣ Minimal — one required classification property (no values yet)
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
description = "Data sensitivity classification for the repository."
}
}
}🔒 Governance defaults apply:
required = trueandvalues_editable_by = "org_actors". A required property should carry adefault_valueso repos without an explicit value are still compliant.
2️⃣ Full classification schema — single_select + string + true_false
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
required = true
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
description = "Data sensitivity classification."
}
"owning-team" = {
value_type = "string"
required = true
description = "Team accountable for the repository (set per repo via repository_values)."
}
"sox-in-scope" = {
value_type = "true_false"
required = true
default_value = "false"
description = "Whether the repository is in scope for SOX controls."
}
}
}💡
owning-teamis required but has no sensible default — every repository should set it viarepository_values(or callers will see it as unset/non-compliant).
3️⃣ Optional metadata property (opt out of the required default)
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"cost-center" = {
value_type = "string"
required = false # purely optional metadata — opt out of the governance default
description = "Finance cost-center code, if applicable."
}
}
}4️⃣ multi_select property — multiple compliance frameworks
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"compliance-frameworks" = {
value_type = "multi_select"
required = false
allowed_values = ["sox", "pci-dss", "glba", "ffiec"]
description = "Regulatory frameworks the repository must satisfy."
}
}
}
⚠️ single_selectandmulti_selectmust set a non-emptyallowed_values— this isvalidation-enforced at plan time.
5️⃣ Set one repository value (property_type derived from the definition)
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
}
}
repository_values = {
"payments-api:data-classification" = {
repository = "payments-api"
property_name = "data-classification"
property_value = ["restricted"] # a single-element set for single_select
# property_type omitted — derived from the definition's value_type
}
}
}6️⃣ Repository value wired from tf-mod-github-repository (cross-module)
module "repository" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "payments-api"
visibility = "private"
}
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
}
}
repository_values = {
"payments-api:data-classification" = {
repository = module.repository.id # id == repo name
property_name = "data-classification"
property_value = ["restricted"]
}
}
}💡 The keystone's
idoutput is the repository name — exactly whatrepositoryexpects.
7️⃣ Explicit property_type — value for a property defined outside this module
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
# No `properties` here — "cost-center" is defined elsewhere in the org.
repository_values = {
"payments-api:cost-center" = {
repository = "payments-api"
property_name = "cost-center"
property_type = "string" # REQUIRED here — cannot be derived (not in this module's properties)
property_value = ["CC-4815"]
}
}
}
⚠️ When a value targets a property not defined in this module'sproperties, you must setproperty_typeexplicitly — otherwise plan fails the resolvabilityvalidation.
8️⃣ Delegated editing — values_editable_by = org_and_repo_actors
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"team-contact" = {
value_type = "string"
required = false
values_editable_by = "org_and_repo_actors" # repo admins may edit this value
description = "Owning-team contact (delegated to repository admins)."
}
}
}🔒 Default is
org_actors(org owners only). Opt intoorg_and_repo_actorsonly where decentralised editing is acceptable.
9️⃣ for_each at scale — repo values from a map(object)
locals {
# Built from your repository inventory: repo => classification.
repo_classifications = {
"payments-api" = "restricted"
"ledger-core" = "confidential"
"docs-site" = "public"
}
}
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
}
}
repository_values = {
for repo, cls in local.repo_classifications :
"${repo}:data-classification" => {
repository = repo
property_name = "data-classification"
property_value = [cls]
}
}
}
⚠️ Bulkfor_eachacross many repositories can trip GitHub secondary rate limits — see Troubleshooting.
🔟 multi_select value — several values on one repository
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"compliance-frameworks" = {
value_type = "multi_select"
allowed_values = ["sox", "pci-dss", "glba", "ffiec"]
required = false
}
}
repository_values = {
"payments-api:compliance-frameworks" = {
repository = "payments-api"
property_name = "compliance-frameworks"
property_value = ["sox", "pci-dss", "glba"] # multi_select takes multiple values
}
}
}1️⃣1️⃣ Schema + values in one call (definitions land before values)
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
}
"owning-team" = {
value_type = "string"
}
}
repository_values = {
"payments-api:data-classification" = { repository = "payments-api", property_name = "data-classification", property_value = ["restricted"] }
"payments-api:owning-team" = { repository = "payments-api", property_name = "owning-team", property_value = ["payments-platform"] }
}
}💡
depends_oninside the module guarantees both definitions exist before either value is written — a singleapplyboth defines and populates.
1️⃣2️⃣ 🔒 Secure / hardened governance variant
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
# Every classification property is REQUIRED and editable ONLY by org owners.
properties = {
"data-classification" = {
value_type = "single_select"
required = true
values_editable_by = "org_actors" # org owners only (default, made explicit)
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
description = "Data sensitivity classification (governed centrally)."
}
"owning-team" = {
value_type = "string"
required = true
values_editable_by = "org_actors"
description = "Accountable team — must be set on every repository."
}
"sox-in-scope" = {
value_type = "true_false"
required = true
values_editable_by = "org_actors"
default_value = "false"
description = "SOX control scope flag."
}
}
}🔒 Hardening checklist: every property
required = true;values_editable_by = "org_actors"; a safedefault_valueon each so no repo is ever non-compliant by omission; classification governed centrally, never by repo admins.
1️⃣3️⃣ 🏗️ End-to-end composition (mandatory) — repository → custom properties → org ruleset
# 1 · Keystone repository.
module "repository" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "payments-api"
visibility = "private"
}
# 2 · Define the classification schema AND set this repo's value.
module "custom_properties" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-custom-properties?ref=v1.0.0"
properties = {
"data-classification" = {
value_type = "single_select"
required = true
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value = "confidential"
description = "Data sensitivity classification."
}
}
repository_values = {
"payments-api:data-classification" = {
repository = module.repository.id # ← repository.id (repo name)
property_name = "data-classification"
property_value = ["restricted"]
}
}
}
# 3 · Govern: an org ruleset that targets repositories BY the custom property.
# property_names is the published contract this ruleset keys on.
module "classification_ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "protect-restricted-repos"
target = "branch"
enforcement = "active"
rules = {
non_fast_forward = true
deletion = true
pull_request = {
required_approving_review_count = 2
require_code_owner_review = true
}
}
conditions = {
ref_name = {
include = ["~DEFAULT_BRANCH"]
}
repository_property = {
include = [
{
name = "data-classification" # one of module.custom_properties.property_names
property_values = ["restricted"]
}
]
}
}
# The ruleset can only target a property that already exists — ensure the
# definitions apply first. property_names is emitted for exactly this contract.
depends_on = [module.custom_properties]
}
output "classification_property_names" {
value = module.custom_properties.property_names
}🏗️ The canonical wiring:
repository.id→ the value'srepository; the custom-property name flows into the org ruleset'srepository_propertycondition so everyrestrictedrepo is governed automatically — no hand-maintained repo lists.
High-level:
properties(map(object), default{}) — organization custom-property definitions, keyed by property name. Governance defaults:required = true,values_editable_by = "org_actors".repository_values(map(object), default{}) — per-repository values, keyed by a stable"<repo>:<property>"handle.property_typeis optional (derived from the matching definition).
Full object schemas for the map inputs
# properties — map keyed by property NAME (the key IS the property name)
map(object({
value_type = optional(string, "string") # string | single_select | multi_select | true_false
required = optional(bool, true) # governance default: REQUIRED unless opted out
description = optional(string) # human description of the property
default_value = optional(string) # default applied to repos that do not set a value
allowed_values = optional(list(string), []) # REQUIRED for single_select / multi_select; ignored otherwise
values_editable_by = optional(string, "org_actors") # org_actors (most restrictive) | org_and_repo_actors
}))
# repository_values — map keyed by a stable "<repo>:<property>" handle
map(object({
repository = string # repo name whose value is set (wire from tf-mod-github-repository.id)
property_name = string # name of the (already-defined) custom property
property_type = optional(string) # string | single_select | multi_select | true_false | url
# OMIT to derive from the matching `properties` definition's value_type
property_value = set(string) # the value(s): single-element set for non-multi types; multiple for multi_select
}))Validations enforced at plan time:
properties[].value_type∈string | single_select | multi_select | true_false.properties[].values_editable_by∈org_actors | org_and_repo_actors.single_select/multi_selectproperties must set a non-emptyallowed_values.repository_values[].property_type, when set, ∈string | single_select | multi_select | true_false | url.- Each
repository_valuesentry must setproperty_typeor reference a property defined inproperties(so the type can be derived). - Each
repository_valuesentry must have a non-emptyrepository,property_name, and at least oneproperty_value.
| Output | Description |
|---|---|
property_ids |
Map of property name → definition resource id (the property name). |
property_names |
Set of defined custom-property names — the key wire for tf-mod-github-organization-ruleset. |
properties |
Map of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description). |
repository_value_ids |
Map of "<repo>:<property>" handle → value resource id (org:repo:property). |
repository_values |
Map of handle → applied value (repository, property_name, property_type, value). |
ℹ️ There is no scalar
id— the provider has no org-level custom-properties singleton. The definitions plane is exposed as maps keyed by property name. Nothing issensitive: property names/values are governance metadata, not secrets or member PII.
- Per-property definitions, not a whole-set overwrite. Despite the plural name,
github_organization_custom_propertiesmanages one property per instance (imported by a single property name; itsidis the name). So the definitions plane is afor_eachset keyed by property name — each property is its own authoritative resource. There is no single resource that owns "the whole schema," and therefore no scalarid/node_id/slugto emit; the module exposesproperty_ids/property_names/propertiesmaps instead. - Two argument vocabularies. The definition uses
value_type(string | single_select | multi_select | true_false); the repository value usesproperty_type(the same set plusurl) and aproperty_valueset. The module hides this asymmetry by derivingproperty_typefrom the matching definition'svalue_typewhen you omit it — declare the type once on the definition. single_select/multi_selectneedallowed_values. These types are meaningless without an enumerated value list, so the modulevalidation-rejects them without one at plan time, before any API call. A value not inallowed_valuesfails at apply.- Definitions before values — one apply.
github_repository_custom_property.valuescarriesdepends_on = [github_organization_custom_properties.this], so every definition exists before any value is written. A value referencing an undefined property fails at apply — keep the property inproperties(or define it elsewhere and passproperty_type). - The compliance/classification keystone. Once repositories carry
data-classification,owning-team,sox-in-scope, an org ruleset (tf-mod-github-organization-ruleset) can target them byrepository_propertyconditions — governing whole classes of repos by metadata instead of by hand-maintained name lists.property_namesis the published contract for that wiring. value_typeis effectively immutable. Changing a property's type after repositories hold values can fail at apply. Plan type changes carefully; treat the schema as append-mostly.- No tags, no timeouts. GitHub has neither a resource-tagging concept nor a
timeoutsblock on these resources — there is intentionally notags/timeoutstail, and auth/ownerare provider concerns, never module variables. - Eventual consistency / secondary rate limits. The GitHub REST API is eventually consistent and enforces undocumented secondary rate limits on bursts of writes. Populating values across many repositories via
for_eachcan intermittently 403/422 mid-apply; a re-run usually converges.
- 🔒 Governed by default — properties are
required = trueand editable only byorg_actors; you opt out explicitly for optional or delegated metadata. - 🏷️ Type is the contract — deeply-typed
map(object)collections +validationenums mean a malformed schema fails at plan time, not at apply. - 🧬 Declare once —
property_typeon values is derived from the definition, so the type lives in exactly one place. - 🪪 Least privilege —
values_editable_bydefaults to the most restrictive option (org_actors). - 🔗 Composable governance — emits
property_namesso rulesets govern repos by classification; consumes repo names from the keystone. - 🚫 No tags, no timeouts, no auth variables — GitHub has no tags; auth and
ownerare provider concerns.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Pin the module source to a tag (?ref=v1.0.0) — never a branch — so applies are reproducible.
plan/apply require a configured integrations/github provider (PAT / GitHub App) with the token scopes below, an org-owner identity, and the target owner / GITHUB_OWNER set.
Classic PAT scopes
admin:org— read/write organization custom-property definitions.repo— set custom-property values on repositories.
Fine-grained PAT / GitHub App permissions
- Organization → Custom properties: read/write (the definitions).
- Repository → Custom properties: read/write (per-repo values).
- Metadata: read.
⚠️ Auth and the target org (owner/GITHUB_OWNER) are provider concerns — never module variables.
- Plan / edition. Custom properties are an organization feature, available on GitHub Team and Enterprise Cloud (managed orgs only — not user accounts). Confirm availability for the org's plan.
- Org-owner identity. The provider identity must be an organization owner to manage definitions.
- Definitions before values. A repository value can only be set for a property that already exists; the module wires
repository_valuestodepends_onthe definitions so a single apply succeeds. - Rate limits. Per-repo value writes are one API call each — watch secondary rate limits when populating values across many repositories via
for_each.
The offline proof gate (no live org required):
terraform fmt -check # zero formatting differences
terraform validate # configuration is valid
tflint # core rules — no dedicated GitHub ruleset exists✅ This module passes
terraform init -backend=false,terraform validate, andterraform fmt -checkcleanly.
property_ids = {
"data-classification" = "data-classification"
"owning-team" = "owning-team"
"sox-in-scope" = "sox-in-scope"
}
property_names = toset([
"data-classification",
"owning-team",
"sox-in-scope",
])
properties = {
"data-classification" = {
"allowed_values" = tolist(["public", "internal", "confidential", "restricted"])
"default_value" = "confidential"
"description" = "Data sensitivity classification."
"required" = true
"value_type" = "single_select"
"values_editable_by" = "org_actors"
}
}
repository_value_ids = {
"payments-api:data-classification" = "casey:payments-api:data-classification"
}
repository_values = {
"payments-api:data-classification" = {
"property_name" = "data-classification"
"property_type" = "single_select"
"repository" = "payments-api"
"value" = toset(["restricted"])
}
}
| Symptom | Likely cause | Resolution |
|---|---|---|
403 Resource not accessible by integration |
Token lacks admin:org/repo (classic) or Custom properties: read/write (fine-grained); or identity is not an org owner |
Grant the scopes above and use an org-owner identity |
404 Not Found / feature unavailable |
Org is not Team/Enterprise, or the provider points at a user account | Custom properties are an org feature on Team/Enterprise — target a managed org |
| Apply error: property does not exist | A value references a property not yet defined | Define it in properties, or define it in the org and pass property_type; depends_on handles in-module ordering |
| Apply error: value not allowed | Value not in the property's allowed_values |
Use one of the enumerated values (or widen allowed_values) |
| Plan error: must set property_type or match a defined property | A value targets an external property with no property_type |
Add property_type, or add the property to properties so the type can be derived |
| Plan error: single_select/multi_select requires allowed_values | Enumerated type without a value list | Add a non-empty allowed_values |
Intermittent 403/422 during bulk apply |
GitHub secondary rate limit on bursty writes | Re-run terraform apply; reduce -parallelism; stagger large rollouts |
| Unexpected replace on a property | value_type changed (effectively immutable once values exist) |
Avoid type changes; migrate to a new property if the type must change |
- This module's design contract —
SCOPE.md - Keystone repository module —
tf-mod-github-repository(emitsid= repo name) - Governance sibling —
tf-mod-github-organization-ruleset(consumesproperty_namesto target repos by property) integrations/githubprovider reference — organization custom properties and repository custom property resources- GitHub custom properties documentation — organization metadata and classification