Hoppa till huvudinnehållet

Konfiguration som kod med Terraform och OpenTofu

Du kan hålla din fremforge-organisation i kod med HashiCorp Terraform eller OpenTofu. Båda CLI:erna kör samma konfiguration, med två providers med öppen källkod från de publika registren:

  • Forgejo-objekt (repon, branch-skydd, team, hemligheter och variabler för Actions) via Forgejos eget REST-API, med svalabs/forgejo.
  • fremforge-inställningar (autentiseringspolicy, IP-tillåtelselista, standardvärden för branch-skydd, skanningspolicy och skannerinställningar, oföränderliga releaser, SIEM-vidarebefordran, webhooks med mera) via fremforges API, med den generiska REST-providern magodo/restful.

För fremforges egna inställningar finns det nu en särskild provider: fremforge-providern (source = "frem.sh/fremforge/fremforge", 0.1.1, installeras från frem.sh med signaturkontroll) täcker dem med typade resurser, import via organisationens slug och avvikelseupptäckt på varje argument du sätter. Använd den för nya konfigurationer. Modulen på den här sidan förblir dokumenterad medan du flyttar över; Flytta från modulen visar hur. Repon och branch-skydd hanteras med svalabs/forgejo i båda fallen.

Providers

ProviderVersionLicensPratar 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-blocken är identiska för båda CLI:erna:

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
}

Hastighetsgräns. fremforges API tillåter 60 skrivningar per minut och token och svarar 429 med Retry-After över det. Hela exemplet gör ungefär 27 skrivningar vid apply och 28 vid destroy, så två körningar direkt efter varandra går över gränsen. Blocket client.retry får providern att vänta och försöka igen vid 429, vilket är säkert eftersom 429 betyder att skrivningen inte gjordes.

>= 1.11.0 behövs för write-only-argument, som håller hemligheter du skickar (värdet på en Actions-hemlighet, en autentiseringsheader för SIEM) borta från state. Både Terraform 1.11 och OpenTofu 1.11 stöder dem.

Var binärerna kommer ifrån. source = "magodo/restful" pekar på registry.terraform.io under Terraform och på registry.opentofu.org under OpenTofu. Båda registren hänvisar nedladdningen till projektets releaser på GitHub. Committa .terraform.lock.hcl: den registrerar varje providers kontrollsummor, så att en senare init avvisar en binär som har ändrats. Låsfilen registrerar registrets värdnamn, så ha en per CLI och dela inte arbetskatalog mellan dem.

Signaturerna skiljer sig mellan de två CLI:erna. Terraform kontrollerar varje providers utvecklarsignatur vid init och skriver ut Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) och Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79). OpenTofus register har inga signeringsnycklar för de här två providerna, så OpenTofu skriver ut Signature validation was skipped och förlitar sig enbart på kontrollsummorna. Under OpenTofu är den committade låsfilen därför den enda kontrollen av att binären är den du har granskat.

Vill du inte att CI hämtar från GitHub vid varje körning kan du spegla providerna en gång och installera från spegeln:

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 två providerna använder olika tokens, eftersom de pratar med olika API:er.

fremforges API (restful). En ffp_...-token. Skapa en API-token för organisationen under Organisationens administration → API-token (frem.sh/<org>/_admin/api-tokens). Den är bunden till en organisation, överlever att personen som skapade den slutar och går ut efter högst 90 dagar. Skapa en skrivskyddad token för plan och spara skriv-scopen till jobbet som kör apply:

InställningarLäsa (plan)Skriva (apply)
Autentiseringspolicy, IP-tillåtelselista, operatörsåtkomst, e-postdomäner, SSH-CA, standardvärden för branch-skydd, skanningspolicy, Actions-policy, lagringstid för artefakter, SLA-policy, policy för imagesignaturer, SIEM-slutpunkterpolicy:readpolicy:write
Skannerinställningar, felläge för push-skydd, oföränderliga releaserfindings:readpolicy:write
Webhook-målwebhooks:readwebhooks:write
Driftsättningsmiljöerenvironments:readenvironments:write
Organisationens Actions-hemligheterorgs:readorgs:write
Beroendeuppdateringarrenovate:readrenovate:write
SSO-domänersso:readsso:write

Från ett jobb i Forgejo Actions kan du slippa lagra en token genom att byta jobbets OIDC-token mot en ffp_-token som gäller en timme (se OIDC token exchange). I dag ger det utbytet en fast uppsättning scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): nog för att köra plan på skanner-, push-skydds- och oföränderlighetsinställningarna, men inte för att ändra dem och inte för att köra plan på policy:*-inställningarna. Att ändra en säkerhetsinställning kräver policy:write, som runner-utbytet aldrig ger, så ett repos CI kan inte stänga av organisationens kontroller. Använd en API-token för organisationen till apply.

Forgejos API (forgejo). En åtkomsttoken för Forgejo, inte en ffp_-token. Providern kontrollerar token mot Forgejo när den startar, även när den inte har något att hantera, så den kan inte lämnas tom. Vill du bara hantera fremforge-inställningar tar du bort forgejo.tf, provider-blocket forgejo, dess post i required_providers och variabeln forgejo_token. Om organisationens autentiseringspolicy inte tillåter användartokens skapar du den under Användarinställningar → Git-PAT:er (frem.sh/user/settings#ff-git-pats), som erbjuder scopes för repon och issues: nog för repon och branch-skydd, men inte för team.

Modulen

Modulen hanterar varje policyinställning på organisationsnivå som fremforges API exponerar. Varje inställning är en valfri indata: utelämnar du den rör modulen den inte. För varje inställning du sätter äger modulen exakt de fält du anger. Fält du utelämnar varken skickas eller jämförs, så en ändring av dem i konsolen syns inte som en avvikelse.

Den finns i dokumentationens repo under examples/terraform/. Filerna nedan hämtas direkt därifrån, så den här sidan visar alltid koden vi testar.

Modulens indata: 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 resurser: 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 utdata: 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 providerlåsning: 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"
    }
  }
}

Anropa den

En rotmodul som konfigurerar en organisation från början till slut. Byt ut exemplets URL:er och CIDR-intervall mot dina egna.

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",
  ]
}
Exemplets indata: 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     = []
}

Varje slutpunkt och hur den mappas

Inställning, modulens indataAPI (skriva, sedan läsa)
Autentiseringspolicy, auth_policyPATCH, GET /orgs/{org}/auth-policy
IP-tillåtelselista, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Operatörsåtkomst, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
Tillåtelselista för e-postdomäner på/av, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Tillåtna e-postdomäner, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
SSH-certifikatutfärdare, ssh_caPUT, GET /orgs/{org}/ssh-ca
SSO-domäner, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Standardvärden för branch-skydd, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Skanningspolicy för hela organisationen, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Actions-policy, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Lagringstid för artefakter, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Beroendeuppdateringar: versionsuppdateringar, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Beroendeuppdateringar: säkerhetsrättningar, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Skannerinställningar, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Licenspolicy per repo, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Felläge för push-skydd, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Inställningar för undantag (maximal giltighetstid, godkännande team, avfärdanden kräver godkännande), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Oföränderliga releaser och taggar, immutabilityPUT, GET /orgs/{org}/code-security/immutability
SLA-policy för säkerhet, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Krav på imagesignaturer, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (ersätter hela policyn; modulen skickar varje fält)
Beroendeproxy, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Webhook-mål, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
SIEM-vidarebefordran, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Driftsättningsmiljöer, environmentsPOST, GET, DELETE /orgs/{org}/environments
Standardåtkomst för paket, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Åtkomst och behörigheter per paket, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Skyddade paketnamn, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Organisationens Actions-hemligheter, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Repon, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Branch-skydd per repo, forgejo_branch_protectionForgejo /api/v1/repos/{org}/{repo}/branch_protections

Bra att veta per inställning:

  • Autentiseringspolicy: valfri delmängd av fälten. max_pat_lifetime_days tar null eller något av 7, 30, 90, 180, 365; andra värden avvisas med 400.
  • IP-tillåtelselista: PUT ersätter hela listan, så skicka alla tre fälten. Den första skrivningen flyttar också en organisation som fortfarande har ett äldre scope (web eller web+git) till all, en lista för varje yta; de äldre scopen kan inte väljas igen. En tillämpad lista som inte innehåller din egen adress avvisas med 400 invalid-allowlist, så CLI:t kan inte stänga ute maskinen det körs på. Börja med audit_only = true.
  • Standardvärden för branch-skydd: innehåller merge_queue (default, on, off) för de regler som standardvärdena skapar.
  • Skanningspolicy för hela organisationen: image_scan.block_severity är blockeringströskeln för images vid merge och pull: low, medium, high, critical eller null (high). Andra värden avvisas med 400.
  • Vissa inställningar skrivs och läses under olika namn (operatörsåtkomst, Actions-policy, lagringstid för artefakter, tillåtelselista för e-postdomäner, beroendeuppdateringar). Modulen mappar dem med en läsmall, så att plan jämför lika med lika.
  • Beroendeuppdateringar: alla fält utom schedule.
  • Oföränderliga releaser: releases = true går bara åt ett håll för de releaser som publiceras medan det är påslaget.
  • Beroendeproxy: påslagen som standard för varje organisation. malware_scan (standard false) slår på ClamAV-skanning av det som proxyn hämtar; block_until_scanned kräver den, och en PUT som skulle lämna block_until_scanned på med malware_scan av avvisas med 400 scan-pair (se Hämtningar via beroendeproxyn). metadata_filter döljer de versioner som din paketpolicy skulle avvisa i de paketmetadata som proxyn levererar (npm, PyPI, Maven, NuGet, Go, Helm och Cargo), så att pakethanterare väljer den senaste tillåtna versionen i stället för att misslyckas vid nedladdningen. Är ingen version tillåten levereras metadata i sin helhet och nedladdningen avvisas med orsaken. "shadow" döljer ingenting och listar vad den skulle dölja under GET /orgs/{org}/package-policy/decisions (metadata). "on" är tills vidare bara tillgängligt för fremforges interna organisationer; i andra avvisas det med 403 metadata-filter-internal-only.
  • Webhooks och SIEM-slutpunkter: bara active (webhooks) eller enabled (SIEM) ändras på plats. Alla andra ändringar ersätter målet och skapar en ny hemlighet. Modulen hanterar SIEM-slutpunkter med en autentiseringsheader i en separat resurs, siem_endpoint_with_auth; se Begränsningar.
  • SSO-domäner: bara registrering. Modulen registrerar varje domän och läser tillbaka den från listan challenges; verifieringen via DNS eller HTTP är ett manuellt steg i konsolen.
  • Branch-skydd på repon (forgejo_branch_protection): fremforge lägger till en statuskontext fremforge/<scanner> för varje skanner som din skanningspolicy tillämpar, och varje skanner tillämpas som standard. Lista alla, i den ordning som exemplet använder, annars visar en senare plan regeln som en avvikelse.
  • Organisationens Actions-hemligheter: värdet är write-only, så plan kan inte se ett ändrat värde. Rotera med -replace. Skicka värdena från ditt hemlighetsförråd, till exempel TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Write-only-argument i OpenTofus plan: när en restful_resource ändras på plats skriver OpenTofu ut ~ ephemeral_body = (write-only attribute) bland ändringarna. Terraform utelämnar den raden. Det är ingen avvikelse och inget extra skickas; antalen i Plan: är desamma under båda CLI:erna.

Hemligheter hålls borta från state. Signeringshemligheter för webhooks och HMAC-hemligheter för SIEM returneras en gång när de skapas. Modulens output_attrs tar bort dem från state; läs dem i konsolen eller rotera dem via API:et. Värden för Actions-hemligheter och autentiseringsheaders för SIEM skickas som write-only-argument och lagras aldrig.

Vad vi testar

Modulen ovan körs mot en särskild testorganisation i fremforge med båda CLI:erna, med skriptet nedan:

  1. apply på exemplet;
  2. plan -detailed-exitcode igen, som måste avslutas med 0 (inga ändringar);
  3. gör en ändring: kopiera in test/change_override.tf, plan måste avslutas med 2, apply gör ändringen och plan därefter måste avslutas med 0;
  4. destroy, med reset_on_destroy = true, varefter varje inställning läses tillbaka som den var före körningen.
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
Ändringssteget: 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
}

Senaste körning, 2026-10-06, HashiCorp Terraform 1.16.5 (den aktuella versionen, från releases.hashicorp.com, SHA-256-kontrollerad) och OpenTofu 1.13.1 mot frem.sh (Forgejo v15), med en organisationstoken med policy:write och sso:read/sso:write:

InställningarTerraform 1.16.5OpenTofu 1.13.1
Autentiseringspolicy, IP-tillåtelselista, operatörsåtkomst, tillåtelselista och domäner för e-post, SSH-CA, standardvärden för branch-skydd, skanningspolicy, Actions-policy, lagringstid för artefakter, SLA-policy, skannerinställningar, felläge för push-skydd, oföränderliga releaser, beroendeuppdateringar, webhook-mål, SIEM-slutpunkter, driftsättningsmiljöer, organisationens Actions-hemligheter, SSO-domänerapply, plan utan ändringar, ändring (plan avslutas med 2, apply, plan avslutas med 0), destroy: godkäntapply, plan utan ändringar, ändring (plan avslutas med 2, apply, plan avslutas med 0), destroy: godkänt
Repo och branch-skydd (svalabs/forgejo)apply, plan utan ändringar, destroy: godkäntapply, plan utan ändringar, destroy: godkänt
Ändring utanför Terraform (en webhook avstängd och en inställning för beroendeuppdateringar ändrad via API:et)plan avslutas med 2 och visar båda, apply återställer båda, plan avslutas med 0plan avslutas med 2 och visar båda, apply återställer båda, plan avslutas med 0

Varje inställning i modulen kördes skarpt: 27 resurser skapade, 15 ändrade i ändringssteget, 28 borttagna, under varje CLI. De två CLI:erna betedde sig likadant i varje steg. De enda skillnaderna är signaturkontrollen av providers vid init och en rad i plan-utdata, båda beskrivna ovan. Ingick inte i körningen: SIEM-slutpunkter med autentiseringsheader och verifiering av SSO-domäner. Indata dependency_proxy lades till senare (2026-10-10) och kördes separat med OpenTofu mot en testorganisation med beroendeproxyn påslagen: skapa, en plan utan ändringar, en ändring från "shadow" till "on", en plan utan ändringar och destroy, som satte tillbaka metadata_filter till "off".

Vad destroy med reset_on_destroy = true lämnar kvar, jämfört med en organisation som aldrig har satt något: standardvärdena för branch-skydd är fremforges grundnivå i stället för osatta, skanningspolicyn behåller en rad per skanner du har hanterat på enforce (samma som standardvärdet i planen), SLA-policyn läses tillbaka som ett objekt med null-värden i stället för null, och skannerinställningar läses tillbaka som configured: true med sina standardvärden. Var och en beter sig likadant som det orörda standardvärdet.

Kö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 befintlig organisation under hantering

Varje organisation har redan sina singleton-inställningar (autentiseringspolicy, IP-tillåtelselista och så vidare). Modulens “create” för en singleton är samma skrivning som en uppdatering, så den första apply skriver dina konfigurerade värden över de aktuella. Vill du se skillnaden först importerar du varje singleton och kör sedan plan. Med restful-providern anger import-ID:t läsvägen och de fält du hanterar:

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

Nycklarna i body måste motsvara de fält du sätter i modulens indata. Efter importen visar plan bara de fält vars aktuella värde skiljer sig från din konfiguration. Gör likadant för de andra singletonerna, med read_selector tillagt där tabellen ovan läser från GET /orgs/{org}/security.

För en befintlig branch-skyddsregel i ett repo:

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

Upptäcka avvikelser

Någon ändrar en inställning i konsolen, och nästa plan visar det. -detailed-exitcode gör det till något CI kan agera på: avslutningskod 0 betyder inga ändringar, 2 betyder avvikelse och 1 betyder fel. Så här såg det ut när vi körde det 2026-10-03 med OpenTofu, med två ändringar gjorda via API:et i stället för i konsolen. 2026-10-06 gav båda CLI:erna samma avslutningskoder i samma test med en webhook och en inställning för beroendeuppdateringar:

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

Ett schemalagt jobb som kör plan -detailed-exitcode med en skrivskyddad token och larmar vid avslutningskod 2 ger dig ett avvikelselarm utan skrivbehörighet. Varje ändring som görs via API:et finns också i din granskningslogg under den token som gjorde den.

Destroy

Singleton-inställningarna kan inte tas bort, så destroy är också en skrivning. Modulens reset_on_destroy avgör vilken:

  • false (standard): destroy skriver tillbaka dina nuvarande värden, så ingenting ändras och inställningen lämnar bara state. Använd det för en organisation där folk arbetar.
  • true: destroy skriver plattformens standardvärde för varje fält du hanterar, och för standardvärdena för branch-skydd anropas POST /orgs/{org}/branch-defaults/reset. Använd det för en testorganisation.

Samlingar (webhooks, SIEM-slutpunkter, miljöer, e-postdomäner, SSO-domäner, hemligheter) tas bort. Ett borttaget repo hamnar i organisationens papperskorg under återställningsfönstret, och att skapa ett med samma namn direkt efter en destroy kan misslyckas medan borttagningen slutförs, så ge ett repo som du skapar och tar bort upprepade gånger ett nytt namn.

För att sluta hantera en inställning utan att röra den använder du ett removed-block:

removed {
  from = module.org.restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

Omfattas inte

Vad modulen inte hanterar, och varför:

  • Team och teammedlemskap. Forgejo-providern har forgejo_team och forgejo_team_member, men en teamändring kräver en Forgejo-token med scope för organisationen, som sidan för Git-PAT:er inte erbjuder. Vi har inte testat dem på fremforge.
  • SSO-autentiseringskällor (OIDC). De bär en klienthemlighet, och att registrera en kräver en verifierad domän och en fungerande IdP, så de finns inte i vår testorganisation. API:et (/orgs/{org}/sso) stöder registrering och uppdatering av mappningen från grupper till team.
  • Inbyggt SSO-krav (organisationens val att kräva ny autentisering). Det finns inget API för det.
  • SCIM, OAuth-klienter, CI-bottar, API-tokens. Varje skapande returnerar en autentiseringsuppgift en gång. Att hålla dem i state är fel avvägning.
  • Medlemmar och användare. Att lägga till en användare skapar ett konto för en person; inbjudningar skickar e-post. Läs dem med en datakälla restful_resource på GET /orgs/{org}/seats.
  • Åsidosättningar per repo (skanningspolicy, undantag från oföränderlighet, publik dokumentation), tidsbegränsade undantag (åsidosättningar av push-skydd och skadeprogramsskanning, avfärdade fynd), säkerhetssammanfattningar och aviseringsinställningar.
  • Miljövariabler, hemligheter, grindar och branch-regler inom en miljö. Modulen skapar bara själva miljön.
  • Fältet schedule i beroendeuppdateringar. Det är null som standard, och providerns läsmall kan inte återge ett null.
  • Allt som inte finns i OpenAPI-specifikationen. En okänd sökväg svarar 501, vilket också kan betyda ett stavfel.

Begränsningar med det här arbetssättet

  • Den generiska providern vet ingenting om fremforge. Den kan inte varna för att releases = true bara går åt ett håll eller validera en body före apply. Varje avvisning kommer som API:ets fel vid apply. API:ets egna kontroller gäller fortfarande, till exempel kontrollen mot utestängning i IP-tillåtelselistan.
  • Forgejo-providern är en tredjepartsprovider för Forgejo i allmänhet. En Forgejo-release kan ändra dess beteende utan förvarning från oss. Vi låser versionen och testar den.
  • SIEM-slutpunkter med autentiseringsheader. magodo/restful 0.25.2 glömmer en resurs fingeravtryck för write-only-argumentet ephemeral_body efter en uppdatering på plats. En resurs som sätter argumentet överhuvudtaget planerar då en tom PATCH vid varje senare körning, som API:et avvisar med 400. Modulen sätter därför ephemeral_body bara på siem_endpoint_with_auth. För de slutpunkterna visar planen efter en ändring på plats ytterligare en uppdatering, som skickar headern igen; planen därefter är ren. Vi har inte kört det fallet skarpt.
  • State innehåller läsresultat. Modulen håller hemligheter borta från state enligt ovan, men state beskriver ändå din säkerhetskonfiguration. Använd en backend du litar på, och OpenTofus state-kryptering om du kör OpenTofu.

Behöver du en inställning som den här sidan inte täcker, hör av dig till support@frem.sh.

Relaterat