Zum Hauptinhalt springen

Konfiguration als Code mit Terraform und OpenTofu

Du kannst deine fremforge-Organisation mit HashiCorp Terraform oder OpenTofu als Code verwalten. Beide CLIs führen dieselbe Konfiguration aus, mit zwei Open-Source-Providern aus den öffentlichen Registrys:

  • Forgejo-Objekte (Repositorys, Branch-Schutz, Teams, Actions-Secrets und -Variablen) über die eigene REST-API von Forgejo, mit svalabs/forgejo.
  • fremforge-Einstellungen (Authentifizierungsrichtlinie, IP-Allowlist, Standards für den Branch-Schutz, Scan-Richtlinie und Scanner-Einstellungen, unveränderliche Releases, SIEM-Weiterleitung, Webhooks und mehr) über die fremforge-API, mit dem generischen REST-Provider magodo/restful.

Für die eigenen Einstellungen von fremforge gibt es inzwischen einen eigenen Provider: Der fremforge-Provider (source = "frem.sh/fremforge/fremforge", 0.1.1, installiert von frem.sh mit Signaturprüfung) deckt sie mit typisierten Ressourcen ab, mit Import über den Slug der Organisation und Drift-Erkennung für jedes Argument, das du setzt. Nimm ihn für neue Konfigurationen. Das Modul auf dieser Seite bleibt dokumentiert, solange du umsteigst; wie das geht, steht unter Umstieg vom Modul. Repositorys und Branch-Schutz bleiben in jedem Fall bei svalabs/forgejo.

Provider

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

Die Provider-Blöcke sind für beide CLIs identisch:

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. Die fremforge-API erlaubt einem Token 60 Schreibvorgänge pro Minute und antwortet darüber mit 429 und Retry-After. Das vollständige Beispiel braucht etwa 27 Schreibvorgänge für apply und 28 für destroy, zwei Läufe direkt hintereinander gehen also darüber. Der Block client.retry lässt den Provider bei einer 429 warten und es erneut versuchen. Das ist sicher, denn eine 429 bedeutet, dass der Schreibvorgang nicht ausgeführt wurde.

>= 1.11.0 ist für Write-only-Argumente nötig, die Secrets, die du sendest (einen Wert eines Actions-Secrets, einen SIEM-Auth-Header), aus dem State heraushalten. Terraform 1.11 und OpenTofu 1.11 unterstützen sie beide.

Woher die Binärdateien kommen. source = "magodo/restful" wird unter Terraform zu registry.terraform.io und unter OpenTofu zu registry.opentofu.org aufgelöst. Beide Registrys verweisen für den Download auf die GitHub-Releases des Projekts. Committe .terraform.lock.hcl: Die Datei hält die Prüfsummen jedes Providers fest, sodass ein späteres init eine veränderte Binärdatei ablehnt. Die Lock-Datei enthält den Hostnamen der Registry, also halte eine pro CLI und teile kein Arbeitsverzeichnis zwischen beiden.

Die Signaturen unterscheiden sich zwischen den beiden CLIs. Terraform prüft bei init die Entwicklersignatur jedes Providers und gibt Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) und Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79) aus. Die OpenTofu-Registry hat für diese beiden Provider keine Signaturschlüssel, deshalb gibt OpenTofu Signature validation was skipped aus und verlässt sich allein auf die Prüfsummen. Unter OpenTofu ist die committete Lock-Datei daher die einzige Prüfung, dass die Binärdatei die ist, die du geprüft hast.

Wenn CI nicht bei jedem Lauf von GitHub laden soll, spiegle die Provider einmal und installiere aus dem 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

Die beiden Provider nutzen unterschiedliche Tokens, weil sie mit unterschiedlichen APIs sprechen.

fremforge-API (restful). Ein ffp_...-Token. Erstelle einen API-Token der Organisation unter Organisationsverwaltung → API-Token (frem.sh/<org>/_admin/api-tokens). Er ist an eine Organisation gebunden, überdauert das Ausscheiden der Person, die ihn erstellt hat, und läuft nach höchstens 90 Tagen ab. Erstelle einen schreibgeschützten Token für plan und behalte die Schreib-Scopes dem Job vor, der apply ausführt:

EinstellungenLesen (plan)Schreiben (apply)
Authentifizierungsrichtlinie, IP-Allowlist, Operator-Zugriff, E-Mail-Domains, SSH-CA, Standards für den Branch-Schutz, Scan-Richtlinie, Actions-Richtlinie, Aufbewahrung von Artefakten, SLA-Richtlinie, Richtlinie für Image-Signaturen, SIEM-Endpunktepolicy:readpolicy:write
Scanner-Einstellungen, Fehlermodus der Push-Protection, unveränderliche Releasesfindings:readpolicy:write
Webhook-Zielewebhooks:readwebhooks:write
Deployment-Umgebungenenvironments:readenvironments:write
Actions-Secrets der Organisationorgs:readorgs:write
Abhängigkeits-Updatesrenovate:readrenovate:write
SSO-Domainssso:readsso:write

Aus einem Forgejo-Actions-Job heraus musst du keinen Token speichern: Du kannst den OIDC-Token des Jobs gegen einen ffp_-Token mit einer Stunde Laufzeit tauschen (siehe OIDC-Token-Austausch). Dieser Austausch gewährt heute einen festen Satz an Scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): genug, um die Einstellungen für Scanner, Push-Protection und Unveränderlichkeit zu planen, aber nicht, um sie zu ändern, und nicht, um die policy:*-Einstellungen zu planen. Jede Sicherheitseinstellung zu ändern braucht policy:write, das der Runner-Austausch nie gewährt, sodass die CI eines Repositorys die Kontrollen deiner Organisation nicht abschalten kann. Für apply nimm einen API-Token der Organisation.

Forgejo-API (forgejo). Ein Forgejo-Zugriffstoken, kein ffp_-Token. Der Provider prüft den Token beim Start gegen Forgejo, auch wenn er nichts zu verwalten hat, er darf also nicht leer bleiben. Um nur fremforge-Einstellungen zu verwalten, lösche forgejo.tf, den forgejo-Provider-Block, seinen Eintrag in required_providers und die Variable forgejo_token. Erlaubt die Authentifizierungsrichtlinie deiner Organisation keine Nutzer-Tokens, erstelle ihn unter Benutzereinstellungen → Git-PATs (frem.sh/user/settings#ff-git-pats). Dort gibt es Scopes für Repositorys und Issues: genug für Repositorys und Branch-Schutz, nicht für Teams.

Das Modul

Das Modul verwaltet jede Richtlinieneinstellung auf Organisationsebene, die die fremforge-API anbietet. Jede Einstellung ist eine optionale Eingabe: Lässt du sie weg, fasst das Modul sie nicht an. Für jede Einstellung, die du setzt, verwaltet das Modul genau die Felder, die du nennst. Felder, die du weglässt, werden weder gesendet noch verglichen, eine Änderung daran in der Konsole erscheint also nicht als Drift.

Es liegt im Docs-Repository unter examples/terraform/. Die Dateien unten werden direkt von dort eingebunden, diese Seite zeigt also immer den Code, den wir testen.

Eingaben des Moduls: 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     = {}
}
Ressourcen des Moduls: 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
}
Ausgaben des Moduls: 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 })
}
Provider-Pin des Moduls: 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"
    }
  }
}

Das Modul aufrufen

Ein Root-Modul, das eine Organisation von Anfang bis Ende konfiguriert. Ersetze die Beispiel-URLs und -CIDRs durch deine eigenen.

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",
  ]
}
Eingaben des Beispiels: 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     = []
}

Jeder Endpunkt und wie er abgebildet wird

Einstellung, Eingabe des ModulsAPI (schreiben, dann lesen)
Authentifizierungsrichtlinie, auth_policyPATCH, GET /orgs/{org}/auth-policy
IP-Allowlist, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Operator-Zugriff, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
E-Mail-Domain-Allowlist an/aus, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Erlaubte E-Mail-Domains, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
SSH-Zertifizierungsstelle, ssh_caPUT, GET /orgs/{org}/ssh-ca
SSO-Domains, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Standards für den Branch-Schutz, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Scan-Richtlinie für die ganze Organisation, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Actions-Richtlinie, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Aufbewahrung von Artefakten, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Abhängigkeits-Updates: Versions-Updates, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Abhängigkeits-Updates: Sicherheitskorrekturen, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Scanner-Einstellungen, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Lizenzrichtlinie pro Repository, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Fehlermodus der Push-Protection, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Einstellungen für Ausnahmen (Höchstlaufzeit, Team für Genehmigungen, Verwerfen nur mit Genehmigung), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Unveränderliche Releases und Tags, immutabilityPUT, GET /orgs/{org}/code-security/immutability
Sicherheits-SLA-Richtlinie, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Durchsetzung von Image-Signaturen, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (ersetzt die ganze Richtlinie; das Modul sendet jedes Feld)
Abhängigkeits-Proxy, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Webhook-Ziele, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
SIEM-Weiterleitung, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Deployment-Umgebungen, environmentsPOST, GET, DELETE /orgs/{org}/environments
Standardzugriff auf Pakete, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Zugriff und Freigaben pro Paket, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Geschützte Paketnamen, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Actions-Secrets der Organisation, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Repositorys, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Branch-Schutz pro Repository, forgejo_branch_protectionForgejo /api/v1/repos/{org}/{repo}/branch_protections

Was du pro Einstellung wissen solltest:

  • Authentifizierungsrichtlinie: beliebige Teilmenge der Felder. max_pat_lifetime_days nimmt null oder einen der Werte 7, 30, 90, 180, 365; jeder andere Wert wird mit 400 abgelehnt.
  • IP-Allowlist: Der PUT ersetzt die ganze Liste, sende also alle drei Felder. Der erste Schreibvorgang stellt außerdem eine Organisation, die noch einen alten Geltungsbereich hat (web oder web+git), auf all um, eine Liste für jeden Bereich; die alten Geltungsbereiche lassen sich danach nicht mehr wählen. Eine durchgesetzte Liste, die deine eigene Adresse nicht enthält, wird mit 400 invalid-allowlist abgelehnt, die CLI kann also nicht die Maschine aussperren, auf der sie läuft. Beginne mit audit_only = true.
  • Standards für den Branch-Schutz: enthält merge_queue (default, on, off) für die Regeln, die diese Standards anlegen.
  • Scan-Richtlinie für die ganze Organisation: image_scan.block_severity ist die Sperrschwelle für Images bei Merges und Pulls: low, medium, high, critical oder null (high). Andere Werte werden mit 400 abgelehnt.
  • Manche Einstellungen werden unter anderen Namen geschrieben als gelesen (Operator-Zugriff, Actions-Richtlinie, Aufbewahrung von Artefakten, E-Mail-Domain-Allowlist, Abhängigkeits-Updates). Das Modul bildet sie mit einer Lesevorlage ab, sodass plan Gleiches mit Gleichem vergleicht.
  • Abhängigkeits-Updates: jedes Feld außer schedule.
  • Unveränderliche Releases: releases = true wirkt nur in eine Richtung, für die Releases, die veröffentlicht werden, solange es aktiv ist.
  • Abhängigkeits-Proxy: für jede Organisation standardmäßig aktiv. malware_scan (Standard false) schaltet das ClamAV-Scannen dessen ein, was der Proxy abruft; block_until_scanned setzt es voraus, und ein PUT, der block_until_scanned aktiv und malware_scan aus lassen würde, wird mit 400 scan-pair abgelehnt (siehe Abrufe über den Abhängigkeits-Proxy). metadata_filter blendet die Versionen, die deine Paketrichtlinie ablehnen würde, aus den Paket-Metadaten aus, die der Proxy ausliefert (npm, PyPI, Maven, NuGet, Go, Helm und Cargo), sodass Paketmanager die neueste erlaubte Version auflösen, statt beim Download zu scheitern. Ist keine Version erlaubt, werden die Metadaten vollständig ausgeliefert und der Download wird mit Begründung abgelehnt. "shadow" blendet nichts aus und listet, was ausgeblendet würde, unter GET /orgs/{org}/package-policy/decisions (metadata). "on" steht vorerst nur internen fremforge-Organisationen zur Verfügung; anderswo wird es mit 403 metadata-filter-internal-only abgelehnt.
  • Webhooks und SIEM-Endpunkte: Nur active (Webhooks) bzw. enabled (SIEM) ändern sich an Ort und Stelle. Jede andere Änderung ersetzt das Ziel und erzeugt ein neues Secret. SIEM-Endpunkte mit Auth-Header verwaltet das Modul in einer eigenen Ressource, siem_endpoint_with_auth; siehe Grenzen dieses Ansatzes.
  • SSO-Domains: nur die Registrierung. Das Modul registriert jede Domain und liest sie aus der Liste challenges zurück; die Verifizierung per DNS oder HTTP ist ein manueller Schritt in der Konsole.
  • Branch-Schutz für Repositorys (forgejo_branch_protection): fremforge fügt für jeden Scanner, den deine Scan-Richtlinie durchsetzt, einen Status-Kontext fremforge/<scanner> hinzu, und standardmäßig setzt jeder Scanner durch. Liste sie alle auf, in der Reihenfolge des Beispiels, sonst zeigt ein späteres plan die Regel als Drift.
  • Actions-Secrets der Organisation: Der Wert ist write-only, plan sieht also keinen geänderten Wert. Rotiere mit -replace. Übergib die Werte aus deinem Secret-Speicher, zum Beispiel TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Write-only-Argumente im Plan von OpenTofu: Ändert sich eine restful_resource an Ort und Stelle, gibt OpenTofu unter den Änderungen ~ ephemeral_body = (write-only attribute) aus. Terraform lässt diese Zeile weg. Das ist keine Drift, und es wird nichts zusätzlich gesendet; die Zahlen unter Plan: sind unter beiden CLIs gleich.

Secrets bleiben aus dem State heraus. Signatur-Secrets von Webhooks und HMAC-Secrets von SIEM werden einmal zurückgegeben, wenn sie angelegt werden. output_attrs des Moduls lässt sie aus dem State weg; lies sie in der Konsole aus oder rotiere sie über die API. Werte von Actions-Secrets und SIEM-Auth-Header werden als Write-only-Argumente gesendet und nie gespeichert.

Was wir testen

Das Modul oben läuft mit beiden CLIs gegen eine eigene fremforge-Testorganisation, mit dem Skript unten:

  1. apply des Beispiels;
  2. erneut plan -detailed-exitcode, das mit 0 enden muss (keine Änderungen);
  3. eine Änderung anwenden: test/change_override.tf hineinkopieren, plan muss mit 2 enden, apply führt die Änderung aus, und das plan danach muss mit 0 enden;
  4. destroy mit reset_on_destroy = true, wonach jede Einstellung so zurückgelesen wird wie vor dem Lauf.
Das Testskript: 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
Der Änderungsschritt: 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
}

Letzter Lauf am 2026-10-06, mit HashiCorp Terraform 1.16.5 (das aktuelle Release, von releases.hashicorp.com, SHA-256 geprüft) und OpenTofu 1.13.1 gegen frem.sh (Forgejo v15), mit einem Organisations-Token mit policy:write und sso:read/sso:write:

EinstellungenTerraform 1.16.5OpenTofu 1.13.1
Authentifizierungsrichtlinie, IP-Allowlist, Operator-Zugriff, E-Mail-Domain-Allowlist und Domains, SSH-CA, Standards für den Branch-Schutz, Scan-Richtlinie, Actions-Richtlinie, Aufbewahrung von Artefakten, SLA-Richtlinie, Scanner-Einstellungen, Fehlermodus der Push-Protection, unveränderliche Releases, Abhängigkeits-Updates, Webhook-Ziele, SIEM-Endpunkte, Deployment-Umgebungen, Actions-Secrets der Organisation, SSO-Domainsapply, Plan ohne Änderungen, Änderung (plan endet mit 2, apply, plan endet mit 0), destroy: bestandenapply, Plan ohne Änderungen, Änderung (plan endet mit 2, apply, plan endet mit 0), destroy: bestanden
Repository und Branch-Schutz (svalabs/forgejo)apply, Plan ohne Änderungen, destroy: bestandenapply, Plan ohne Änderungen, destroy: bestanden
Änderung außerhalb von Terraform (ein Webhook deaktiviert und eine Einstellung der Abhängigkeits-Updates über die API geändert)plan endet mit 2 und zeigt beide, apply stellt beide wieder her, plan endet mit 0plan endet mit 2 und zeigt beide, apply stellt beide wieder her, plan endet mit 0

Jede Einstellung im Modul lief live: 27 Ressourcen angelegt, 15 im Änderungsschritt geändert, 28 gelöscht, unter jeder CLI. Die beiden CLIs verhielten sich bei jedem Schritt gleich. Die einzigen Unterschiede sind die Signaturprüfung des Providers bei init und eine Zeile der Plan-Ausgabe, beide oben beschrieben. Nicht vom Lauf abgedeckt: SIEM-Endpunkte mit Auth-Header und die Verifizierung von SSO-Domains. Die Eingabe dependency_proxy kam später hinzu (2026-10-10) und lief für sich mit OpenTofu gegen eine Testorganisation mit aktivem Abhängigkeits-Proxy: anlegen, ein Plan ohne Änderungen, eine Änderung von "shadow" auf "on", ein Plan ohne Änderungen und destroy, das metadata_filter wieder auf "off" setzte.

Was destroy mit reset_on_destroy = true hinterlässt, verglichen mit einer Organisation, die nie etwas gesetzt hat: Die Standards für den Branch-Schutz sind die fremforge-Grundeinstellung statt ungesetzt, die Scan-Richtlinie behält für jeden verwalteten Scanner eine Zeile mit enforce (wie der Standard des Plans), die SLA-Richtlinie wird als Objekt aus null-Werten statt als null zurückgelesen, und Scanner-Einstellungen werden als configured: true mit ihren Standardwerten zurückgelesen. Jede davon verhält sich genauso wie der unberührte Standard.

Ausführen

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

Eine bestehende Organisation in die Verwaltung übernehmen

Jede Organisation hat ihre Singleton-Einstellungen bereits (Authentifizierungsrichtlinie, IP-Allowlist und so weiter). Das „Anlegen“ eines Singletons ist im Modul derselbe Schreibvorgang wie ein Update, das erste apply schreibt also deine konfigurierten Werte über die aktuellen. Um den Unterschied vorher zu sehen, importiere jedes Singleton und führe dann plan aus. Beim restful-Provider nennt die Import-ID den Lesepfad und die Felder, die du verwaltest:

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
    }
  })
}

Die Schlüssel in body müssen zu den Feldern passen, die du in der Eingabe des Moduls setzt. Nach dem Import zeigt plan nur die Felder, deren aktueller Wert von deiner Konfiguration abweicht. Mach dasselbe für die anderen Singletons, mit zusätzlichem read_selector dort, wo die Tabelle oben aus GET /orgs/{org}/security liest.

Für die Branch-Schutzregel eines bestehenden Repositorys:

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

Drift-Erkennung

Jemand ändert eine Einstellung in der Konsole, und das nächste plan zeigt es. -detailed-exitcode macht daraus etwas, auf das CI reagieren kann: Exit-Code 0 heißt keine Änderungen, 2 heißt Drift, 1 heißt Fehler. Das haben wir am 2026-10-03 mit OpenTofu ausgeführt, mit zwei Änderungen über die API anstelle von Änderungen in der Konsole. Am 2026-10-06 lieferten beide CLIs im selben Test mit einem Webhook und einer Einstellung der Abhängigkeits-Updates dieselben Exit-Codes:

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

Ein geplanter Job, der plan -detailed-exitcode mit einem schreibgeschützten Token ausführt und bei Exit-Code 2 alarmiert, gibt dir einen Drift-Alarm ohne Schreibzugriff. Jede Änderung über die API steht außerdem in deinem Audit-Log, unter dem Token, das sie vorgenommen hat.

Destroy

Die Singleton-Einstellungen lassen sich nicht löschen, also ist auch destroy ein Schreibvorgang. Welcher, entscheidet reset_on_destroy des Moduls:

  • false (Standard): destroy schreibt deine aktuellen Werte zurück, es ändert sich also nichts, und die Einstellung verlässt nur den State. Nimm das für eine Organisation, in der Leute arbeiten.
  • true: destroy schreibt für jedes verwaltete Feld den Plattformstandard und ruft für die Standards des Branch-Schutzes POST /orgs/{org}/branch-defaults/reset auf. Nimm das für eine Testorganisation.

Sammlungen (Webhooks, SIEM-Endpunkte, Umgebungen, E-Mail-Domains, SSO-Domains, Secrets) werden gelöscht. Ein gelöschtes Repository landet für die Dauer seines Wiederherstellungsfensters im Papierkorb der Organisation, und ein Repository gleichen Namens direkt nach einem destroy anzulegen, kann fehlschlagen, solange das Löschen noch läuft. Gib einem Repository, das du wiederholt anlegst und löschst, also jedes Mal einen neuen Namen.

Um eine Einstellung nicht mehr zu verwalten, ohne sie anzufassen, nimm einen removed-Block:

removed {
  from = module.org.restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

Nicht abgedeckt

Was dieses Modul nicht verwaltet, und warum:

  • Teams und Team-Mitgliedschaften. Der Forgejo-Provider hat forgejo_team und forgejo_team_member, aber eine Änderung an einem Team braucht einen Forgejo-Token mit Organisations-Scope, den die Seite Git-PATs nicht anbietet. Wir haben sie auf fremforge nicht getestet.
  • SSO-Auth-Quellen (OIDC). Sie enthalten ein Client-Secret, und um eine zu registrieren, braucht es eine verifizierte Domain und einen funktionierenden IdP, deshalb sind sie nicht in unserer Testorganisation. Die API (/orgs/{org}/sso) unterstützt das Registrieren und das Aktualisieren der Zuordnung von Gruppen zu Teams.
  • Native SSO-Durchsetzung (das Opt-in pro Organisation für eine frische erneute Authentifizierung). Dafür gibt es keine API.
  • SCIM, OAuth-Clients, CI-Bots, API-Token. Jedes Anlegen gibt einmalig Zugangsdaten zurück. Diese im State zu halten, ist der falsche Kompromiss.
  • Mitglieder und Nutzer. Einen Nutzer hinzuzufügen legt ein Konto für eine Person an; Einladungen verschicken E-Mails. Lies sie mit einer restful_resource-Datenquelle auf GET /orgs/{org}/seats.
  • Überschreibungen pro Repository (Scan-Richtlinie, Opt-outs von der Unveränderlichkeit, öffentliche Dokumentation), befristete Ausnahmen (Überschreibungen von Push-Protection und Malware-Scan, verworfene Befunde), Sicherheits-Digests und Benachrichtigungseinstellungen.
  • Umgebungsvariablen, Secrets, Gates und Branch-Regeln innerhalb einer Umgebung. Das Modul legt nur die Umgebung selbst an.
  • Das Feld schedule der Abhängigkeits-Updates. Es ist standardmäßig null, und die Lesevorlage des Providers kann kein null darstellen.
  • Alles, was nicht in der OpenAPI-Spezifikation steht. Ein unbekannter Pfad antwortet mit 501, was auch einen Tippfehler bedeuten kann.

Grenzen dieses Ansatzes

  • Der generische Provider weiß nichts über fremforge. Er kann nicht warnen, dass releases = true nur in eine Richtung wirkt, und keinen Body vor apply validieren. Jede Ablehnung kommt als Fehler der API zur Zeit von apply. Die eigenen Prüfungen der API gelten weiterhin, etwa die Aussperr-Prüfung der IP-Allowlist.
  • Der Forgejo-Provider ist ein Drittanbieter-Provider für Forgejo allgemein. Ein Forgejo-Release kann sein Verhalten ändern, ohne dass wir Bescheid geben. Wir pinnen die Version und testen sie.
  • SIEM-Endpunkte mit Auth-Header. magodo/restful 0.25.2 vergisst nach einem Update an Ort und Stelle den Fingerabdruck des write-only ephemeral_body einer Ressource. Eine Ressource, die das Argument überhaupt setzt, plant danach bei jedem weiteren Lauf einen leeren PATCH, den die API mit 400 ablehnt. Das Modul setzt ephemeral_body deshalb nur bei siem_endpoint_with_auth. Für diese Endpunkte zeigt der Plan nach einer Änderung an Ort und Stelle ein weiteres Update, das den Header erneut sendet; der Plan danach ist sauber. Diesen Fall haben wir nicht live ausgeführt.
  • Der State enthält Leseantworten. Das Modul hält Secrets wie oben beschrieben aus dem State heraus, aber der State beschreibt trotzdem deine Sicherheitskonfiguration. Nutze ein Backend, dem du vertraust, und die State-Verschlüsselung von OpenTofu, wenn du OpenTofu verwendest.

Brauchst du eine Einstellung, die diese Seite nicht abdeckt, schreib uns an support@frem.sh.

Verwandte Themen