Gå til hovedinnhold

Konfigurasjon som kode med Terraform og OpenTofu

Du kan holde fremforge-organisasjonen din i kode med HashiCorp Terraform eller OpenTofu. Begge CLI-ene kjører den samme konfigurasjonen, med to providere med åpen kildekode fra de offentlige registrene:

  • Forgejo-objekter (repositoryer, branchbeskyttelse, team, Actions-secrets og -variabler) gjennom Forgejos eget REST API, med svalabs/forgejo.
  • fremforge-innstillinger (autentiseringspolicy, IP-tillatelsesliste, standarder for branchbeskyttelse, skannepolicy og skannerinnstillinger, uforanderlige utgivelser, SIEM-videresending, webhooks og mer) gjennom fremforge-API-et, med den generiske REST-provideren magodo/restful.

For fremforges egne innstillinger finnes det nå en egen provider: fremforge-provideren (source = "frem.sh/fremforge/fremforge", 0.1.1, installert fra frem.sh med bekreftelse av signaturen) dekker dem med typede ressurser, import etter organisasjonens slug og avviksoppdagelse på hvert argument du setter. Foretrekk den for nye konfigurasjoner. Modulen på denne siden forblir dokumentert mens du flytter over; Flytte fra modulen viser hvordan. Repositoryer og branchbeskyttelse blir uansett hos svalabs/forgejo.

Providere

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

Providerblokkene er identiske for begge CLI-ene:

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
}

Frekvensgrense. fremforge-API-et tillater 60 skrivinger i minuttet per token og svarer 429 med Retry-After over det. Hele eksemplet er omtrent 27 skrivinger ved apply og 28 ved destroy, så to kjøringer rett etter hverandre går over grensen. Blokken client.retry får provideren til å vente og prøve en 429 på nytt, noe som er trygt fordi 429 betyr at skrivingen ikke ble utført.

>= 1.11.0 trengs for write-only-argumenter, som holder secrets du sender (verdien til en Actions-secret, en autentiseringsheader for SIEM), utenfor state. Både Terraform 1.11 og OpenTofu 1.11 støtter dem.

Hvor binærfilene kommer fra. source = "magodo/restful" peker til registry.terraform.io under Terraform og til registry.opentofu.org under OpenTofu. Begge registrene henter nedlastingen fra prosjektets GitHub-utgivelser. Commit .terraform.lock.hcl: den registrerer sjekksummene til hver provider, så en senere init avviser en binærfil som er endret. Låsefilen registrerer vertsnavnet til registeret, så ha én per CLI og ikke del en arbeidskatalog mellom dem.

Signaturene er forskjellige for de to CLI-ene. Terraform kontrollerer utviklersignaturen til hver provider 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-registeret har ingen signeringsnøkler for disse to providerne, så OpenTofu skriver Signature validation was skipped og stoler bare på sjekksummene. Under OpenTofu er derfor den committede låsefilen den eneste kontrollen av at binærfilen er den du gikk gjennom.

Hvis du ikke vil at CI skal hente fra GitHub ved hver kjøring, speiler du providerne én gang og installerer fra speilet:

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"]
  }
}

Tokener

De to providerne bruker forskjellige tokener, fordi de snakker med forskjellige API-er.

fremforge-API-et (restful). Et ffp_...-token. Opprett et organisasjons-API-token under Organisasjonsadministrasjon → API-tokener (frem.sh/<org>/_admin/api-tokens). Det er bundet til én organisasjon, overlever at personen som opprettet det, slutter, og utløper etter høyst 90 dager. Opprett et token med bare lesetilgang for plan, og hold skrive-scopene for jobben som kjører apply:

InnstillingerLese (plan)Skrive (apply)
Autentiseringspolicy, IP-tillatelsesliste, operatørtilgang, e-postdomener, SSH CA, standarder for branchbeskyttelse, skannepolicy, Actions-policy, oppbevaring av artefakter, SLA-policy, policy for image-signaturer, SIEM-endepunkterpolicy:readpolicy:write
Skannerinnstillinger, feilmodus for push-beskyttelse, uforanderlige utgivelserfindings:readpolicy:write
Webhook-målwebhooks:readwebhooks:write
Utrullingsmiljøerenvironments:readenvironments:write
Actions-secrets for organisasjonenorgs:readorgs:write
Avhengighetsoppdateringerrenovate:readrenovate:write
SSO-domenersso:readsso:write

Fra en jobb i Forgejo Actions kan du unngå å lagre et token ved å bytte jobbens OIDC-token mot et ffp_-token som gjelder i én time (se Bytte av OIDC-token). I dag gir dette byttet et fast sett med scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): nok til å planlegge innstillingene for skannere, push-beskyttelse og uforanderlighet, men ikke til å endre dem og ikke til å planlegge policy:*-innstillingene. Å endre en sikkerhetsinnstilling krever policy:write, som byttet for runnere aldri gir, så CI-en i et repository kan ikke slå av kontrollene i organisasjonen din. Til apply bruker du et organisasjons-API-token.

Forgejo-API-et (forgejo). Et tilgangstoken for Forgejo, ikke et ffp_-token. Provideren kontrollerer tokenet mot Forgejo når den starter, selv om den ikke har noe å styre, så det kan ikke stå tomt. For å styre bare fremforge-innstillinger sletter du forgejo.tf, providerblokken forgejo, oppføringen i required_providers og variabelen forgejo_token. Hvis autentiseringspolicyen i organisasjonen din ikke tillater brukertokener, oppretter du det under Brukerinnstillinger → Git PATs (frem.sh/user/settings#ff-git-pats), som tilbyr scopes for repositoryer og issues: nok for repositoryer og branchbeskyttelse, men ikke for team.

Modulen

Modulen styrer hver policyinnstilling på organisasjonsnivå som fremforge-API-et eksponerer. Hver innstilling er en valgfri input: utelater du den, rører ikke modulen den. For hver innstilling du setter, eier modulen nøyaktig de feltene du nevner. Felt du utelater, blir verken sendt eller sammenlignet, så en endring av dem i konsollen vises ikke som avvik.

Den ligger i dokumentasjonsrepositoryet under examples/terraform/. Filene nedenfor hentes rett derfra, så denne siden viser alltid koden vi tester.

Modulens input: 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     = {}
}
Modulens ressurser: 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
}
Modulens output: 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 })
}
Modulens låsing av 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"
    }
  }
}

Slik kaller du den

En rotmodul som konfigurerer én organisasjon fra ende til ende. Bytt ut eksempel-URL-ene og CIDR-ene 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 input: 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     = []
}

Hvert endepunkt, og hvordan det kobles

Innstilling, modulinputAPI (skriving, deretter lesing)
Autentiseringspolicy, auth_policyPATCH, GET /orgs/{org}/auth-policy
IP-tillatelsesliste, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Operatørtilgang, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
Tillatelsesliste for e-postdomener av/på, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Tillatte e-postdomener, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
SSH-sertifikatutsteder, ssh_caPUT, GET /orgs/{org}/ssh-ca
SSO-domener, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Standarder for branchbeskyttelse, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Skannepolicy for hele organisasjonen, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Actions-policy, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Oppbevaring av artefakter, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Avhengighetsoppdateringer: Version updates, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Avhengighetsoppdateringer: Security fixes, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Skannerinnstillinger, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Lisenspolicy per repository, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Feilmodus for push-beskyttelse, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Innstillinger for unntak (tak for utløpstid, godkjennerteam, avvisninger krever godkjenning), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Uforanderlige utgivelser og tagger, immutabilityPUT, GET /orgs/{org}/code-security/immutability
SLA-policy for sikkerhet, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Håndheving av image-signaturer, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (erstatter hele policyen; modulen sender hvert felt)
Avhengighetsproxy, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Webhook-mål, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
SIEM-videresending, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Utrullingsmiljøer, environmentsPOST, GET, DELETE /orgs/{org}/environments
Standard pakketilgang, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Tilgang og tildelinger per pakke, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Beskyttede pakkenavn, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Actions-secrets for organisasjonen, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Repositoryer, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Branchbeskyttelse per repository, forgejo_branch_protectionForgejo /api/v1/repos/{org}/{repo}/branch_protections

Dette bør du vite om hver innstilling:

  • Autentiseringspolicy: et hvilket som helst utvalg av feltene. max_pat_lifetime_days tar null eller en av 7, 30, 90, 180, 365; alle andre verdier avvises med 400.
  • IP-tillatelsesliste: PUT erstatter hele listen, så send alle tre feltene. Den første skrivingen flytter også en organisasjon som fortsatt har et eldre omfang (web eller web+git), til all, én liste for hver flate; de eldre omfangene kan ikke velges igjen. En håndhevende liste som ikke inneholder din egen adresse, avvises med 400 invalid-allowlist, så CLI-en kan ikke låse ute maskinen den kjører på. Start med audit_only = true.
  • Standarder for branchbeskyttelse: inkluderer merge_queue (default, on, off) for reglene disse standardene oppretter.
  • Skannepolicy for hele organisasjonen: image_scan.block_severity er blokkeringsterskelen for imager ved merge og pull: low, medium, high, critical eller null (high). Andre verdier avvises med 400.
  • Noen innstillinger skrives og leses under forskjellige navn (operatørtilgang, Actions-policy, oppbevaring av artefakter, tillatelsesliste for e-postdomener, avhengighetsoppdateringer). Modulen kobler dem med en lesemal, så plan sammenligner like med like.
  • Avhengighetsoppdateringer: alle felt unntatt schedule.
  • Uforanderlige utgivelser: releases = true er enveis for utgivelsene som publiseres mens den er på.
  • Avhengighetsproxy: på som standard for hver organisasjon. malware_scan (standard false) slår på ClamAV-skanning av det proxyen henter; block_until_scanned krever den, og en PUT som ville latt block_until_scanned være på med malware_scan av, avvises med 400 scan-pair (se Henting via avhengighetsproxyen). metadata_filter skjuler versjonene som pakkepolicyen din ville avvist, fra pakkemetadataene proxyen leverer (npm, PyPI, Maven, NuGet, Go, Helm og Cargo), så pakkebehandlere løser opp til den nyeste tillatte versjonen i stedet for å feile ved nedlastingen. Hvis ingen versjon er tillatt, leveres metadataene i sin helhet, og nedlastingen avvises med begrunnelsen. "shadow" skjuler ingenting og lister det den ville skjult, under GET /orgs/{org}/package-policy/decisions (metadata). "on" er foreløpig bare tilgjengelig for interne fremforge-organisasjoner; ellers avvises den med 403 metadata-filter-internal-only.
  • Webhooks og SIEM-endepunkter: bare active (webhooks) eller enabled (SIEM) endres på stedet. Enhver annen endring erstatter målet og oppretter en ny secret. Modulen styrer SIEM-endepunkter med en autentiseringsheader i en egen ressurs, siem_endpoint_with_auth; se Begrensninger.
  • SSO-domener: bare registrering. Modulen registrerer hvert domene og leser det tilbake fra listen challenges; bekreftelsen via DNS eller HTTP er et manuelt steg i konsollen.
  • Branchbeskyttelse i repositoryer (forgejo_branch_protection): fremforge legger til en statuskontekst fremforge/<scanner> for hver skanner skannepolicyen din håndhever, og hver skanner håndhever som standard. List alle, i den rekkefølgen eksemplet bruker, ellers viser en senere plan regelen som avvik.
  • Actions-secrets for organisasjonen: verdien er write-only, så plan kan ikke se en endret verdi. Roter med -replace. Send verdiene fra secret-lageret ditt, for eksempel TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Write-only-argumenter i OpenTofus plan: når en restful_resource endres på stedet, skriver OpenTofu ~ ephemeral_body = (write-only attribute) blant endringene. Terraform utelater den linjen. Det er ikke avvik, og ingenting ekstra sendes; tallene i Plan: er de samme under begge CLI-ene.

Secrets holdes utenfor state. Signerings-secrets for webhooks og HMAC-secrets for SIEM returneres én gang når de opprettes. Modulens output_attrs fjerner dem fra state; les dem i konsollen eller roter dem gjennom API-et. Verdier for Actions-secrets og autentiseringsheadere for SIEM sendes som write-only-argumenter og lagres aldri.

Hva vi tester

Modulen over kjøres mot en egen testorganisasjon i fremforge med begge CLI-ene, med skriptet nedenfor:

  1. apply eksemplet;
  2. plan -detailed-exitcode igjen, som må avslutte med 0 (ingen endringer);
  3. gjør en endring: kopier inn test/change_override.tf, plan må avslutte med 2, apply utfører endringen, og plan etter det må avslutte med 0;
  4. destroy, med reset_on_destroy = true, hvoretter hver innstilling leses tilbake slik den var før kjøringen.
Testskriptet: 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
Endringssteget: 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
}

Siste kjøring, 2026-10-06, HashiCorp Terraform 1.16.5 (gjeldende utgivelse, fra releases.hashicorp.com, SHA-256 kontrollert) og OpenTofu 1.13.1 mot frem.sh (Forgejo v15), med et organisasjonstoken med policy:write og sso:read/sso:write:

InnstillingerTerraform 1.16.5OpenTofu 1.13.1
Autentiseringspolicy, IP-tillatelsesliste, operatørtilgang, tillatelsesliste og domener for e-post, SSH CA, standarder for branchbeskyttelse, skannepolicy, Actions-policy, oppbevaring av artefakter, SLA-policy, skannerinnstillinger, feilmodus for push-beskyttelse, uforanderlige utgivelser, avhengighetsoppdateringer, webhook-mål, SIEM-endepunkter, utrullingsmiljøer, Actions-secrets for organisasjonen, SSO-domenerapply, plan uten endringer, endring (plan avslutter med 2, apply, plan avslutter med 0), destroy: beståttapply, plan uten endringer, endring (plan avslutter med 2, apply, plan avslutter med 0), destroy: bestått
Repository og branchbeskyttelse (svalabs/forgejo)apply, plan uten endringer, destroy: beståttapply, plan uten endringer, destroy: bestått
Endring utenfor Terraform (en webhook slått av og en innstilling for avhengighetsoppdateringer endret gjennom API-et)plan avslutter med 2 og viser begge, apply gjenoppretter begge, plan avslutter med 0plan avslutter med 2 og viser begge, apply gjenoppretter begge, plan avslutter med 0

Hver innstilling i modulen ble kjørt live: 27 ressurser brukt, 15 endret i endringssteget, 28 ødelagt, under hver CLI. De to CLI-ene oppførte seg likt i hvert steg. De eneste forskjellene er kontrollen av providersignaturen ved init og én linje i plan-utskriften, begge beskrevet over. Ikke dekket av kjøringen: SIEM-endepunkter med en autentiseringsheader, og bekreftelse av SSO-domener. Inputen dependency_proxy ble lagt til senere (2026-10-10) og kjørt for seg med OpenTofu mot en testorganisasjon med avhengighetsproxyen slått på: opprettelse, en plan uten endringer, en endring fra "shadow" til "on", en plan uten endringer og destroy, som satte metadata_filter tilbake til "off".

Hva destroy med reset_on_destroy = true etterlater, sammenlignet med en organisasjon som aldri har satt noe: standardene for branchbeskyttelse er fremforges grunnlinje i stedet for usatt, skannepolicyen beholder én rad per skanner du styrte, på enforce (det samme som standarden i plan), SLA-policyen leses tilbake som et objekt med null-verdier i stedet for null, og skannerinnstillinger leses tilbake som configured: true med standardverdiene sine. Hver av dem oppfører seg likt med den urørte standarden.

Kjør den

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

Ta en eksisterende organisasjon under styring

Hver organisasjon har allerede sine enkeltstående innstillinger (autentiseringspolicy, IP-tillatelsesliste og så videre). Modulens «create» for en enkeltstående innstilling er den samme skrivingen som en oppdatering, så den første apply skriver de konfigurerte verdiene dine over de som gjelder nå. For å se forskjellen først importerer du hver enkeltstående innstilling og kjører deretter plan. Med restful-provideren angir import-ID-en lesestien og feltene du styrer:

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øklene i body må stemme med feltene du setter i modulinputen. Etter importen viser plan bare feltene der den gjeldende verdien er forskjellig fra konfigurasjonen din. Gjør det samme for de andre enkeltstående innstillingene, med read_selector lagt til der tabellen over leser fra GET /orgs/{org}/security.

For en regel for branchbeskyttelse i et eksisterende repository:

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

Oppdagelse av avvik

Noen endrer en innstilling i konsollen, og neste plan viser det. -detailed-exitcode gjør det om til noe CI kan handle på: 0 betyr ingen endringer, 2 betyr avvik, 1 betyr feil. Dette er det vi kjørte 2026-10-03 med OpenTofu, med to endringer gjort gjennom API-et i stedet for endringer i konsollen. 2026-10-06 ga begge CLI-ene de samme avslutningskodene i samme test med en webhook og en innstilling for avhengighetsoppdateringer:

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

En planlagt jobb som kjører plan -detailed-exitcode med et token med bare lesetilgang og varsler ved avslutningskode 2, gir deg en avviksalarm uten skrivetilgang. Hver endring gjort gjennom API-et ligger også i revisjonsloggen din under tokenet som gjorde den.

Destroy

De enkeltstående innstillingene kan ikke slettes, så destroy er også en skriving. Modulens reset_on_destroy avgjør hvilken:

  • false (standard): destroy skriver tilbake de gjeldende verdiene dine, så ingenting endres, og innstillingen forsvinner bare fra state. Bruk dette for en organisasjon folk jobber i.
  • true: destroy skriver plattformens standard for hvert felt du styrer, og for standarder for branchbeskyttelse kaller den POST /orgs/{org}/branch-defaults/reset. Bruk dette for en testorganisasjon.

Samlinger (webhooks, SIEM-endepunkter, miljøer, e-postdomener, SSO-domener, secrets) slettes. Et slettet repository havner i organisasjonens papirkurv i gjenopprettingsperioden, og å opprette et med samme navn rett etter en destroy kan feile mens slettingen fullføres, så gi et repository du oppretter og ødelegger gjentatte ganger, et nytt navn.

For å slutte å styre en innstilling uten å røre den bruker du en removed-blokk:

removed {
  from = module.org.restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

Ikke dekket

Hva denne modulen ikke styrer, og hvorfor:

  • Team og teammedlemskap. Forgejo-provideren har forgejo_team og forgejo_team_member, men en endring av et team krever et Forgejo-token med scope for organisasjonen, og det tilbyr ikke siden Git PATs. Vi har ikke testet dem på fremforge.
  • SSO-autentiseringskilder (OIDC). De har en klient-secret, og å registrere en krever et bekreftet domene og en fungerende IdP, så de er ikke i testorganisasjonen vår. API-et (/orgs/{org}/sso) støtter registrering og oppdatering av koblingen mellom grupper og team.
  • Innebygd SSO-håndheving (tilvalget for ny autentisering per organisasjon). Det finnes ikke noe API for det.
  • SCIM, OAuth-klienter, CI-boter, API-tokener. Hver opprettelse returnerer en legitimasjon én gang. Å holde dem i state er feil avveining.
  • Medlemmer og brukere. Å legge til en bruker oppretter en konto for en person; invitasjoner sender e-post. Les dem med en restful_resource-datakilde mot GET /orgs/{org}/seats.
  • Overstyringer per repository (skannepolicy, fravalg av uforanderlighet, offentlig dokumentasjon), tidsavgrensede unntak (overstyringer av push-beskyttelse og skadevareskanning, avvisninger av funn), sikkerhetsoppsummeringer og varselinnstillinger.
  • Miljøvariabler, secrets, sperrer og branchregler inne i et miljø. Modulen oppretter bare miljøet.
  • Feltet schedule i avhengighetsoppdateringer. Det er null som standard, og providerens lesemal kan ikke gjengi en null.
  • Alt som ikke er i OpenAPI-spesifikasjonen. En ukjent sti svarer 501, noe som også kan bety en skrivefeil.

Begrensninger ved denne tilnærmingen

  • Den generiske provideren vet ingenting om fremforge. Den kan ikke advare om at releases = true er enveis, eller validere en kropp før apply. Hver avvisning kommer som API-ets feil ved apply. API-ets egne kontroller gjelder fortsatt, som kontrollen mot å låse seg ute i IP-tillatelseslisten.
  • Forgejo-provideren er en tredjepartsprovider for Forgejo generelt. En Forgejo-utgivelse kan endre oppførselen dens uten varsel fra oss. Vi låser versjonen og tester den.
  • SIEM-endepunkter med en autentiseringsheader. magodo/restful 0.25.2 glemmer fingeravtrykket av en ressurs’ write-only ephemeral_body etter en oppdatering på stedet. En ressurs som i det hele tatt setter argumentet, planlegger da en tom PATCH ved hver senere kjøring, som API-et avviser med 400. Modulen setter derfor ephemeral_body bare på siem_endpoint_with_auth. For de endepunktene viser planen etter en endring på stedet én oppdatering til, som sender headeren på nytt; planen etter det er ren. Vi har ikke kjørt dette tilfellet live.
  • State inneholder svar fra lesinger. Modulen holder secrets utenfor state som beskrevet over, men state beskriver fortsatt sikkerhetskonfigurasjonen din. Bruk en backend du stoler på, og OpenTofus kryptering av state hvis du kjører OpenTofu.

Hvis du trenger en innstilling som denne siden ikke dekker, si fra til oss på support@frem.sh.

Relatert