Naar de hoofdinhoud

Configuratie als code met Terraform en OpenTofu

Je kunt je fremforge-organisatie als code bijhouden met HashiCorp Terraform of OpenTofu. Beide CLI’s draaien dezelfde configuratie, met twee open source providers uit de openbare registers:

  • Forgejo-objecten (repository’s, branchbescherming, teams, Actions-secrets en -variabelen) via de eigen REST-API van Forgejo, met svalabs/forgejo.
  • fremforge-instellingen (authenticatiebeleid, IP-toegestane lijst, standaardinstellingen voor branchbescherming, scanbeleid en scannerinstellingen, onveranderlijke releases, SIEM-doorsturen, webhooks en meer) via de fremforge-API, met de generieke REST-provider magodo/restful.

Voor de eigen instellingen van fremforge is er nu een aparte provider: de fremforge-provider (source = "frem.sh/fremforge/fremforge", 0.1.1, geïnstalleerd vanaf frem.sh met controle van de handtekening) dekt ze met getypeerde resources, importeren op slug van de organisatie en drift op elk argument dat je instelt. Gebruik die bij voorkeur voor nieuwe configuraties. De module op deze pagina blijft gedocumenteerd terwijl je overstapt; Overstappen vanaf de module laat zien hoe. Repository’s en branchbescherming blijven hoe dan ook bij svalabs/forgejo.

Providers

ProviderVersieLicentiePraat met
svalabs/forgejo1.6.1MPL-2.0Forgejo, https://frem.sh/api/v1
magodo/restful0.25.2MPL-2.0fremforge, https://frem.sh/_app/api/v1

De providerblokken zijn voor beide CLI’s gelijk:

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    restful = {
      source  = "magodo/restful"
      version = "0.25.2"
    }
    forgejo = {
      source  = "svalabs/forgejo"
      version = "1.6.1"
    }
  }
}

provider "restful" {
  base_url = "https://frem.sh/_app/api/v1"

  # 60 writes a minute per token; retry the 429 instead of failing half-way.
  client = {
    retry = {
      status_codes    = [429]
      count           = 6
      wait_in_sec     = 10
      max_wait_in_sec = 60
    }
  }

  security = {
    http = {
      token = {
        token = var.fremforge_token # ffp_... fremforge API token
      }
    }
  }
}

provider "forgejo" {
  host      = "https://frem.sh"
  api_token = var.forgejo_token # Forgejo access token
}

Rate limit. De fremforge-API staat één token 60 schrijfacties per minuut toe en antwoordt daarboven met 429 en Retry-After. Het volledige voorbeeld is ongeveer 27 schrijfacties voor apply en 28 voor destroy, dus twee runs direct na elkaar gaan eroverheen. Het blok client.retry laat de provider wachten en een 429 opnieuw proberen. Dat is veilig, want een 429 betekent dat de schrijfactie niet is uitgevoerd.

>= 1.11.0 is nodig voor write-only-argumenten, die secrets die je verstuurt (de waarde van een Actions-secret, een auth-header voor SIEM) buiten de state houden. Terraform 1.11 en OpenTofu 1.11 ondersteunen ze allebei.

Waar de binaries vandaan komen. source = "magodo/restful" verwijst onder Terraform naar registry.terraform.io en onder OpenTofu naar registry.opentofu.org. Beide registers laten de download naar de GitHub-releases van het project wijzen. Commit .terraform.lock.hcl: dat bestand legt de checksums van elke provider vast, zodat een latere init een gewijzigde binary weigert. Het lockbestand legt de hostnaam van het register vast, dus houd er één per CLI aan en deel geen werkmap tussen de twee.

Handtekeningen verschillen tussen de twee CLI’s. Terraform controleert bij init de ontwikkelaarshandtekening van elke provider en toont Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) en Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79). Het OpenTofu-register heeft geen ondertekeningssleutels voor deze twee providers, dus OpenTofu toont Signature validation was skipped en vertrouwt alleen op de checksums. Onder OpenTofu is het gecommitte lockbestand daarom de enige controle dat de binary de versie is die je hebt beoordeeld.

Wil je niet dat CI bij elke run van GitHub ophaalt, spiegel de providers dan één keer en installeer vanaf de spiegel:

terraform providers mirror -platform=linux_amd64 ./providers   # or: tofu providers mirror ...
# ~/.terraformrc, ~/.tofurc, or the file named by TF_CLI_CONFIG_FILE
provider_installation {
  filesystem_mirror {
    path    = "/path/to/providers"
    include = ["*/svalabs/forgejo", "*/magodo/restful"]
  }
}

Tokens

De twee providers gebruiken verschillende tokens, omdat ze met verschillende API’s praten.

fremforge-API (restful). Een ffp_...-token. Maak een API-token voor de organisatie aan onder Organisatiebeheer → API-tokens (frem.sh/<org>/_admin/api-tokens). Het is aan één organisatie gebonden, blijft werken als de persoon die het aanmaakte vertrekt, en verloopt na hoogstens 90 dagen. Maak een token met alleen leesrechten voor plan en bewaar de schrijfscopes voor de job die apply uitvoert:

InstellingenLezen (plan)Schrijven (apply)
Authenticatiebeleid, IP-toegestane lijst, operatortoegang, e-maildomeinen, SSH-CA, standaardinstellingen voor branchbescherming, scanbeleid, Actions-beleid, bewaartermijn van artefacten, SLA-beleid, beleid voor image-handtekeningen, SIEM-endpointspolicy:readpolicy:write
Scannerinstellingen, foutmodus van de push-bescherming, onveranderlijke releasesfindings:readpolicy:write
Webhookbestemmingenwebhooks:readwebhooks:write
Deploymentomgevingenenvironments:readenvironments:write
Actions-secrets van de organisatieorgs:readorgs:write
Updates van afhankelijkhedenrenovate:readrenovate:write
SSO-domeinensso:readsso:write

Vanuit een Forgejo Actions-job kun je het opslaan van een token vermijden door het OIDC-token van de job in te wisselen voor een ffp_-token dat een uur geldig is (zie OIDC-tokenuitwisseling). Vandaag geeft die uitwisseling een vaste set scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): genoeg om de instellingen voor scanners, push-bescherming en onveranderlijkheid te plannen, maar niet om ze te wijzigen en niet om de policy:*-instellingen te plannen. Voor het wijzigen van een beveiligingsinstelling is policy:write nodig, en dat geeft de uitwisseling voor runners nooit, zodat de CI van een repository de controls van je organisatie niet kan uitschakelen. Gebruik voor apply een API-token van de organisatie.

Forgejo-API (forgejo). Een toegangstoken van Forgejo, geen ffp_-token. De provider controleert het token bij het starten tegen Forgejo, ook als hij niets te beheren heeft, dus het kan niet leeg blijven. Wil je alleen fremforge-instellingen beheren, verwijder dan forgejo.tf, het providerblok forgejo, de bijbehorende regel in required_providers en de variabele forgejo_token. Staat het authenticatiebeleid van je organisatie geen gebruikerstokens toe, maak het dan aan onder Gebruikersinstellingen → Git-PAT’s (frem.sh/user/settings#ff-git-pats), dat scopes voor repository’s en issues aanbiedt: genoeg voor repository’s en branchbescherming, niet voor teams.

De module

De module beheert elke beleidsinstelling op organisatieniveau die de fremforge-API aanbiedt. Elke instelling is een optionele invoer: laat je haar weg, dan raakt de module haar niet aan. Voor elke instelling die je zet, beheert de module precies de velden die je noemt. Velden die je weglaat, worden niet verstuurd en niet vergeleken, dus een wijziging daarvan in de console verschijnt niet als drift.

De module staat in de documentatierepository onder examples/terraform/. De bestanden hieronder worden er rechtstreeks uit ingevoegd, zodat deze pagina altijd de code toont die we testen.

Invoer van de module: variables.tf
terraform/modules/fremforge-org/variables.tf
# Every setting is optional. Leave a variable at null and the module does not
# touch that setting at all. Set it to an object and the module owns exactly the
# fields you name: fields you leave out are neither sent nor compared, so the
# console can still change them without showing up as drift.
#
# Field names are the API's own. The OpenAPI spec is at
# https://docs.frem.sh/api/openapi.yaml.

variable "org" {
  description = "The fremforge organisation slug, as in frem.sh/<org>."
  type        = string
}

variable "reset_on_destroy" {
  description = <<-EOT
    What `destroy` does to a singleton setting (one that always exists and has
    no DELETE). false (the default): destroy writes the current values back, so
    the live setting is left exactly as it is and only state forgets it. true:
    destroy writes the platform default for every field you manage. Use true for
    a throwaway test org, never for an org people work in.
  EOT
  type        = bool
  default     = false
}

# --- Authentication and access ---------------------------------------------

variable "auth_policy" {
  description = "PATCH /orgs/{org}/auth-policy. E.g. { max_pat_lifetime_days = 30, ssh_disabled = true, allow_user_pats = false }."
  type        = any
  default     = null
}

variable "ip_allowlist" {
  description = "PUT /orgs/{org}/security/ip-policy. Send all three fields: { cidrs = [...], audit_only = true, allow_platform_runners = true }. The PUT replaces the list."
  type        = any
  default     = null
}

variable "operator_access" {
  description = "PUT /orgs/{org}/security/operator-access. { allow = false } stops fremforge operators managing users in your org."
  type        = any
  default     = null
}

variable "email_domain_allowlist_enforced" {
  description = "PUT /orgs/{org}/email-domains/policy. true: members may only add email addresses on the domains in email_domains."
  type        = bool
  default     = null
}

variable "email_domains" {
  description = "POST/DELETE /orgs/{org}/email-domains. Domain patterns members may use, e.g. [\"acme.com\"]."
  type        = set(string)
  default     = []
}

variable "ssh_ca" {
  description = "PUT /orgs/{org}/ssh-ca. { ca_keys = [...], require_cert = false }. (principal_policy was removed 2026-10-14: ignored by the API and no longer returned, so leave it out or plan shows a permanent diff.)"
  type        = any
  default     = null
}

variable "sso_domains" {
  description = "POST/DELETE /orgs/{org}/sso/domains. Registers domains for SSO. Verification (DNS or HTTP) is a separate, manual step."
  type        = set(string)
  default     = []
}

# --- Repository and Actions defaults ----------------------------------------

variable "branch_protection_defaults" {
  description = "PUT /orgs/{org}/policy, body under branch_protection. E.g. { enabled = true, required_approvals = 1, require_status_checks = true }."
  type        = any
  default     = null
}

variable "actions_policy" {
  description = "PUT /orgs/{org}/security/actions-policy. { only_protected_branches = true } lets Actions run only on protected branches."
  type        = any
  default     = null
}

variable "artifact_retention" {
  description = "PUT /orgs/{org}/artifact-retention. { days = 30 } (1 to 90)."
  type        = any
  default     = null
}

variable "dependency_updates" {
  description = "Version updates: PUT /orgs/{org}/dependency-updates/version-updates/settings. E.g. { automerge_patch = true, major_approval = true }. Every field except `schedule` and `merge_window`. `security_automerge` is no longer taken here: use security_fixes.merge."
  type        = any
  default     = null
}

variable "security_fixes" {
  description = "Security fixes: PUT /orgs/{org}/dependency-updates/security-fixes/settings. E.g. { mode = \"auto_important\", merge = \"review\" }. Fields: mode, include_unknown, merge, max_open_per_repo, max_per_day, soak_days (`repos` is not managed by this module)."
  type        = any
  default     = null
}

# --- Code security ----------------------------------------------------------

variable "scan_policy" {
  description = "PUT /orgs/{org}/policy, body under scan_policy. Org-wide mode per scanner, e.g. { sast = { mode = \"enforce\", block_severity = \"high\" } }."
  type        = any
  default     = null
}

variable "scanner_settings" {
  description = <<-EOT
    PUT /orgs/{org}/code-security/<scanner>/settings, keyed by scanner:
    sast, deps, images, secrets, licenses. E.g.
    { sast = { enabled = true, block_severity = "error" }, licenses = { enabled = true } }.
    licenses also takes the licence policy: allowlisted_license_ids,
    denied_license_ids, warned_license_ids, unknown_action, multiple_mode.
  EOT
  type        = any
  default     = {}
}

variable "licence_repo_overrides" {
  description = <<-EOT
    PUT /orgs/{org}/repos/<repo>/code-security/licenses/settings, keyed by
    repository name: a per-repository licence policy override. Fields:
    allowlisted_license_ids, denied_license_ids, warned_license_ids (lists of
    SPDX ids), unknown_action (threshold|allow|warn|deny), multiple_mode
    (all|any). An omitted field inherits the org's value. E.g.
    { legacy-tool = { denied_license_ids = ["GPL-3.0-only"], unknown_action = "deny" } }.
  EOT
  type        = any
  default     = {}
}

variable "push_protection_failure_mode" {
  description = "PUT /orgs/{org}/push-protection/failure-mode. \"block\" or \"allow\": what a push does when the secret scanner cannot run."
  type        = string
  default     = null
}

variable "exception_settings" {
  description = <<-EOT
    PUT /orgs/{org}/exceptions/settings. Fields: max_days (1..366, the longest
    an exception may run), approver_team (a team of the org, or null for the
    owners plus the package-policy approver list), dismissals_require_approval
    (true: a finding dismissal by a non-approver waits for an approver other
    than the requester, and the finding stays open until then; default false).
    E.g. { max_days = 90, dismissals_require_approval = true }.
  EOT
  type        = any
  default     = null
}

variable "immutability" {
  description = "PUT /orgs/{org}/code-security/immutability. WARNING: releases = true is one-way for every release published while it is on."
  type        = any
  default     = null
}

variable "dependency_proxy" {
  description = <<-EOT
    PUT /orgs/{org}/dependency-proxy. Fields: enabled, block_until_scanned,
    retention_days (7..365), metadata_filter ("off" | "shadow" | "on": hide the
    versions the package policy would refuse from the package metadata the proxy
    serves; "on" is refused with 403 outside fremforge's internal organisations
    for now). E.g. { enabled = true, metadata_filter = "shadow" }.
  EOT
  type        = any
  default     = null
}

variable "image_signature_policy" {
  description = <<-EOT
    PUT /orgs/{org}/image-signature-policy: which container image signatures
    the org trusts at pull. { enabled = true, mode = "shadow",
    identities = [{ issuer = "https://frem.sh/_app/runner/oidc",
    subject_regex = "https://frem\\.sh/acme/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/tags/v.+" }],
    keys = [{ name = "release", public_key_pem = file("cosign.pub") }], scope = ["api"] }.
    Fields left out take their defaults.
  EOT
  type        = any
  default     = null
}

variable "sla_policy" {
  description = "PUT /orgs/{org}/security/sla-policy. { critical_sla_days = 7, high_sla_days = 30, medium_sla_days = 90, low_sla_days = null }."
  type        = any
  default     = null
}

# --- Integrations -----------------------------------------------------------

variable "webhooks" {
  description = <<-EOT
    POST/PATCH/DELETE /orgs/{org}/webhooks/destinations, keyed by name:
    { audit = { target_url = "https://hooks.acme.com/fremforge", events = ["push"], repo_filter_regex = null, active = true } }.
    Only `active` can change in place; any other change replaces the destination
    and mints a new signing secret.
  EOT
  type        = any
  default     = {}
}

variable "siem_endpoints" {
  description = <<-EOT
    POST/PATCH/DELETE /orgs/{org}/siem/endpoints, keyed by name:
    { splunk = { url = "https://siem.acme.com/hec", action_prefixes = ["auth."], enabled = true } }.
    Only `enabled` can change in place; any other change replaces the endpoint
    and mints a new HMAC secret.
  EOT
  type        = any
  default     = {}
}

variable "siem_auth_headers" {
  description = "Optional auth header per SIEM endpoint name, { splunk = { name = \"Authorization\", value = \"Splunk ...\" } }. Sent once, never stored in state."
  type        = map(object({ name = string, value = string }))
  default     = {}
  sensitive   = true
}

variable "environments" {
  description = "POST/DELETE /orgs/{org}/environments. Deployment environment names, e.g. [\"staging\", \"production\"]."
  type        = set(string)
  default     = []
}

variable "org_secrets" {
  description = "PUT/DELETE /orgs/{org}/secrets/{name}. Actions secrets for every repo in the org, { NAME = \"value\" }. Values are write-only: never read back and never in state."
  type        = map(string)
  default     = {}
  sensitive   = true
}

# --- Package access ---------------------------------------------------------

variable "package_access_default" {
  description = "PUT /orgs/{org}/packages/access-settings: \"legacy\" (organisation-wide package access, the default) or \"granular\" (every package of a supported type is on per-package access unless it says otherwise)."
  type        = string
  default     = null
}

variable "packages" {
  description = <<-EOT
    Per-package access, keyed by "<type>/<name>" (the first "/" separates the
    type; a name may contain more): { "container/team/app" = { mode = "granular",
    inherit_repo = true, teams = { release-managers = "admin" }, actions = { api = "write" } } }.
    mode: "granular", "legacy" or null (follow the organisation). teams: read,
    write or admin per team name. actions: read or write per repository name,
    for that repository's Actions jobs. Removing a package from the map
    removes its configuration and grants.
  EOT
  type = map(object({
    mode         = optional(string)
    inherit_repo = optional(bool, true)
    teams        = optional(map(string), {})
    actions      = optional(map(string), {})
  }))
  default = {}
}

variable "package_protection_rules" {
  description = <<-EOT
    Protected package names, keyed by any label: { releases = { type = "*",
    name_pattern = "release/*", push = { owners = false, robots = true,
    teams = ["release-managers"], actions = ["api"] }, delete = { owners = true } } }.
    Only the listed principals may publish to, or delete, matching packages.
  EOT
  type        = any
  default     = {}
}
Resources van de module: main.tf
terraform/modules/fremforge-org/main.tf
locals {
  org = "/orgs/${var.org}"

  # Platform defaults, written back on destroy when reset_on_destroy = true.
  # Only the fields the caller manages are reset.
  defaults = {
    auth_policy = {
      require_hardware_backed_keys      = false
      max_pat_lifetime_days             = 90
      audit_full_payload_retention_days = 365
      require_signed_commits            = false
      require_oidc_signed_commits       = false
      allowed_oidc_issuers              = []
      allow_user_pats                   = false
      allow_repo_deploy_keys            = false
      allowed_user_pat_scopes           = []
      ssh_disabled                      = true
    }
    ip_allowlist                 = { cidrs = [], audit_only = false, allow_platform_runners = true }
    operator_access              = { allow = true }
    actions_policy               = { only_protected_branches = false }
    artifact_retention           = { days = 90 }
    sla_policy                   = { critical_sla_days = null, high_sla_days = null, medium_sla_days = null, low_sla_days = null }
    ssh_ca                       = { ca_keys = [], require_cert = false }
    dependency_proxy             = { enabled = false, block_until_scanned = false, retention_days = 90, metadata_filter = "off" }
    immutability                 = { releases = false, allow_repo_opt_out = false, oci_mode = "off", oci_mutable_tags = [] }
    image_signature_policy       = { enabled = false, mode = "shadow", issuers = [], identities = [], keys = [], scope = [], trust_public_sigstore = false }
    push_protection_failure_mode = "block"
    exception_settings           = { max_days = 366, approver_team = null, dismissals_require_approval = false }
    # GET .../code-security/<scanner>/settings on an org that never saved them.
    scanner_settings = {
      sast    = { enabled = true, block_severity = "error", custom_rules_url = null }
      deps    = { enabled = true, block_severity = "critical" }
      images  = { enabled = true, block_action = "warn" }
      secrets = { enabled = true }
      licenses = {
        enabled                 = true
        block_severity          = "restricted"
        allowlisted_license_ids = []
        denied_license_ids      = []
        warned_license_ids      = []
        unknown_action          = "threshold"
        multiple_mode           = "all"
      }
    }
    # Version updates (hosted Renovate). Since 2026-10-15 a new settings row
    # defaults to 2 / 5 for the PR limits, as the runner always applied.
    dependency_updates = {
      enabled             = true
      automerge_patch     = true
      automerge_minor     = false
      major_approval      = true
      pr_hourly_limit     = 2
      pr_concurrent_limit = 5
    }
    # Security fixes (Dependency updates, the other lane).
    security_fixes = {
      mode              = "on_click"
      include_unknown   = false
      merge             = "review"
      max_open_per_repo = 3
      max_per_day       = 20
      soak_days         = 3
    }
  }

  # What destroy writes for a singleton: the platform default for each managed
  # field, or the managed fields unchanged (a no-op write that leaves the live
  # value alone).
  reset = {
    for k, v in {
      auth_policy        = var.auth_policy
      ip_allowlist       = var.ip_allowlist
      operator_access    = var.operator_access
      actions_policy     = var.actions_policy
      artifact_retention = var.artifact_retention
      sla_policy         = var.sla_policy
      ssh_ca             = var.ssh_ca
      immutability       = var.immutability
      dependency_updates = var.dependency_updates
      security_fixes     = var.security_fixes
      # PUT replaces the whole policy, so the reset writes every field.
      image_signature_policy = var.image_signature_policy == null ? null : merge(local.defaults.image_signature_policy, var.image_signature_policy)
      dependency_proxy       = var.dependency_proxy
      exception_settings     = var.exception_settings
    } :
    k => v == null ? null : (
      var.reset_on_destroy ? { for f in keys(v) : f => local.defaults[k][f] } : v
    )
  }
}

# ============================================================================
# Singletons: every org has exactly one of each, it always exists, and there is
# no DELETE. "Create" is the same write as "update"; "destroy" is a write too
# (see reset_on_destroy). To bring an existing org under management without a
# write, import first: see docs.frem.sh/admin/configuration-as-code/#bring-an-existing-org-under-management.
# ============================================================================

# --- Authentication policy --------------------------------------------------
resource "restful_resource" "auth_policy" {
  count = var.auth_policy == null ? 0 : 1

  path                 = "${local.org}/auth-policy"
  create_method        = "PATCH"
  update_method        = "PATCH"
  delete_method        = "PATCH"
  merge_patch_disabled = true

  body        = var.auth_policy
  delete_body = local.reset.auth_policy
}

# --- IP allowlist -----------------------------------------------------------
# PUT replaces the whole list; it is read back from GET /orgs/{org}/security.
# An enforcing list that does not contain the caller's own address is refused
# (400 invalid-allowlist), so tofu/terraform cannot lock out the machine it
# runs on. Roll out with audit_only = true first.
resource "restful_resource" "ip_allowlist" {
  count = var.ip_allowlist == null ? 0 : 1

  path          = "${local.org}/security/ip-policy"
  read_path     = "${local.org}/security"
  read_selector = "ip_allowlist"
  create_method = "PUT"
  update_method = "PUT"
  update_path   = "${local.org}/security/ip-policy"
  delete_method = "PUT"
  delete_path   = "${local.org}/security/ip-policy"

  body        = var.ip_allowlist
  delete_body = local.reset.ip_allowlist
}

# --- Operator access --------------------------------------------------------
resource "restful_resource" "operator_access" {
  count = var.operator_access == null ? 0 : 1

  path                   = "${local.org}/security/operator-access"
  read_path              = "${local.org}/security"
  read_response_template = "{\"allow\": $(body.allow_operator_user_management)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/security/operator-access"
  delete_method          = "PUT"
  delete_path            = "${local.org}/security/operator-access"

  body        = var.operator_access
  delete_body = local.reset.operator_access
}

# --- Email-domain allowlist (on/off) ----------------------------------------
resource "restful_resource" "email_domain_policy" {
  count = var.email_domain_allowlist_enforced == null ? 0 : 1

  path                   = "${local.org}/email-domains/policy"
  read_path              = "${local.org}/email-domains"
  read_response_template = "{\"enabled\": $(body.allowlist_enforced)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/email-domains/policy"
  delete_method          = "PUT"
  delete_path            = "${local.org}/email-domains/policy"

  body        = { enabled = var.email_domain_allowlist_enforced }
  delete_body = { enabled = var.reset_on_destroy ? true : var.email_domain_allowlist_enforced }
}

# --- SSH certificate authority ----------------------------------------------
resource "restful_resource" "ssh_ca" {
  count = var.ssh_ca == null ? 0 : 1

  path          = "${local.org}/ssh-ca"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.ssh_ca
  delete_body = local.reset.ssh_ca
}

# --- Branch-protection defaults ---------------------------------------------
# Applied to every new repository, and by an hourly sweep to any repository
# with no rule at all. Destroy with reset_on_destroy = true restores the
# fremforge baseline (POST /orgs/{org}/branch-defaults/reset).
resource "restful_resource" "branch_protection_defaults" {
  count = var.branch_protection_defaults == null ? 0 : 1

  path          = "${local.org}/policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = var.reset_on_destroy ? "POST" : "PUT"
  delete_path   = var.reset_on_destroy ? "${local.org}/branch-defaults/reset" : "${local.org}/policy"

  body        = { branch_protection = var.branch_protection_defaults }
  delete_body = var.reset_on_destroy ? {} : { branch_protection = var.branch_protection_defaults }
}

# --- Org-wide scan policy ---------------------------------------------------
resource "restful_resource" "scan_policy" {
  count = var.scan_policy == null ? 0 : 1

  path          = "${local.org}/policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body = { scan_policy = var.scan_policy }
  # There is no DELETE for a scanner's org-wide row. Reset writes the plan
  # default (enforce, with the scanner's default severity) for each scanner you
  # manage; the row stays, so GET still lists those scanners.
  delete_body = { scan_policy = var.reset_on_destroy ? { for k, v in var.scan_policy : k => { mode = "enforce", block_severity = null } } : var.scan_policy }
}

# --- Actions policy ---------------------------------------------------------
resource "restful_resource" "actions_policy" {
  count = var.actions_policy == null ? 0 : 1

  path                   = "${local.org}/security/actions-policy"
  read_path              = "${local.org}/security"
  read_response_template = "{\"only_protected_branches\": $(body.actions_only_protected_branches)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/security/actions-policy"
  delete_method          = "PUT"
  delete_path            = "${local.org}/security/actions-policy"

  body        = var.actions_policy
  delete_body = local.reset.actions_policy
}

# --- Actions artifact retention ---------------------------------------------
resource "restful_resource" "artifact_retention" {
  count = var.artifact_retention == null ? 0 : 1

  path                   = "${local.org}/artifact-retention"
  read_response_template = "{\"days\": $(body.retention_days)}"
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.artifact_retention
  delete_body = local.reset.artifact_retention
}

# --- Dependency updates: Version updates -------------------------------------
# GET/PUT /orgs/{org}/dependency-updates/version-updates/settings: snake_case
# both ways, the read wrapped in `settings`. `schedule` and `merge_window` are
# not managed here: both are null by default, and the provider's response
# template cannot render a null. (Until 2026-10-15 this wrote
# /dependency-updates/settings, which now answers with Deprecation headers and
# still takes security_automerge; that setting is `merge` under security_fixes.)
resource "restful_resource" "dependency_updates" {
  count = var.dependency_updates == null ? 0 : 1

  path                   = "${local.org}/dependency-updates/version-updates/settings"
  read_response_template = <<-EOT
    {
      "enabled": $(body.settings.enabled),
      "automerge_patch": $(body.settings.automerge_patch),
      "automerge_minor": $(body.settings.automerge_minor),
      "major_approval": $(body.settings.major_approval),
      "pr_hourly_limit": $(body.settings.pr_hourly_limit),
      "pr_concurrent_limit": $(body.settings.pr_concurrent_limit)
    }
  EOT
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.dependency_updates
  delete_body = local.reset.dependency_updates
}

# --- Dependency updates: Security fixes --------------------------------------
# GET/PUT /orgs/{org}/dependency-updates/security-fixes/settings. `repos` is not
# managed here (the response template renders scalars only); set it in the
# console or with the fremforge provider's fremforge_dependency_updates. The lane
# switch (who opens security PRs) is an owner decision and is not managed.
resource "restful_resource" "security_fixes" {
  count = var.security_fixes == null ? 0 : 1

  path                   = "${local.org}/dependency-updates/security-fixes/settings"
  read_response_template = <<-EOT
    {
      "mode": "$(body.settings.mode)",
      "include_unknown": $(body.settings.include_unknown),
      "merge": "$(body.settings.merge)",
      "max_open_per_repo": $(body.settings.max_open_per_repo),
      "max_per_day": $(body.settings.max_per_day),
      "soak_days": $(body.settings.soak_days)
    }
  EOT
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.security_fixes
  delete_body = local.reset.security_fixes
}

# --- Scanner settings (SAST, dependencies, images, secrets, licences) -------
resource "restful_resource" "scanner_settings" {
  for_each = var.scanner_settings

  path          = "${local.org}/code-security/${each.key}/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body = each.value
  delete_body = var.reset_on_destroy ? {
    for f, v in each.value : f => try(local.defaults.scanner_settings[each.key][f], v)
  } : each.value
}

# --- Licence policy overrides per repository ---------------------------------
# A field that is set replaces the org's value for that repository; null
# inherits it. Destroy removes the override (the repository inherits again).
resource "restful_resource" "licence_repo_overrides" {
  for_each = var.licence_repo_overrides

  path          = "${local.org}/repos/${each.key}/code-security/licenses/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = each.value
}

# --- Push protection: what a push does when the secret scanner cannot run --
resource "restful_resource" "push_protection_failure_mode" {
  count = var.push_protection_failure_mode == null ? 0 : 1

  path          = "${local.org}/push-protection/failure-mode"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = { mode = var.push_protection_failure_mode }
  delete_body = { mode = var.reset_on_destroy ? local.defaults.push_protection_failure_mode : var.push_protection_failure_mode }
}

# --- Image signature enforcement at pull -----------------------------------
# PUT replaces the whole policy: a field left out of the body is set to its
# default, so the module always sends every field (the defaults merged with
# yours). mode = "enforce" refuses unsigned pulls; roll out in "shadow" first.
resource "restful_resource" "image_signature_policy" {
  count = var.image_signature_policy == null ? 0 : 1

  path          = "${local.org}/image-signature-policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = merge(local.defaults.image_signature_policy, var.image_signature_policy)
  delete_body = local.reset.image_signature_policy
}

# --- Exception settings: expiry cap, approvers, dismissals need approval ----
# dismissals_require_approval = true: a finding dismissal by someone who is not
# an approver becomes an exception request, and the finding stays open until an
# approver other than the requester approves it.
resource "restful_resource" "exception_settings" {
  count = var.exception_settings == null ? 0 : 1

  path          = "${local.org}/exceptions/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.exception_settings
  delete_body = local.reset.exception_settings
}

# --- Immutable releases and container tags ----------------------------------
# WARNING: releases = true cannot be undone for the releases published while it
# is on. Setting it back to false stops new releases being frozen; it unfreezes
# nothing.
resource "restful_resource" "immutability" {
  count = var.immutability == null ? 0 : 1

  path          = "${local.org}/code-security/immutability"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.immutability
  delete_body = local.reset.immutability
}

# --- Dependency proxy -------------------------------------------------------
# GET returns usage, cap and platform fields as well; only the fields set in
# var.dependency_proxy are sent and compared.
resource "restful_resource" "dependency_proxy" {
  count = var.dependency_proxy == null ? 0 : 1

  path          = "${local.org}/dependency-proxy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.dependency_proxy
  delete_body = local.reset.dependency_proxy
}

# --- Security SLA policy ----------------------------------------------------
resource "restful_resource" "sla_policy" {
  count = var.sla_policy == null ? 0 : 1

  path          = "${local.org}/security/sla-policy"
  read_path     = "${local.org}/security"
  read_selector = "sla_policy"
  create_method = "PUT"
  update_method = "PUT"
  update_path   = "${local.org}/security/sla-policy"
  delete_method = "PUT"
  delete_path   = "${local.org}/security/sla-policy"

  body        = var.sla_policy
  delete_body = local.reset.sla_policy
}

# ============================================================================
# Collections: ordinary create / read / delete.
# ============================================================================

# --- Allowed email domains --------------------------------------------------
resource "restful_resource" "email_domain" {
  for_each = var.email_domains

  path          = "${local.org}/email-domains"
  read_path     = "${local.org}/email-domains"
  read_selector = "patterns.#(id==\"$(body.id)\")"
  delete_path   = "${local.org}/email-domains/$(body.id)"
  output_attrs  = ["id", "pattern"]

  body = { pattern = each.value }
}

# --- SSO domains (registration only; verification is manual) ----------------
resource "restful_resource" "sso_domain" {
  for_each = var.sso_domains

  path      = "${local.org}/sso/domains"
  read_path = "${local.org}/sso/domains"
  # GET lists every registration under `challenges` (pending, verified or
  # expired; the row and its id stay after verification).
  read_selector = "challenges.#(id==\"$(body.id)\")"
  delete_path   = "${local.org}/sso/domains/$(body.id)"
  output_attrs  = ["id", "domain", "status"]

  body = { domain = each.value }
}

# --- Webhook destinations ---------------------------------------------------
# The signing secret is returned once at create. output_attrs keeps it out of
# state; read it from the create response in the console instead, or rotate it
# with POST .../rotate.
resource "restful_resource" "webhook" {
  for_each = var.webhooks

  path          = "${local.org}/webhooks/destinations"
  read_path     = "${local.org}/webhooks/destinations"
  read_selector = "destinations.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/webhooks/destinations/$(body.id)"
  delete_path   = "${local.org}/webhooks/destinations/$(body.id)"
  output_attrs  = ["id", "name", "target_url", "events", "repo_filter_regex", "active"]

  body = merge({ name = each.key }, each.value)

  force_new_attrs = ["name", "target_url", "events", "repo_filter_regex"]
}

# --- SIEM forwarding endpoints ----------------------------------------------
# Two resources, split on whether the endpoint has an auth header. magodo/restful
# 0.25.2 forgets an ephemeral_body's fingerprint after an in-place update, so a
# resource that sets the attribute at all -- even to null, which a conditional
# types as a null object -- plans an empty PATCH on every run after its first
# update, and the API refuses it (400). Endpoints without a header therefore
# never set ephemeral_body.
locals {
  siem_auth_names = nonsensitive(toset(keys(var.siem_auth_headers)))
}

resource "restful_resource" "siem_endpoint" {
  for_each = { for k, v in var.siem_endpoints : k => v if !contains(local.siem_auth_names, k) }

  path          = "${local.org}/siem/endpoints"
  read_path     = "${local.org}/siem/endpoints"
  read_selector = "endpoints.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/siem/endpoints/$(body.id)"
  delete_path   = "${local.org}/siem/endpoints/$(body.id)"
  output_attrs  = ["id", "name", "url", "action_prefixes", "enabled", "signature_mode", "auth_header_name"]

  body = merge({ name = each.key }, each.value)

  force_new_attrs = ["name", "url", "action_prefixes", "signature_mode", "batch"]
}

resource "restful_resource" "siem_endpoint_with_auth" {
  for_each = { for k, v in var.siem_endpoints : k => v if contains(local.siem_auth_names, k) }

  path          = "${local.org}/siem/endpoints"
  read_path     = "${local.org}/siem/endpoints"
  read_selector = "endpoints.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/siem/endpoints/$(body.id)"
  delete_path   = "${local.org}/siem/endpoints/$(body.id)"
  output_attrs  = ["id", "name", "url", "action_prefixes", "enabled", "signature_mode", "auth_header_name"]

  body = merge({ name = each.key }, each.value)

  # The auth header value is sent once and never stored in state. After an
  # in-place change the next plan shows one more update, which re-sends the
  # header; the plan after that is clean (the same provider behaviour).
  ephemeral_body = { auth_header = var.siem_auth_headers[each.key] }

  force_new_attrs = ["name", "url", "action_prefixes", "signature_mode", "batch"]
}

# --- Deployment environments ------------------------------------------------
resource "restful_resource" "environment" {
  for_each = var.environments

  path         = "${local.org}/environments"
  read_path    = "${local.org}/environments/${each.value}"
  delete_path  = "${local.org}/environments/${each.value}"
  output_attrs = ["name"]

  body = { name = each.value }
}

# --- Org-level Actions secrets ----------------------------------------------
# The value is write-only: sent on create, never read back, never in state. A
# changed value is therefore not detected by plan; to rotate, replace the
# resource (`-replace=...`).
resource "restful_resource" "org_secret" {
  for_each = nonsensitive(toset(keys(var.org_secrets)))

  path          = "${local.org}/secrets/${each.key}"
  create_method = "PUT"
  read_path     = "${local.org}/secrets"
  read_selector = "secrets.#(name==\"${each.key}\")"
  delete_path   = "${local.org}/secrets/${each.key}"
  output_attrs  = ["name"]

  body           = { name = each.key }
  ephemeral_body = { data = var.org_secrets[each.key] }
}

# ============================================================================
# Package access: per-package access and protected names.
# ============================================================================

locals {
  package_refs = {
    for k, v in var.packages : k => {
      type = split("/", k)[0]
      name = join("/", slice(split("/", k), 1, length(split("/", k))))
      path = "${local.org}/packages/${split("/", k)[0]}/${urlencode(join("/", slice(split("/", k), 1, length(split("/", k)))))}/access"
    }
  }
  package_team_grants = merge([
    for k, v in var.packages : { for team, perm in v.teams : "${k}#${team}" => { pkg = k, team = team, permission = perm } }
  ]...)
  package_actions_grants = merge([
    for k, v in var.packages : { for repo, perm in v.actions : "${k}#${repo}" => { pkg = k, repo = repo, permission = perm } }
  ]...)
}

# --- Organisation default ---------------------------------------------------
resource "restful_resource" "package_access_default" {
  count = var.package_access_default == null ? 0 : 1

  path          = "${local.org}/packages/access-settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = { default_mode = var.package_access_default }
  delete_body = { default_mode = var.reset_on_destroy ? "legacy" : var.package_access_default }
}

# --- One package's settings -------------------------------------------------
resource "restful_resource" "package_access" {
  for_each = var.packages

  path          = local.package_refs[each.key].path
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { mode = each.value.mode, inherit_repo = each.value.inherit_repo }
}

# --- Team grants on a package -----------------------------------------------
resource "restful_resource" "package_team_grant" {
  for_each = local.package_team_grants

  path      = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  read_path = local.package_refs[each.value.pkg].path
  # The resource id is the read path (the package); writes and the delete must
  # name the grant itself, or they would hit the package's own settings.
  update_path   = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  delete_path   = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  read_selector = "grants.teams.#(team==\"${each.value.team}\")"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { permission = each.value.permission }

  depends_on = [restful_resource.package_access]
}

# --- Actions grants on a package --------------------------------------------
resource "restful_resource" "package_actions_grant" {
  for_each = local.package_actions_grants

  path      = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  read_path = local.package_refs[each.value.pkg].path
  # The resource id is the read path (the package); writes and the delete must
  # name the grant itself, or they would hit the package's own settings.
  update_path   = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  delete_path   = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  read_selector = "grants.actions.#(repository==\"${each.value.repo}\")"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { permission = each.value.permission }

  depends_on = [restful_resource.package_access]
}

# --- Protected package names ------------------------------------------------
resource "restful_resource" "package_protection_rule" {
  for_each = var.package_protection_rules

  path          = "${local.org}/packages/protection-rules"
  read_path     = "${local.org}/packages/protection-rules"
  read_selector = "rules.#(id==\"$(body.id)\")"
  update_method = "PUT"
  update_path   = "${local.org}/packages/protection-rules/$(body.id)"
  delete_path   = "${local.org}/packages/protection-rules/$(body.id)"
  output_attrs  = ["id", "type", "name_pattern"]

  body = each.value
}
Uitvoer van de module: outputs.tf
terraform/modules/fremforge-org/outputs.tf
output "webhook_ids" {
  description = "Webhook destination IDs by name."
  value       = { for k, r in restful_resource.webhook : k => r.output.id }
}

output "siem_endpoint_ids" {
  description = "SIEM endpoint IDs by name."
  value       = merge({ for k, r in restful_resource.siem_endpoint : k => r.output.id }, { for k, r in restful_resource.siem_endpoint_with_auth : k => r.output.id })
}
Providerpin van de module: versions.tf
terraform/modules/fremforge-org/versions.tf
# fremforge org settings as code: a module for Terraform and OpenTofu.
#
# Every fremforge setting this module manages goes through the fremforge API
# (https://frem.sh/_app/api/v1) with the generic magodo/restful provider. The
# module declares no provider block; the caller configures `restful` (see
# ../../example/providers.tf) and passes it in.
#
# Provider licence: magodo/restful is MPL-2.0. Tested with Terraform 1.16 and
# OpenTofu 1.13 (docs.frem.sh/admin/configuration-as-code/).

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    restful = {
      source  = "magodo/restful"
      version = "0.25.2"
    }
  }
}

De module aanroepen

Een rootmodule die één organisatie van begin tot eind configureert. Vervang de voorbeeld-URL’s en CIDR’s door je eigen.

terraform/example/main.tf
# A whole org's fremforge settings in one module call. Every argument is
# optional; drop the ones you do not want to manage.
module "org" {
  source = "../modules/fremforge-org"

  org              = var.org
  reset_on_destroy = var.reset_on_destroy

  # Authentication and access
  auth_policy = {
    max_pat_lifetime_days  = 30
    ssh_disabled           = true
    allow_user_pats        = false
    allow_repo_deploy_keys = false
    require_signed_commits = false
  }
  ip_allowlist = {
    cidrs                  = var.office_cidrs
    audit_only             = true
    allow_platform_runners = true
  }
  operator_access                 = { allow = true }
  email_domain_allowlist_enforced = true
  email_domains                   = ["example.com"]
  ssh_ca = {
    ca_keys      = []
    require_cert = false
  }

  # Repository and Actions defaults
  branch_protection_defaults = {
    enabled                   = true
    required_approvals        = 1
    dismiss_stale_reviews     = true
    require_status_checks     = true
    require_pull_request      = true
    require_codeowners_review = false
    merge_queue               = "default"
  }
  actions_policy     = { only_protected_branches = true }
  artifact_retention = { days = 30 }
  dependency_updates = {
    automerge_patch = true
    automerge_minor = false
    major_approval  = true
  }
  security_fixes = {
    mode  = "auto_important"
    merge = "review"
  }

  # Code security
  scan_policy = {
    dep_scan = { mode = "enforce", block_severity = "critical" }
    sast     = { mode = "enforce", block_severity = "error" }
  }
  scanner_settings = {
    sast     = { enabled = true, block_severity = "error" }
    deps     = { enabled = true }
    images   = { enabled = true }
    secrets  = { enabled = true }
    licenses = { enabled = true }
  }
  push_protection_failure_mode = "block"
  exception_settings = {
    max_days                    = 180
    approver_team               = null
    dismissals_require_approval = true
  }
  immutability = {
    releases           = false
    allow_repo_opt_out = false
    oci_mode           = "off"
    oci_mutable_tags   = []
  }
  image_signature_policy = {
    enabled = true
    mode    = "shadow"
    identities = [{
      issuer        = "https://frem.sh/_app/runner/oidc"
      subject_regex = "https://frem\\.sh/${var.org}/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/heads/main"
    }]
    scope = []
  }
  sla_policy = {
    critical_sla_days = 7
    high_sla_days     = 30
    medium_sla_days   = 90
    low_sla_days      = null
  }

  # Integrations
  webhooks = {
    audit-feed = {
      target_url = var.webhook_url
      events     = ["push", "pull_request"]
      active     = true
    }
  }
  siem_endpoints = {
    siem = {
      url             = var.siem_url
      action_prefixes = ["auth.", "security."]
      enabled         = true
    }
  }
  environments = ["staging", "production"]
  org_secrets  = var.org_secrets

  # SSO domain registration. Verification (DNS or HTTP) stays a manual step.
  sso_domains = var.sso_domains
}
terraform/example/forgejo.tf
# Repositories and branch protection through Forgejo's own API. Delete this
# file (and the forgejo provider, see providers.tf) to manage only fremforge
# settings.
#
# fremforge manages part of every default-branch rule itself: a new repository
# gets the org's branch-protection defaults, and every scanner your scan policy
# sets to enforce adds its fremforge/<scanner> status context. List exactly
# those contexts here (docs.frem.sh/security/branch-protection/ has them all):
# leave one out and plan shows it as drift; add one nothing posts and merges
# block waiting for it.

resource "forgejo_repository" "service" {
  owner          = var.org
  name           = var.repo_name
  private        = true
  default_branch = "main"
  auto_init      = true

  # fremforge creates repositories with squash as the default merge style. The
  # provider's own default is "merge", so without this line every plan after
  # the first shows a change.
  default_merge_style = "squash"
}

resource "forgejo_branch_protection" "service_main" {
  repository_id       = forgejo_repository.service.id
  branch_name         = "main"
  required_approvals  = 1
  enable_status_check = true
  # Every scanner enforces by default, so fremforge adds all of these to the
  # rule soon after the repository is created (in this order). A shorter list
  # shows as drift on a later plan.
  status_check_contexts = [
    "fremforge/cosign-verify",
    "fremforge/dep-scan",
    "fremforge/image-scan",
    "fremforge/license-scan",
    "fremforge/release-age",
    "fremforge/sast",
    "fremforge/secret-scan",
  ]
}
Invoer van het voorbeeld: variables.tf
terraform/example/variables.tf
variable "org" {
  description = "Your fremforge organisation slug, as in frem.sh/<org>."
  type        = string
}

variable "fremforge_token" {
  description = "fremforge API token (ffp_...): an org API token, or a one-hour token from the OIDC token exchange."
  type        = string
  sensitive   = true
}

variable "forgejo_token" {
  description = "Forgejo access token for the repository part (forgejo.tf). Required while forgejo.tf is in the configuration."
  type        = string
  sensitive   = true
}

variable "office_cidrs" {
  description = "CIDR ranges for the IP allowlist. Kept in audit-only mode in this example."
  type        = list(string)
  default     = ["192.0.2.0/24"]
}

variable "siem_url" {
  description = "Where SIEM events are forwarded. Must be a public HTTPS URL."
  type        = string
  default     = "https://siem.example.com/fremforge"
}

variable "webhook_url" {
  description = "Where org webhooks are delivered. Must be a public HTTPS URL."
  type        = string
  default     = "https://hooks.example.com/fremforge"
}

variable "reset_on_destroy" {
  description = "true only for a throwaway test org: destroy then restores platform defaults."
  type        = bool
  default     = false
}

variable "repo_name" {
  description = "Name of the example repository. A deleted repository stays in the org's trash for its restore window, so reuse of a name can fail until it is purged."
  type        = string
  default     = "iac-example-service"
}

variable "org_secrets" {
  description = "Org-level Actions secrets, { NAME = \"value\" }. Pass them from your secret store (TF_VAR_org_secrets); values are write-only and never stored in state."
  type        = map(string)
  default     = {}
  sensitive   = true
}

variable "sso_domains" {
  description = "Domains to register for SSO, e.g. [\"acme.com\"]. Registration only: verify each one in the console."
  type        = set(string)
  default     = []
}

Elk endpoint en hoe het overeenkomt

Instelling, invoer van de moduleAPI (schrijven, dan lezen)
Authenticatiebeleid, auth_policyPATCH, GET /orgs/{org}/auth-policy
IP-toegestane lijst, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Operatortoegang, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
Toegestane lijst van e-maildomeinen aan/uit, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Toegestane e-maildomeinen, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
SSH-certificaatautoriteit, ssh_caPUT, GET /orgs/{org}/ssh-ca
SSO-domeinen, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Standaardinstellingen voor branchbescherming, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Scanbeleid voor de hele organisatie, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Actions-beleid, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Bewaartermijn van artefacten, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Updates van afhankelijkheden: Versie-updates, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Updates van afhankelijkheden: Beveiligingsfixes, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Scannerinstellingen, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Licentiebeleid per repository, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Foutmodus van de push-bescherming, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Instellingen voor uitzonderingen (maximale looptijd, team van goedkeurders, afwijzingen vereisen goedkeuring), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Onveranderlijke releases en tags, immutabilityPUT, GET /orgs/{org}/code-security/immutability
SLA-beleid voor beveiliging, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Afdwingen van image-handtekeningen, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (vervangt het hele beleid; de module verstuurt elk veld)
Proxy voor afhankelijkheden, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Webhookbestemmingen, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
SIEM-doorsturen, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Deploymentomgevingen, environmentsPOST, GET, DELETE /orgs/{org}/environments
Standaard pakkettoegang, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Toegang en rechten per pakket, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Beschermde pakketnamen, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Actions-secrets van de organisatie, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Repository’s, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Branchbescherming per repository, forgejo_branch_protectionForgejo /api/v1/repos/{org}/{repo}/branch_protections

Wat je per instelling moet weten:

  • Authenticatiebeleid: elke deelverzameling van velden. max_pat_lifetime_days accepteert null of een van 7, 30, 90, 180, 365; elke andere waarde wordt met 400 geweigerd.
  • IP-toegestane lijst: de PUT vervangt de hele lijst, dus stuur alle drie de velden mee. De eerste schrijfactie zet een organisatie die nog een oud bereik heeft (web of web+git) ook over naar all, één lijst voor elk oppervlak; de oude bereiken kun je niet meer kiezen. Een afdwingende lijst die je eigen adres niet bevat, wordt geweigerd met 400 invalid-allowlist, zodat de CLI de machine waarop hij draait niet kan buitensluiten. Begin met audit_only = true.
  • Standaardinstellingen voor branchbescherming: inclusief merge_queue (default, on, off) voor de regels die deze standaarden aanmaken.
  • Scanbeleid voor de hele organisatie: image_scan.block_severity is de blokkeerdrempel voor images bij merges en pulls: low, medium, high, critical of null (high). Andere waarden worden met 400 geweigerd.
  • Sommige instellingen worden onder andere namen geschreven dan gelezen (operatortoegang, Actions-beleid, bewaartermijn van artefacten, toegestane lijst van e-maildomeinen, updates van afhankelijkheden). De module zet ze om met een leessjabloon, zodat plan gelijke dingen vergelijkt.
  • Updates van afhankelijkheden: elk veld behalve schedule.
  • Onveranderlijke releases: releases = true is eenrichtingsverkeer voor de releases die worden gepubliceerd terwijl het aanstaat.
  • Proxy voor afhankelijkheden: standaard aan voor elke organisatie. malware_scan (standaard false) zet het scannen met ClamAV aan van wat de proxy ophaalt; block_until_scanned vereist dat, en een PUT die block_until_scanned aan zou laten met malware_scan uit, wordt geweigerd met 400 scan-pair (zie Ophalen via de dependency proxy). metadata_filter verbergt de versies die je pakketbeleid zou weigeren uit de pakketmetadata die de proxy levert (npm, PyPI, Maven, NuGet, Go, Helm en Cargo), zodat pakketbeheerders uitkomen op de nieuwste toegestane versie in plaats van bij de download te falen. Is geen enkele versie toegestaan, dan wordt de metadata volledig geleverd en wordt de download met de reden geweigerd. "shadow" verbergt niets en toont wat het zou verbergen onder GET /orgs/{org}/package-policy/decisions (metadata). "on" is voorlopig alleen beschikbaar voor interne organisaties van fremforge; elders wordt het geweigerd met 403 metadata-filter-internal-only.
  • Webhooks en SIEM-endpoints: alleen active (webhooks) of enabled (SIEM) verandert ter plekke. Elke andere wijziging vervangt de bestemming en maakt een nieuwe secret aan. De module beheert SIEM-endpoints met een auth-header in een aparte resource, siem_endpoint_with_auth; zie Beperkingen.
  • SSO-domeinen: alleen registratie. De module registreert elk domein en leest het terug uit de lijst challenges; de verificatie via DNS of HTTP is een handmatige stap in de console.
  • Branchbescherming op repository’s (forgejo_branch_protection): fremforge voegt een statuscontext fremforge/<scanner> toe voor elke scanner die je scanbeleid afdwingt, en elke scanner dwingt standaard af. Noem ze allemaal, in de volgorde die het voorbeeld gebruikt, anders toont een latere plan de regel als drift.
  • Actions-secrets van de organisatie: de waarde is write-only, dus plan ziet een gewijzigde waarde niet. Roteer met -replace. Geef de waarden mee vanuit je secretopslag, bijvoorbeeld TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Write-only-argumenten in het plan van OpenTofu: wanneer een restful_resource ter plekke verandert, toont OpenTofu ~ ephemeral_body = (write-only attribute) tussen de wijzigingen. Terraform laat die regel weg. Het is geen drift en er wordt niets extra verstuurd; de aantallen in Plan: zijn onder beide CLI’s gelijk.

Secrets blijven buiten de state. Ondertekeningssecrets van webhooks en HMAC-secrets van SIEM worden één keer teruggegeven bij het aanmaken. output_attrs van de module houdt ze uit de state; lees ze in de console of roteer ze via de API. Waarden van Actions-secrets en auth-headers voor SIEM worden als write-only-argumenten verstuurd en nooit opgeslagen.

Wat we testen

De module hierboven draait met beide CLI’s tegen een aparte fremforge-testorganisatie, met het script hieronder:

  1. apply van het voorbeeld;
  2. opnieuw plan -detailed-exitcode, dat met 0 moet eindigen (geen wijzigingen);
  3. een wijziging toepassen: kopieer test/change_override.tf erin, plan moet met 2 eindigen, apply voert de wijziging uit, en de plan daarna moet met 0 eindigen;
  4. destroy, met reset_on_destroy = true, waarna elke instelling weer wordt teruggelezen zoals vóór de run.
Het testscript: run-cycle.sh
terraform/run-cycle.sh
#!/usr/bin/env bash
# run-cycle.sh: the whole lifecycle of the example, with either CLI.
#
#   run-cycle.sh terraform   # or: run-cycle.sh tofu
#
# init -> apply -> plan again (must report no changes) -> apply a change
# (test/change_override.tf: plan must show it, apply makes it, the plan after
# must be clean) -> destroy. The no-change plans are the checks that matter:
# they prove every setting reads back exactly as it was written, which is what
# drift detection depends on.
#
# Expects TF_VAR_org, TF_VAR_fremforge_token and TF_VAR_forgejo_token in the
# environment, and TF_VAR_repo_name set to a name not used before (a destroyed
# repository sits in the org's trash under its old name). Set TF_VAR_org_secrets
# to cover org Actions secrets. Run it against a test org only: it sets
# reset_on_destroy, so destroy puts platform defaults back.
set -euo pipefail

cli="${1:?usage: run-cycle.sh terraform|tofu}"
case "$cli" in
  terraform | tofu) ;;
  *)
    echo "run-cycle.sh: unknown CLI '$cli' (want terraform or tofu)" >&2
    exit 2
    ;;
esac

cd "$(dirname "$0")/example"
export TF_VAR_reset_on_destroy=true
export TF_IN_AUTOMATION=1

# A fresh working directory per run: the two CLIs record providers under
# different registry hostnames, so neither may reuse the other's lock or state.
rm -rf .terraform .terraform.lock.hcl terraform.tfstate terraform.tfstate.backup
rm -f zz_change_override.tf

destroy_on_exit() {
  local rc=$?
  echo "== $cli destroy"
  "$cli" destroy -auto-approve -input=false -no-color || rc=1
  rm -f zz_change_override.tf
  exit "$rc"
}

# plan_expect <0|2> <label>: plan with -detailed-exitcode and fail unless it
# exits as expected (0 no changes, 2 changes).
plan_expect() {
  local want="$1" label="$2" rc
  echo "== $cli plan $label (expect exit $want)"
  set +e
  "$cli" plan -detailed-exitcode -input=false -no-color
  rc=$?
  set -e
  if [ "$rc" = "$want" ]; then
    echo "OK: plan $label exited $rc"
    return 0
  fi
  case "$rc" in
    0 | 2) echo "FAILED: plan $label exited $rc, expected $want" >&2 ;;
  esac
  return 1
}

echo "== $cli $("$cli" version | head -1)"
"$cli" init -input=false -no-color
"$cli" validate -no-color

trap destroy_on_exit EXIT

echo "== $cli apply"
"$cli" apply -auto-approve -input=false -no-color

plan_expect 0 "after apply" || exit 1

echo "== $cli apply a change"
cp ../test/change_override.tf zz_change_override.tf
plan_expect 2 "with the change" || exit 1
"$cli" apply -auto-approve -input=false -no-color
plan_expect 0 "after the change" || exit 1
De wijzigingsstap: test/change_override.tf
terraform/test/change_override.tf
# The "apply a change" step of run-cycle.sh. It is copied into ../example as
# zz_change_override.tf after the first no-drift plan, so the next plan must
# show exactly these changes, an apply must make them, and the plan after that
# must be clean again. Override files replace whole arguments, so each object
# is repeated in full with the changed fields marked.
module "org" {
  auth_policy = {
    max_pat_lifetime_days  = 7 # was 30; must be one the console offers
    ssh_disabled           = true
    allow_user_pats        = false
    allow_repo_deploy_keys = false
    require_signed_commits = false
  }
  ip_allowlist = {
    cidrs                  = ["192.0.2.0/24", "198.51.100.0/24"] # one added
    audit_only             = true
    allow_platform_runners = true
  }
  operator_access = { allow = false } # was true
  ssh_ca = {
    ca_keys      = []
    require_cert = false
  }
  branch_protection_defaults = {
    enabled                   = true
    required_approvals        = 2 # was 1
    dismiss_stale_reviews     = true
    require_status_checks     = true
    require_pull_request      = true
    require_codeowners_review = false
    merge_queue               = "default"
  }
  actions_policy     = { only_protected_branches = false } # was true
  artifact_retention = { days = 14 }                       # was 30
  dependency_updates = {
    automerge_patch = true
    automerge_minor = true # was false
    major_approval  = true
  }
  scan_policy = {
    dep_scan = { mode = "enforce", block_severity = "high" } # was critical
    sast     = { mode = "enforce", block_severity = "error" }
  }
  scanner_settings = {
    sast     = { enabled = true, block_severity = "warning" } # was error
    deps     = { enabled = true }
    images   = { enabled = true }
    secrets  = { enabled = true }
    licenses = { enabled = true }
  }
  push_protection_failure_mode = "allow" # was block
  exception_settings = {
    max_days                    = 90 # was 180
    approver_team               = null
    dismissals_require_approval = false # was true
  }
  immutability = {
    releases           = false
    allow_repo_opt_out = true # was false
    oci_mode           = "off"
    oci_mutable_tags   = []
  }
  image_signature_policy = {
    enabled = true
    mode    = "shadow"
    identities = [{
      issuer        = "https://frem.sh/_app/runner/oidc"
      subject_regex = "https://frem\\.sh/${var.org}/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/heads/main"
    }]
    scope = ["api"] # was []
  }
  sla_policy = {
    critical_sla_days = 3 # was 7
    high_sla_days     = 30
    medium_sla_days   = 90
    low_sla_days      = null
  }
  webhooks = {
    audit-feed = {
      target_url = var.webhook_url
      events     = ["push", "pull_request"]
      active     = false # was true: changes in place
    }
  }
  siem_endpoints = {
    siem = {
      url             = var.siem_url
      action_prefixes = ["auth.", "security."]
      enabled         = false # was true: changes in place
    }
  }
  environments = ["staging", "production", "qa"] # one added
}

Laatste run, 2026-10-06, HashiCorp Terraform 1.16.5 (de huidige release, van releases.hashicorp.com, SHA-256 gecontroleerd) en OpenTofu 1.13.1 tegen frem.sh (Forgejo v15), met een token van de organisatie met policy:write en sso:read/sso:write:

InstellingenTerraform 1.16.5OpenTofu 1.13.1
Authenticatiebeleid, IP-toegestane lijst, operatortoegang, toegestane lijst van e-maildomeinen en domeinen, SSH-CA, standaardinstellingen voor branchbescherming, scanbeleid, Actions-beleid, bewaartermijn van artefacten, SLA-beleid, scannerinstellingen, foutmodus van de push-bescherming, onveranderlijke releases, updates van afhankelijkheden, webhookbestemmingen, SIEM-endpoints, deploymentomgevingen, Actions-secrets van de organisatie, SSO-domeinenapply, plan zonder wijzigingen, wijziging (plan exit 2, apply, plan exit 0), destroy: geslaagdapply, plan zonder wijzigingen, wijziging (plan exit 2, apply, plan exit 0), destroy: geslaagd
Repository en branchbescherming (svalabs/forgejo)apply, plan zonder wijzigingen, destroy: geslaagdapply, plan zonder wijzigingen, destroy: geslaagd
Wijziging buiten Terraform om (een webhook uitgeschakeld en een instelling van de updates van afhankelijkheden gewijzigd via de API)plan exit 2 toont beide, apply herstelt beide, plan exit 0plan exit 2 toont beide, apply herstelt beide, plan exit 0

Elke instelling in de module is live gedraaid: 27 resources toegepast, 15 gewijzigd in de wijzigingsstap, 28 verwijderd, onder elke CLI. De twee CLI’s gedroegen zich bij elke stap hetzelfde. De enige verschillen zijn de controle van de providerhandtekening bij init en één regel in de uitvoer van het plan, allebei hierboven beschreven. Niet gedekt door de run: SIEM-endpoints met een auth-header, en de verificatie van SSO-domeinen. De invoer dependency_proxy is later toegevoegd (2026-10-10) en apart gedraaid met OpenTofu tegen een testorganisatie met de proxy voor afhankelijkheden aan: aanmaken, een plan zonder wijzigingen, een wijziging van "shadow" naar "on", een plan zonder wijzigingen en destroy, dat metadata_filter terugzette op "off".

Wat destroy met reset_on_destroy = true achterlaat, vergeleken met een organisatie die nooit iets heeft ingesteld: de standaardinstellingen voor branchbescherming zijn de basis van fremforge in plaats van niet ingesteld, het scanbeleid houdt een rij per scanner die je beheerde op enforce (hetzelfde als de standaard van het plan), het SLA-beleid wordt teruggelezen als een object met null-waarden in plaats van null, en scannerinstellingen worden teruggelezen als configured: true met hun standaardwaarden. Elk gedraagt zich hetzelfde als de onaangeroerde standaard.

Uitvoeren

export TF_VAR_org=acme
export TF_VAR_fremforge_token="ffp_..."    # from your secret store, never committed
export TF_VAR_forgejo_token="..."          # required while forgejo.tf is in the configuration

terraform init && terraform plan && terraform apply
# or
tofu init && tofu plan && tofu apply

Een bestaande organisatie onder beheer brengen

Elke organisatie heeft al haar instellingen die maar één keer bestaan (authenticatiebeleid, IP-toegestane lijst enzovoort). De “create” van de module voor zo’n instelling is dezelfde schrijfactie als een update, dus de eerste apply schrijft je geconfigureerde waarden over de live waarden heen. Wil je het verschil eerst zien, importeer dan elke zo’n instelling en voer daarna plan uit. Bij de restful-provider noemt de import-ID het leespad en de velden die je beheert:

import {
  to = module.org.restful_resource.auth_policy[0]
  id = jsonencode({
    id   = "/orgs/acme/auth-policy"
    path = "/orgs/acme/auth-policy"
    body = {
      max_pat_lifetime_days  = null
      ssh_disabled           = null
      allow_user_pats        = null
      allow_repo_deploy_keys = null
      require_signed_commits = null
    }
  })
}

De sleutels in body moeten overeenkomen met de velden die je in de invoer van de module zet. Na de import toont plan alleen de velden waarvan de live waarde afwijkt van je configuratie. Doe hetzelfde voor de andere instellingen die maar één keer bestaan, met read_selector erbij waar de tabel hierboven uit GET /orgs/{org}/security leest.

Voor de regel voor branchbescherming van een bestaande repository:

import {
  to = forgejo_branch_protection.service_main
  id = "acme/payments-service/main"
}

Driftdetectie

Iemand wijzigt een instelling in de console, en de volgende plan laat het zien. -detailed-exitcode maakt daar iets van waarop CI kan reageren: exit 0 betekent geen wijzigingen, 2 betekent drift, 1 betekent een fout. Dit is wat we op 2026-10-03 met OpenTofu draaiden, met twee wijzigingen via de API als vervanging voor bewerkingen in de console. Op 2026-10-06 gaven beide CLI’s dezelfde exitcodes bij dezelfde test met een webhook en een instelling van de updates van afhankelijkheden:

$ curl -X PUT .../code-security/immutability -d '{"allow_repo_opt_out":false}'
$ curl -X PATCH .../webhooks/destinations/<id> -d '{"active":false}'

$ tofu plan -detailed-exitcode            # exit 2
  ~ resource "restful_resource" "immutability" {
      ~ body = {
          ~ allow_repo_opt_out = false -> true
  ~ resource "restful_resource" "webhook" {
      ~ body = {
          ~ active = false -> true

$ tofu apply
Apply complete! Resources: 0 added, 2 changed, 0 destroyed.

$ tofu plan -detailed-exitcode            # exit 0
No changes. Your infrastructure matches the configuration.

Een geplande job die plan -detailed-exitcode draait met een token met alleen leesrechten en waarschuwt bij exit 2, geeft je een driftalarm zonder schrijftoegang. Elke wijziging via de API staat bovendien in je auditlog onder het token dat haar uitvoerde.

Destroy

De instellingen die maar één keer bestaan, kunnen niet worden verwijderd, dus destroy is ook een schrijfactie. reset_on_destroy van de module bepaalt welke:

  • false (de standaard): destroy schrijft je huidige waarden terug, dus er verandert niets en de instelling verdwijnt alleen uit de state. Gebruik dit voor een organisatie waarin mensen werken.
  • true: destroy schrijft voor elk veld dat je beheert de standaard van het platform, en roept voor de standaardinstellingen voor branchbescherming POST /orgs/{org}/branch-defaults/reset aan. Gebruik dit voor een testorganisatie.

Verzamelingen (webhooks, SIEM-endpoints, omgevingen, e-maildomeinen, SSO-domeinen, secrets) worden verwijderd. Een verwijderde repository gaat voor de herstelperiode naar de prullenbak van de organisatie, en direct na een destroy een repository met dezelfde naam aanmaken kan mislukken zolang het verwijderen nog loopt; geef een repository die je herhaaldelijk aanmaakt en verwijdert dus een nieuwe naam.

Wil je stoppen met het beheren van een instelling zonder haar aan te raken, gebruik dan een removed-blok:

removed {
  from = module.org.restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

Niet gedekt

Wat deze module niet beheert, en waarom:

  • Teams en teamlidmaatschap. De Forgejo-provider heeft forgejo_team en forgejo_team_member, maar een teamwijziging vereist een Forgejo-token met bereik op de organisatie, en dat biedt de pagina Git-PAT’s niet. We hebben ze niet op fremforge getest.
  • SSO-auth-bronnen (OIDC). Die bevatten een client secret, en voor het registreren ervan zijn een geverifieerd domein en een werkende IdP nodig, dus ze zitten niet in onze testorganisatie. De API (/orgs/{org}/sso) ondersteunt het registreren en bijwerken van de koppeling van groepen aan teams.
  • Eigen SSO-afdwinging (de opt-in voor verse herauthenticatie per organisatie). Daar is geen API voor.
  • SCIM, OAuth-clients, CI-bots, API-tokens. Elke aanmaak geeft één keer een credential terug. Die in de state bewaren is de verkeerde afweging.
  • Leden en gebruikers. Een gebruiker toevoegen maakt een account voor een persoon aan; uitnodigingen versturen e-mail. Lees ze met een restful_resource-databron op GET /orgs/{org}/seats.
  • Uitzonderingen per repository (scanbeleid, opt-outs van onveranderlijkheid, openbare documentatie), tijdgebonden uitzonderingen (uitzonderingen op push-bescherming en malwarescan, afgewezen bevindingen), beveiligingsoverzichten en meldingsvoorkeuren.
  • Omgevingsvariabelen, secrets, poorten en branchregels binnen een omgeving. De module maakt alleen de omgeving aan.
  • Het veld schedule van de updates van afhankelijkheden. Het is standaard null, en het leessjabloon van de provider kan geen null weergeven.
  • Alles wat niet in de OpenAPI-specificatie staat. Een onbekend pad antwoordt met 501, wat ook een typfout kan betekenen.

Beperkingen van deze aanpak

  • De generieke provider weet niets van fremforge. Hij kan niet waarschuwen dat releases = true eenrichtingsverkeer is en kan een body niet vóór apply valideren. Elke weigering komt bij het toepassen binnen als de foutmelding van de API. De eigen controles van de API gelden nog steeds, zoals de controle op buitensluiten bij de IP-toegestane lijst.
  • De Forgejo-provider is een provider van derden voor Forgejo in het algemeen. Een Forgejo-release kan zijn gedrag veranderen zonder dat wij dat melden. Wij pinnen de versie en testen haar.
  • SIEM-endpoints met een auth-header. magodo/restful 0.25.2 vergeet na een update ter plekke de fingerprint van het write-only ephemeral_body van een resource. Een resource die het argument überhaupt zet, plant daarna bij elke volgende run een lege PATCH, die de API met 400 weigert. De module zet ephemeral_body daarom alleen op siem_endpoint_with_auth. Voor die endpoints toont het plan na een wijziging ter plekke nog één update, die de header opnieuw verstuurt; het plan daarna is schoon. Dit geval hebben we niet live gedraaid.
  • De state bevat leesantwoorden. De module houdt secrets buiten de state zoals hierboven beschreven, maar de state beschrijft nog steeds je beveiligingsconfiguratie. Gebruik een backend die je vertrouwt, en de state-versleuteling van OpenTofu als je OpenTofu gebruikt.

Heb je een instelling nodig die deze pagina niet dekt, laat het ons weten via support@frem.sh.

Gerelateerd