Gå til hovedindhold

Konfiguration som kode med Terraform og OpenTofu

Du kan holde din fremforge-organisation i kode med HashiCorp Terraform eller OpenTofu. Begge CLI’er kører den samme konfiguration med to open source-providers fra de offentlige registries:

  • Forgejo-objekter (repositorier, branch-beskyttelse, teams, Actions-secrets og -variabler) gennem Forgejos eget REST API med svalabs/forgejo.
  • fremforge-indstillinger (godkendelsespolitik, IP-tilladelsesliste, standarder for branch-beskyttelse, scanningspolitik og scannerindstillinger, uforanderlige releases, SIEM-videresendelse, webhooks med mere) gennem fremforge-API’et med den generiske REST-provider magodo/restful.

Til fremforges egne indstillinger findes der nu en dedikeret provider: fremforge-provideren (source = "frem.sh/fremforge/fremforge", 0.1.1, installeret fra frem.sh med signaturkontrol) dækker dem med typede ressourcer, import efter organisationens slug og drift på hvert argument, du sætter. Brug den hellere til nye konfigurationer. Modulet på denne side forbliver dokumenteret, mens du flytter; Flyt fra modulet viser hvordan. Repositorier og branch-beskyttelse bliver under alle omstændigheder hos svalabs/forgejo.

Providers

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

Provider-blokkene er ens for begge CLI’er:

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
}

Hastighedsgrænse. fremforge-API’et tillader ét token 60 skrivninger i minuttet og svarer 429 med Retry-After derover. Det fulde eksempel er omkring 27 skrivninger at anvende og 28 at nedlægge, så to kørsler lige efter hinanden går over grænsen. Blokken client.retry får provideren til at vente og prøve en 429 igen, hvilket er sikkert, fordi en 429 betyder, at skrivningen ikke blev udført.

>= 1.11.0 er nødvendig for write-only-argumenter, som holder de secrets, du sender (en Actions-secrets værdi, en SIEM-godkendelsesheader), ude af state. Terraform 1.11 og OpenTofu 1.11 understøtter dem begge.

Hvor binærfilerne kommer fra. source = "magodo/restful" peger på registry.terraform.io under Terraform og på registry.opentofu.org under OpenTofu. Begge registries henter downloaden fra projektets GitHub-releases. Commit .terraform.lock.hcl: den registrerer hver providers checksummer, så en senere init afviser en binærfil, der har ændret sig. Lock-filen registrerer registry-værtsnavnet, så hav én pr. CLI, og del ikke en arbejdsmappe mellem dem.

Signaturerne er forskellige i de to CLI’er. Terraform kontrollerer hver providers udviklersignatur ved init og skriver Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) og Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79). OpenTofu-registryet har ingen signeringsnøgler til disse to providers, så OpenTofu skriver Signature validation was skipped og stoler alene på checksummerne. Under OpenTofu er den committede lock-fil derfor den eneste kontrol af, at binærfilen er den, du har gennemgået.

Hvis du ikke vil have CI til at hente fra GitHub ved hver kørsel, så spejl providerne én gang, og installér fra spejlet:

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 to providers bruger forskellige tokens, fordi de taler med forskellige API’er.

fremforge-API’et (restful). Et ffp_...-token. Opret et organisations-API-token under Administration → API-tokens (frem.sh/<org>/_admin/api-tokens). Det er bundet til én organisation, overlever, at den person, der oprettede det, forlader den, og udløber efter højst 90 dage. Opret et skrivebeskyttet token til plan, og gem skrive-scopes til det job, der kører apply:

IndstillingerLæs (plan)Skriv (apply)
Godkendelsespolitik, IP-tilladelsesliste, operatøradgang, e-maildomæner, SSH CA, standarder for branch-beskyttelse, scanningspolitik, Actions-politik, opbevaring af artefakter, SLA-politik, politik for image-signaturer, SIEM-endpointspolicy:readpolicy:write
Scannerindstillinger, fejltilstand for push-beskyttelse, uforanderlige releasesfindings:readpolicy:write
Webhook-destinationerwebhooks:readwebhooks:write
Deployment-miljøerenvironments:readenvironments:write
Organisationens Actions-secretsorgs:readorgs:write
Afhængighedsopdateringerrenovate:readrenovate:write
SSO-domænersso:readsso:write

Fra et Forgejo Actions-job kan du undgå at gemme et token ved at bytte jobbets OIDC-token til et ffp_-token, der gælder i en time (se OIDC token exchange). I dag giver den udveksling et fast sæt scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): nok til at planlægge scanner-, push-beskyttelses- og uforanderlighedsindstillingerne, men ikke til at ændre dem og ikke til at planlægge policy:*-indstillingerne. Enhver ændring af en sikkerhedsindstilling kræver policy:write, som runner-udvekslingen aldrig giver, så et repositorys CI kan ikke slå din organisations kontroller fra. Brug et organisations-API-token til apply.

Forgejo-API’et (forgejo). Et Forgejo-adgangstoken, ikke et ffp_-token. Provideren kontrollerer tokenet mod Forgejo, når den starter, også når den ikke har noget at administrere, så det kan ikke stå tomt. Vil du kun administrere fremforge-indstillinger, så slet forgejo.tf, provider-blokken forgejo, dens post i required_providers og variablen forgejo_token. Hvis din organisations godkendelsespolitik ikke tillader brugertokens, så opret det under Brugerindstillinger → Git-PAT’er (frem.sh/user/settings#ff-git-pats), som tilbyder scopes til repositorier og issues: nok til repositorier og branch-beskyttelse, men ikke til teams.

Modulet

Modulet administrerer alle de politikindstillinger på organisationsniveau, som fremforge-API’et udstiller. Hver indstilling er et valgfrit input: udelader du det, rører modulet den ikke. For hver indstilling, du sætter, ejer modulet præcis de felter, du nævner. Felter, du udelader, bliver hverken sendt eller sammenlignet, så en ændring af dem i konsollen vises ikke som drift.

Det ligger i docs-repositoriet under examples/terraform/. Filerne nedenfor er indlæst direkte derfra, så denne side altid viser den kode, vi tester.

Modulets inputs: 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     = {}
}
Modulets ressourcer: 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
}
Modulets outputs: 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 })
}
Modulets fastlåste provider: 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"
    }
  }
}

Sådan kalder du det

Et rodmodul, der konfigurerer én organisation fra ende til anden. Erstat eksemplets URL’er og CIDR’er med dine egne.

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",
  ]
}
Eksemplets inputs: 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     = []
}

Alle endpoints, og hvordan de hænger sammen

Indstilling, modulinputAPI (skriv, derefter læs)
Godkendelsespolitik, auth_policyPATCH, GET /orgs/{org}/auth-policy
IP-tilladelsesliste, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Operatøradgang, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
Tilladelsesliste for e-maildomæner til/fra, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Tilladte e-maildomæner, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
SSH-certifikatmyndighed, ssh_caPUT, GET /orgs/{org}/ssh-ca
SSO-domæner, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Standarder for branch-beskyttelse, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Scanningspolitik for hele organisationen, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Actions-politik, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Opbevaring af artefakter, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Afhængighedsopdateringer: versionsopdateringer, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Afhængighedsopdateringer: sikkerhedsrettelser, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Scannerindstillinger, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Licenspolitik pr. repository, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Fejltilstand for push-beskyttelse, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Indstillinger for undtagelser (maksimal udløbstid, godkenderteam, afvisninger kræver godkendelse), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Uforanderlige releases og tags, immutabilityPUT, GET /orgs/{org}/code-security/immutability
SLA-politik for sikkerhed, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Håndhævelse af image-signaturer, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (erstatter hele politikken; modulet sender alle felter)
Dependency proxy, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Webhook-destinationer, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
SIEM-videresendelse, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Deployment-miljøer, environmentsPOST, GET, DELETE /orgs/{org}/environments
Standardadgang til pakker, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Adgang og tildelinger pr. pakke, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Beskyttede pakkenavne, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Organisationens Actions-secrets, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Repositorier, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Branch-beskyttelse pr. repository, forgejo_branch_protectionForgejo /api/v1/repos/{org}/{repo}/branch_protections

Det skal du vide om hver indstilling:

  • Godkendelsespolitik: en vilkårlig delmængde af felterne. max_pat_lifetime_days tager null eller en af 7, 30, 90, 180, 365; enhver anden værdi afvises med 400.
  • IP-tilladelsesliste: PUT erstatter hele listen, så send alle tre felter. Den første skrivning flytter også en organisation, der stadig har et ældre scope (web eller web+git), over på all, én liste for alle overflader; de ældre scopes kan ikke vælges igen. En håndhævende liste, der ikke indeholder din egen adresse, afvises med 400 invalid-allowlist, så CLI’en ikke kan lukke den maskine ude, den kører på. Begynd med audit_only = true.
  • Standarder for branch-beskyttelse: omfatter merge_queue (default, on, off) for de regler, standarderne opretter.
  • Scanningspolitik for hele organisationen: image_scan.block_severity er image-blokeringstærsklen for merges og pulls: low, medium, high, critical eller null (high). Andre værdier afvises med 400.
  • Nogle indstillinger skrives og læses under forskellige navne (operatøradgang, Actions-politik, opbevaring af artefakter, tilladelsesliste for e-maildomæner, afhængighedsopdateringer). Modulet oversætter dem med en læseskabelon, så plan sammenligner lige med lige.
  • Afhængighedsopdateringer: alle felter undtagen schedule.
  • Uforanderlige releases: releases = true er envejs for de releases, der udgives, mens den er slået til.
  • Dependency proxy: slået til som standard for alle organisationer. malware_scan (standard false) slår ClamAV-scanning til af det, proxyen henter; block_until_scanned kræver den, og en PUT, der ville lade block_until_scanned være slået til med malware_scan slået fra, afvises med 400 scan-pair (se Hentninger gennem dependency proxy). metadata_filter skjuler de versioner, din pakkepolitik ville afvise, fra de pakkemetadata, proxyen serverer (npm, PyPI, Maven, NuGet, Go, Helm og Cargo), så pakkehåndteringer vælger den nyeste tilladte version i stedet for at fejle ved downloaden. Er ingen version tilladt, serveres metadataene fuldt ud, og downloaden afvises med begrundelsen. "shadow" skjuler intet og viser, hvad den ville skjule, under GET /orgs/{org}/package-policy/decisions (metadata). "on" er indtil videre kun tilgængelig for fremforges interne organisationer; andre steder afvises den med 403 metadata-filter-internal-only.
  • Webhooks og SIEM-endpoints: kun active (webhooks) eller enabled (SIEM) ændres på stedet. Enhver anden ændring erstatter destinationen og opretter en ny secret. Modulet administrerer SIEM-endpoints med en godkendelsesheader i en separat ressource, siem_endpoint_with_auth; se Begrænsninger.
  • SSO-domæner: kun registrering. Modulet registrerer hvert domæne og læser det tilbage fra listen challenges; DNS- eller HTTP-verificeringen er et manuelt trin i konsollen.
  • Branch-beskyttelse på repositorier (forgejo_branch_protection): fremforge tilføjer en statuskontekst fremforge/<scanner> for hver scanner, din scanningspolitik håndhæver, og alle scannere håndhæver som standard. Angiv dem alle, i den rækkefølge eksemplet bruger, ellers viser en senere plan reglen som drift.
  • Organisationens Actions-secrets: værdien er write-only, så plan kan ikke se en ændret værdi. Rotér med -replace. Send værdierne fra dit secret-lager, for eksempel TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Write-only-argumenter i OpenTofus plan: når en restful_resource ændres på stedet, skriver OpenTofu ~ ephemeral_body = (write-only attribute) blandt ændringerne. Terraform udelader den linje. Det er ikke drift, og der sendes ikke noget ekstra; tallene i Plan: er de samme under begge CLI’er.

Secrets holdes ude af state. Signeringssecrets til webhooks og HMAC-secrets til SIEM returneres én gang, når de oprettes. Modulets output_attrs fjerner dem fra state; læs dem i konsollen, eller rotér dem gennem API’et. Værdier af Actions-secrets og SIEM-godkendelsesheadere sendes som write-only-argumenter og gemmes aldrig.

Hvad vi tester

Modulet ovenfor kører mod en dedikeret fremforge-testorganisation med begge CLI’er ved hjælp af scriptet nedenfor:

  1. apply eksemplet;
  2. plan -detailed-exitcode igen, som skal afslutte med 0 (ingen ændringer);
  3. anvend en ændring: kopiér test/change_override.tf ind, plan skal afslutte med 2, apply udfører ændringen, og plan derefter skal afslutte med 0;
  4. destroy med reset_on_destroy = true, hvorefter alle indstillinger læses tilbage, som de var før kørslen.
Testscriptet: 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
Ændringstrinnet: 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
}

Seneste kørsel, 2026-10-06, HashiCorp Terraform 1.16.5 (den aktuelle udgave, fra releases.hashicorp.com, SHA-256-kontrolleret) og OpenTofu 1.13.1 mod frem.sh (Forgejo v15), med et organisationstoken med policy:write og sso:read/sso:write:

IndstillingerTerraform 1.16.5OpenTofu 1.13.1
Godkendelsespolitik, IP-tilladelsesliste, operatøradgang, tilladelsesliste for e-maildomæner og domæner, SSH CA, standarder for branch-beskyttelse, scanningspolitik, Actions-politik, opbevaring af artefakter, SLA-politik, scannerindstillinger, fejltilstand for push-beskyttelse, uforanderlige releases, afhængighedsopdateringer, webhook-destinationer, SIEM-endpoints, deployment-miljøer, organisationens Actions-secrets, SSO-domænerapply, plan uden ændringer, ændring (plan afslutter med 2, apply, plan afslutter med 0), destroy: beståetapply, plan uden ændringer, ændring (plan afslutter med 2, apply, plan afslutter med 0), destroy: bestået
Repository og branch-beskyttelse (svalabs/forgejo)apply, plan uden ændringer, destroy: beståetapply, plan uden ændringer, destroy: bestået
Ændring uden om Terraform (en webhook slået fra og en indstilling for afhængighedsopdateringer ændret gennem API’et)plan afslutter med 2 og viser begge, apply gendanner begge, plan afslutter med 0plan afslutter med 2 og viser begge, apply gendanner begge, plan afslutter med 0

Alle indstillinger i modulet kørte live: 27 ressourcer anvendt, 15 ændret i ændringstrinnet, 28 nedlagt, under hver CLI. De to CLI’er opførte sig ens i alle trin. De eneste forskelle er providerens signaturkontrol ved init og én linje i plan-outputtet, begge beskrevet ovenfor. Ikke dækket af kørslen: SIEM-endpoints med en godkendelsesheader og verificering af SSO-domæner. Inputtet dependency_proxy blev tilføjet senere (2026-10-10) og kørt for sig selv med OpenTofu mod en testorganisation med dependency proxy slået til: oprettelse, en plan uden ændringer, en ændring fra "shadow" til "on", en plan uden ændringer og destroy, som satte metadata_filter tilbage til "off".

Hvad destroy med reset_on_destroy = true efterlader, sammenlignet med en organisation, der aldrig har sat noget: standarderne for branch-beskyttelse er fremforges grundlinje frem for usat, scanningspolitikken beholder en række pr. scanner, du har administreret, på enforce (det samme som planens standard), SLA-politikken læses tilbage som et objekt af null-værdier frem for null, og scannerindstillinger læses tilbage som configured: true med deres standardværdier. Hver af dem opfører sig ligesom den urørte standard.

Kør det

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

Tag en eksisterende organisation under administration

Alle organisationer har allerede deres singleton-indstillinger (godkendelsespolitik, IP-tilladelsesliste og så videre). Modulets “create” for en singleton er den samme skrivning som en opdatering, så den første apply skriver dine konfigurerede værdier hen over de aktuelle. Vil du se forskellen først, så importér hver singleton, og kør derefter plan. Med restful-provideren navngiver import-id’et læsestien og de felter, du administrerer:

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

Nøglerne i body skal svare til de felter, du sætter i modulinputtet. Efter importen viser plan kun de felter, hvis aktuelle værdi afviger fra din konfiguration. Gør det samme for de andre singletons, med read_selector tilføjet, hvor tabellen ovenfor læser fra GET /orgs/{org}/security.

For en eksisterende branch-beskyttelsesregel på et repository:

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

Opdagelse af drift

Nogen ændrer en indstilling i konsollen, og den næste plan viser det. -detailed-exitcode gør det til noget, CI kan handle på: afslutningskode 0 betyder ingen ændringer, 2 betyder drift, 1 betyder en fejl. Dette er, hvad vi kørte 2026-10-03 med OpenTofu, med to ændringer foretaget gennem API’et i stedet for redigeringer i konsollen. 2026-10-06 gav begge CLI’er de samme afslutningskoder i den samme test med en webhook og en indstilling for afhængighedsopdateringer:

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

Et planlagt job, der kører plan -detailed-exitcode med et skrivebeskyttet token og alarmerer ved afslutningskode 2, giver dig en driftalarm uden skriveadgang. Enhver ændring foretaget gennem API’et står også i din revisionslog under det token, der foretog den.

Destroy

Singleton-indstillingerne kan ikke slettes, så destroy er også en skrivning. Modulets reset_on_destroy afgør hvilken:

  • false (standarden): destroy skriver dine nuværende værdier tilbage, så intet ændres, og indstillingen forlader blot state. Brug dette til en organisation, hvor folk arbejder.
  • true: destroy skriver platformens standard for hvert felt, du administrerer, og kalder for standarderne for branch-beskyttelse POST /orgs/{org}/branch-defaults/reset. Brug dette til en testorganisation.

Samlinger (webhooks, SIEM-endpoints, miljøer, e-maildomæner, SSO-domæner, secrets) slettes. Et slettet repository havner i organisationens papirkurv i dets gendannelsesvindue, og det kan fejle at oprette et med samme navn lige efter en destroy, mens sletningen gøres færdig, så giv et repository, du opretter og nedlægger gentagne gange, et nyt navn.

Vil du holde op med at administrere en indstilling uden at røre den, så brug en removed-blok:

removed {
  from = module.org.restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

Ikke dækket

Hvad modulet ikke administrerer, og hvorfor:

  • Teams og teammedlemskab. Forgejo-provideren har forgejo_team og forgejo_team_member, men en ændring af et team kræver et Forgejo-token med organisations-scope, som siden Git-PAT’er ikke tilbyder. Vi har ikke testet dem på fremforge.
  • SSO-godkendelseskilder (OIDC). De indeholder en klient-secret, og registrering af en kræver et verificeret domæne og en fungerende IdP, så de er ikke i vores testorganisation. API’et (/orgs/{org}/sso) understøtter registrering og opdatering af tilknytningen fra grupper til teams.
  • Indbygget SSO-håndhævelse (tilvalget pr. organisation om frisk genautentificering). Der findes intet API til det.
  • SCIM, OAuth-klienter, CI-bots, API-tokens. Hver oprettelse returnerer et legitimationsbevis én gang. At gemme dem i state er den forkerte afvejning.
  • Medlemmer og brugere. At tilføje en bruger opretter en konto til en person; invitationer sender e-mail. Læs dem med en restful_resource-datakilde på GET /orgs/{org}/seats.
  • Tilsidesættelser pr. repository (scanningspolitik, fravalg af uforanderlighed, offentlig dokumentation), tidsbegrænsede undtagelser (tilsidesættelser af push-beskyttelse og malwarescanning, afvisninger af fund), sikkerhedsoversigter og notifikationsindstillinger.
  • Miljøvariabler, secrets, gates og branch-regler inde i et miljø. Modulet opretter kun miljøet.
  • Feltet schedule i afhængighedsopdateringer. Det er null som standard, og providerens læseskabelon kan ikke gengive en null.
  • Alt, der ikke står i OpenAPI-specifikationen. En ukendt sti svarer 501, hvilket også kan betyde en tastefejl.

Begrænsninger ved denne tilgang

  • Den generiske provider ved intet om fremforge. Den kan ikke advare om, at releases = true er envejs, eller validere en body før apply. Enhver afvisning kommer som API’ets fejl på apply-tidspunktet. API’ets egne kontroller gælder stadig, som kontrollen mod at lukke sig selv ude på IP-tilladelseslisten.
  • Forgejo-provideren er en tredjepartsprovider til Forgejo i almindelighed. En Forgejo-udgave kan ændre dens opførsel uden varsel fra os. Vi fastlåser versionen og tester den.
  • SIEM-endpoints med en godkendelsesheader. magodo/restful 0.25.2 glemmer en ressources write-only-fingeraftryk af ephemeral_body efter en opdatering på stedet. En ressource, der overhovedet sætter argumentet, planlægger derefter en tom PATCH ved hver senere kørsel, som API’et afviser med 400. Modulet sætter derfor kun ephemeral_body på siem_endpoint_with_auth. For de endpoints viser planen efter en ændring på stedet én opdatering mere, som sender headeren igen; planen derefter er ren. Vi har ikke kørt dette tilfælde live.
  • State indeholder læsesvar. Modulet holder secrets ude af state som beskrevet ovenfor, men state beskriver stadig din sikkerhedskonfiguration. Brug en backend, du stoler på, og OpenTofus state-kryptering, hvis du kører OpenTofu.

Har du brug for en indstilling, som denne side ikke dækker, så skriv til os på support@frem.sh.

Relateret