Configuration as code with Terraform and OpenTofu
You can keep your fremforge org in code with HashiCorp Terraform or OpenTofu. Both CLIs run the same configuration, with two open-source providers from the public registries:
- Forgejo objects (repositories, branch protection, teams, Actions secrets and variables) through Forgejo’s own REST API, with
svalabs/forgejo. - fremforge settings (authentication policy, IP allowlist, branch-protection defaults, scan policy and scanner settings, immutable releases, SIEM forwarding, webhooks and more) through the fremforge API, with the generic REST provider
magodo/restful.
There is no dedicated fremforge provider. This page and its module are the supported way, and the module is the exact code we run against a test org (see What we test).
Providers
| Provider | Version | Licence | Talks to |
|---|---|---|---|
svalabs/forgejo | 1.6.1 | MPL-2.0 | Forgejo, https://frem.sh/api/v1 |
magodo/restful | 0.25.2 | MPL-2.0 | fremforge, https://frem.sh/_app/api/v1 |
The provider blocks are identical for both CLIs:
terraform {
required_version = ">= 1.11.0"
required_providers {
restful = {
source = "magodo/restful"
version = "0.25.2"
}
forgejo = {
source = "svalabs/forgejo"
version = "1.6.1"
}
}
}
provider "restful" {
base_url = "https://frem.sh/_app/api/v1"
# 60 writes a minute per token; retry the 429 instead of failing half-way.
client = {
retry = {
status_codes = [429]
count = 6
wait_in_sec = 10
max_wait_in_sec = 60
}
}
security = {
http = {
token = {
token = var.fremforge_token # ffp_... fremforge API token
}
}
}
}
provider "forgejo" {
host = "https://frem.sh"
api_token = var.forgejo_token # Forgejo access token
}Rate limit. The fremforge API allows one token 60 writes a minute and answers 429 with Retry-After above that. The full example is about 27 writes to apply and 28 to destroy, so two runs back to back go over it. The client.retry block makes the provider wait and retry a 429, which is safe because a 429 means the write was not made.
>= 1.11.0 is needed for write-only arguments, which keep secrets you send (an Actions secret value, a SIEM auth header) out of state. Terraform 1.11 and OpenTofu 1.11 both support them.
Where the binaries come from. source = "magodo/restful" resolves to registry.terraform.io under Terraform and to registry.opentofu.org under OpenTofu. Both registries point the download at the project’s GitHub releases. Commit .terraform.lock.hcl: it records each provider’s checksums, so a later init refuses a binary that changed. The lock file records the registry hostname, so keep one per CLI and do not share a working directory between them.
Signatures differ between the two CLIs. Terraform checks each provider’s developer signature at init and prints Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) and Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79). The OpenTofu registry holds no signing keys for these two providers, so OpenTofu prints Signature validation was skipped and relies on the checksums alone. Under OpenTofu, the committed lock file is therefore the only check that the binary is the one you reviewed.
If you do not want CI to fetch from GitHub on every run, mirror the providers once and install from the mirror:
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
The two providers use different tokens, because they talk to different APIs.
fremforge API (restful). An ffp_... token. Mint an org API token under Org admin → API tokens (frem.sh/<org>/_admin/api-tokens). It is bound to one org, survives the person who minted it leaving, and expires after at most 90 days. Mint a read-only token for plan and keep the write scopes for the job that runs apply:
| Settings | Read (plan) | Write (apply) |
|---|---|---|
| Authentication policy, IP allowlist, operator access, email domains, SSH CA, branch-protection defaults, scan policy, Actions policy, artifact retention, SLA policy, SIEM endpoints | policy:read | policy:write |
| Scanner settings, push-protection failure mode, immutable releases | findings:read | policy:write |
| Webhook destinations | webhooks:read | webhooks:write |
| Deployment environments | environments:read | environments:write |
| Org Actions secrets | orgs:read | orgs:write |
| Dependency updates | renovate:read | renovate:write |
| SSO domains | sso:read | sso:write |
From a Forgejo Actions job you can avoid storing a token by exchanging the job’s OIDC token for a one-hour ffp_ token (see OIDC token exchange). Today that exchange grants a fixed set of scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write): enough to plan the scanner, push-protection and immutability settings, but not to change them and not to plan the policy:* ones. Changing any security setting needs policy:write, which the runner exchange never grants, so a repository’s CI cannot switch your org’s controls off. For apply, use an org API token.
Forgejo API (forgejo). A Forgejo access token, not an ffp_ token. The provider checks the token against Forgejo when it starts, even when it has nothing to manage, so it cannot be left empty. To manage only fremforge settings, delete forgejo.tf, the forgejo provider block, its required_providers entry and the forgejo_token variable. If your org’s authentication policy does not allow user tokens, mint it under User settings → Git PATs (frem.sh/user/settings#ff-git-pats), which offers repository and issue scopes: enough for repositories and branch protection, not for teams.
The module
The module manages every org-level policy setting the fremforge API exposes. Each setting is an optional input: leave it out and the module does not touch it. For each setting you set, the module owns exactly the fields you name. Fields you leave out are neither sent nor compared, so a console change to them does not show as drift.
It lives in the docs repository under examples/terraform/. The files below are included straight from it, so this page always shows the code we test.
Module inputs: 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 = [...], principal_policy = \"username\", require_cert = false }."
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 = "PUT /orgs/{org}/dependency-updates/settings. E.g. { automerge_patch = true, major_approval = true }. Every field except `schedule`."
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 "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 "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 = {}
}Module resources: 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 = [], principal_policy = "username", require_cert = false }
immutability = { releases = false, allow_repo_opt_out = false, oci_mode = "off", oci_mutable_tags = [] }
push_protection_failure_mode = "block"
# 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"
}
}
dependency_updates = {
enabled = true
automerge_patch = true
automerge_minor = false
major_approval = true
security_automerge = true
pr_hourly_limit = 0
pr_concurrent_limit = 0
}
}
# 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
} :
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 (Renovate) ------------------------------------------
# Written in snake_case, read back in camelCase; the template maps one to the
# other so plan compares like with like. `schedule` is not managed: it is null
# by default, and the provider's response template cannot render a null.
resource "restful_resource" "dependency_updates" {
count = var.dependency_updates == null ? 0 : 1
path = "${local.org}/dependency-updates/settings"
read_path = "${local.org}/dependency-updates"
read_response_template = <<-EOT
{
"enabled": $(body.settings.enabled),
"automerge_patch": $(body.settings.automergePatch),
"automerge_minor": $(body.settings.automergeMinor),
"major_approval": $(body.settings.majorApproval),
"security_automerge": $(body.settings.securityAutomerge),
"pr_hourly_limit": $(body.settings.prHourlyLimit),
"pr_concurrent_limit": $(body.settings.prConcurrentLimit)
}
EOT
create_method = "PUT"
update_method = "PUT"
update_path = "${local.org}/dependency-updates/settings"
delete_method = "PUT"
delete_path = "${local.org}/dependency-updates/settings"
body = var.dependency_updates
delete_body = local.reset.dependency_updates
}
# --- 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 }
}
# --- 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
}
# --- 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
}Module outputs: 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 })
}Module provider pin: 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"
}
}
}Calling it
A root module that configures one org end to end. Replace the example URLs and CIDRs with your own.
# 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 = []
principal_policy = "username"
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
}
# 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"
immutability = {
releases = false
allow_repo_opt_out = false
oci_mode = "off"
oci_mutable_tags = []
}
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
}# 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",
]
}Example inputs: 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 = []
}Every endpoint, and how it maps
| Setting, module input | API (write, then read) |
|---|---|
Authentication policy, auth_policy | PATCH, GET /orgs/{org}/auth-policy |
IP allowlist, ip_allowlist | PUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security |
Operator access, operator_access | PUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security |
Email-domain allowlist on/off, email_domain_allowlist_enforced | PUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains |
Allowed email domains, email_domains | POST, GET, DELETE /orgs/{org}/email-domains |
SSH certificate authority, ssh_ca | PUT, GET /orgs/{org}/ssh-ca |
SSO domains, sso_domains | POST, GET, DELETE /orgs/{org}/sso/domains |
Branch-protection defaults, branch_protection_defaults | PUT, GET /orgs/{org}/policy (branch_protection) |
Org-wide scan policy, scan_policy | PUT, GET /orgs/{org}/policy (scan_policy) |
Actions policy, actions_policy | PUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security |
Artifact retention, artifact_retention | PUT, GET /orgs/{org}/artifact-retention |
Dependency updates, dependency_updates | PUT /orgs/{org}/dependency-updates/settings, GET /orgs/{org}/dependency-updates |
Scanner settings, scanner_settings | PUT, GET /orgs/{org}/code-security/{scanner}/settings |
Licence policy per repository, licence_repo_overrides | PUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings |
Push-protection failure mode, push_protection_failure_mode | PUT, GET /orgs/{org}/push-protection/failure-mode |
Immutable releases and tags, immutability | PUT, GET /orgs/{org}/code-security/immutability |
Security SLA policy, sla_policy | PUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security |
Webhook destinations, webhooks | POST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations |
SIEM forwarding, siem_endpoints | POST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints |
Deployment environments, environments | POST, GET, DELETE /orgs/{org}/environments |
Package access default, package_access_default | PUT, GET /orgs/{org}/packages/access-settings |
Per-package access and grants, packages | PUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo} |
Protected package names, package_protection_rules | POST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules |
Org Actions secrets, org_secrets | PUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets |
Repositories, forgejo_repository | Forgejo /api/v1/orgs/{org}/repos |
Branch protection per repository, forgejo_branch_protection | Forgejo /api/v1/repos/{org}/{repo}/branch_protections |
What to know per setting:
- Authentication policy: any subset of fields.
max_pat_lifetime_daystakesnullor one of7,30,90,180,365; any other value is refused with400. - IP allowlist: the
PUTreplaces the whole list, so send all three fields. The first write also moves an org still on a legacy scope (weborweb+git) toall, one list for every surface; the legacy scopes cannot be chosen again. An enforcing list that does not contain your own address is refused with400 invalid-allowlist, so the CLI cannot lock out the machine it runs on. Start withaudit_only = true. - Branch-protection defaults: includes
merge_queue(default,on,off) for the rules these defaults create. - Org-wide scan policy:
image_scan.block_severityis the image block threshold for merges and pulls:low,medium,high,criticalornull(high). Other values are refused with400. - Some settings are written and read under different names (operator access, Actions policy, artifact retention, email-domain allowlist, dependency updates). The module maps them with a read template, so
plancompares like with like. - Dependency updates: every field except
schedule. - Immutable releases:
releases = trueis one-way for the releases published while it is on. - Webhooks and SIEM endpoints: only
active(webhooks) orenabled(SIEM) change in place. Any other change replaces the destination and mints a new secret. The module manages SIEM endpoints with an auth header in a separate resource,siem_endpoint_with_auth; see Limits. - SSO domains: registration only. The module registers each domain and reads it back from the
challengeslist; the DNS or HTTP verification is a manual step in the console. - Branch protection on repositories (
forgejo_branch_protection): fremforge adds afremforge/<scanner>status context for every scanner your scan policy enforces, and every scanner enforces by default. List all of them, in the order the example uses, or a laterplanshows the rule as drift. - Org Actions secrets: the value is write-only, so
plancannot see a changed value. Rotate with-replace. Pass the values from your secret store, for exampleTF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'. - Write-only arguments in OpenTofu’s plan: when any
restful_resourcechanges in place, OpenTofu prints~ ephemeral_body = (write-only attribute)among the changes. Terraform leaves that line out. It is not drift and nothing extra is sent; the counts inPlan:are the same under both CLIs.
Secrets stay out of state. Webhook signing secrets and SIEM HMAC secrets are returned once when they are created. The module’s output_attrs drops them from state; read them in the console or rotate them through the API. Actions secret values and SIEM auth headers are sent as write-only arguments and never stored.
What we test
The module above runs against a dedicated fremforge test org with both CLIs, using the script below:
applythe example;plan -detailed-exitcodeagain, which must exit0(no changes);- apply a change: copy
test/change_override.tfin,planmust exit2,applymakes the change, and theplanafter it must exit0; destroy, withreset_on_destroy = true, after which every setting reads back as it did before the run.
The test script: 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 1The change step: 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 = []
principal_policy = "username"
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
immutability = {
releases = false
allow_repo_opt_out = true # was false
oci_mode = "off"
oci_mutable_tags = []
}
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
}Last run, 2026-10-06, HashiCorp Terraform 1.16.5 (the current release, from releases.hashicorp.com, SHA-256 checked) and OpenTofu 1.13.1 against frem.sh (Forgejo v15), with an org token holding policy:write and sso:read/sso:write:
| Settings | Terraform 1.16.5 | OpenTofu 1.13.1 |
|---|---|---|
| Authentication policy, IP allowlist, operator access, email-domain allowlist and domains, SSH CA, branch-protection defaults, scan policy, Actions policy, artifact retention, SLA policy, scanner settings, push-protection failure mode, immutable releases, dependency updates, webhook destinations, SIEM endpoints, deployment environments, org Actions secrets, SSO domains | apply, no-change plan, change (plan exit 2, apply, plan exit 0), destroy: pass | apply, no-change plan, change (plan exit 2, apply, plan exit 0), destroy: pass |
Repository and branch protection (svalabs/forgejo) | apply, no-change plan, destroy: pass | apply, no-change plan, destroy: pass |
| Out-of-band change (a webhook disabled and a dependency-update setting changed through the API) | plan exit 2 shows both, apply restores both, plan exit 0 | plan exit 2 shows both, apply restores both, plan exit 0 |
Every setting in the module ran live: 27 resources applied, 15 changed in the change step, 28 destroyed, under each CLI. The two CLIs behaved the same on every step. The only differences are the provider signature check at init and one line of plan output, both described above. Not covered by the run: SIEM endpoints with an auth header, and SSO domain verification.
What destroy with reset_on_destroy = true leaves behind, compared with an org that never set anything: branch-protection defaults are the fremforge baseline rather than unset, the scan policy keeps a row per scanner you managed at enforce (the same as the plan default), the SLA policy reads back as an object of nulls rather than null, and scanner settings read back as configured: true with their default values. Each behaves the same as the untouched default.
Run it
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 applyBring an existing org under management
Every org already has its singleton settings (authentication policy, IP allowlist and so on). The module’s “create” for a singleton is the same write as an update, so the first apply writes your configured values over the live ones. To see the difference first, import each singleton, then plan. With the restful provider, the import ID names the read path and the fields you manage:
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
}
})
}The body keys must match the fields you set in the module input. After the import, plan shows only the fields whose live value differs from your configuration. Do the same for the other singletons, with read_selector added where the table above reads from GET /orgs/{org}/security.
For an existing repository’s branch-protection rule:
import {
to = forgejo_branch_protection.service_main
id = "acme/payments-service/main"
}Drift detection
Someone changes a setting in the console, and the next plan shows it. -detailed-exitcode turns that into something CI can act on: exit 0 means no changes, 2 means drift, 1 means an error. This is what we ran on 2026-10-03 with OpenTofu, with two changes made through the API to stand in for console edits. On 2026-10-06 both CLIs gave the same exit codes on the same test with a webhook and a dependency-update setting:
$ 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.A scheduled job that runs plan -detailed-exitcode with a read-only token and alerts on exit 2 gives you a drift alarm without write access. Every change made through the API is also in your audit log under the token that made it.
Destroy
The singleton settings cannot be deleted, so destroy is a write too. The module’s reset_on_destroy decides which:
false(the default):destroywrites your current values back, so nothing changes and the setting only leaves state. Use this for an org people work in.true:destroywrites the platform default for every field you manage, and for branch-protection defaults callsPOST /orgs/{org}/branch-defaults/reset. Use this for a test org.
Collections (webhooks, SIEM endpoints, environments, email domains, SSO domains, secrets) are deleted. A deleted repository goes to the org’s trash for its restore window, and creating one with the same name straight after a destroy can fail while the delete completes, so give a repository you create and destroy repeatedly a fresh name.
To stop managing a setting without touching it, use a removed block:
removed {
from = module.org.restful_resource.immutability
lifecycle {
destroy = false
}
}Not covered
What this module does not manage, and why:
- Teams and team membership. The Forgejo provider has
forgejo_teamandforgejo_team_member, but a team change needs an organisation-scoped Forgejo token, which the Git PATs page does not offer. We have not tested them on fremforge. - SSO auth sources (OIDC, SAML). They carry a client secret or certificate, and registering one needs a verified domain and a working IdP, so they are not in our test org. The API (
/orgs/{org}/sso) supports register and update of the group-to-team map. - Native SSO enforcement (the per-org fresh re-authentication opt-in). There is no API for it.
- SCIM, OAuth clients, CI bots, API tokens. Each create returns a credential once. Keeping those in state is the wrong trade.
- Members and seats. Adding a seat creates an account for a person; invitations send email. Read them with a
restful_resourcedata source onGET /orgs/{org}/seats. - Per-repository overrides (scan policy, immutability opt-outs, public docs), time-boxed exceptions (push-protection and malware-scan overrides, finding dismissals), security digests and notification preferences.
- Environment variables, secrets, gates and branch rules inside an environment. The module creates the environment only.
- The
schedulefield of dependency updates. It isnullby default, and the provider’s read template cannot render anull. - Anything not in the OpenAPI spec. An unknown path answers
501, which can also mean a typo.
Limits of this approach
- The generic provider knows nothing about fremforge. It cannot warn that
releases = trueis one-way or validate a body beforeapply. Every refusal arrives as the API’s error at apply time. The API’s own checks still apply, such as the lock-out check on the IP allowlist. - The Forgejo provider is a third-party provider for Forgejo in general. A Forgejo release can change its behaviour without notice from us. We pin the version and test it.
- SIEM endpoints with an auth header.
magodo/restful0.25.2 forgets a resource’s write-onlyephemeral_bodyfingerprint after an in-place update. A resource that sets the argument at all then plans an emptyPATCHon every later run, which the API refuses with400. The module therefore setsephemeral_bodyonly onsiem_endpoint_with_auth. For those endpoints, the plan after an in-place change shows one more update, which re-sends the header; the plan after that is clean. We have not run this case live. - State holds read responses. The module keeps secrets out of state as described above, but state still describes your security configuration. Use a backend you trust, and OpenTofu’s state encryption if you run OpenTofu.
If you need a setting this page does not cover, tell us at support@frem.sh.
Related
- Public REST API: authentication, tokens and scopes
- Authentication policy
- IP allowlist
- Immutable releases
- Branch protection: the full list of fremforge status contexts
- SIEM forwarding