La configuration en code avec Terraform et OpenTofu
Vous pouvez décrire votre organisation fremforge en code avec HashiCorp Terraform ou OpenTofu. Les deux CLI exécutent la même configuration, avec deux providers open source issus des registres publics :
- Les objets Forgejo (dépôts, protection de branche, équipes, secrets et variables Actions) via l’API REST de Forgejo, avec
svalabs/forgejo. - Les paramètres fremforge (politique d’authentification, liste d’adresses IP autorisées, valeurs par défaut de la protection de branche, politique d’analyse et paramètres des scanners, releases immuables, transfert vers un SIEM, webhooks, etc.) via l’API fremforge, avec le provider REST générique
magodo/restful.
Pour les paramètres propres à fremforge, il existe désormais un provider dédié : le provider fremforge (source = "frem.sh/fremforge/fremforge", 0.1.1, installé depuis frem.sh avec vérification de signature) les couvre avec des ressources typées, l’import par slug d’organisation et la détection de dérive sur chaque argument que vous définissez. Privilégiez-le pour les nouvelles configurations. Le module de cette page reste documenté le temps de votre migration ; Migrer depuis le module explique comment procéder. Les dépôts et la protection de branche restent gérés par svalabs/forgejo dans les deux cas.
Providers
| Provider | Version | Licence | Communique avec |
|---|---|---|---|
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 |
Les blocs provider sont identiques pour les deux CLI :
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
}Limite de débit. L’API fremforge autorise 60 écritures par minute et par jeton, et répond 429 avec Retry-After au-delà. L’exemple complet représente environ 27 écritures pour l’apply et 28 pour le destroy, si bien que deux exécutions d’affilée dépassent la limite. Le bloc client.retry fait attendre le provider puis réessayer après un 429, ce qui est sans risque, car un 429 signifie que l’écriture n’a pas eu lieu.
>= 1.11.0 est nécessaire pour les arguments en écriture seule, qui gardent hors de l’état les secrets que vous envoyez (la valeur d’un secret Actions, un en-tête d’authentification SIEM). Terraform 1.11 et OpenTofu 1.11 les prennent tous deux en charge.
D’où viennent les binaires. source = "magodo/restful" est résolu vers registry.terraform.io sous Terraform et vers registry.opentofu.org sous OpenTofu. Les deux registres renvoient le téléchargement vers les releases GitHub du projet. Commitez .terraform.lock.hcl : il enregistre les sommes de contrôle de chaque provider, de sorte qu’un init ultérieur refuse un binaire qui a changé. Le fichier de verrouillage enregistre le nom d’hôte du registre : gardez-en un par CLI et ne partagez pas un répertoire de travail entre les deux.
Les signatures diffèrent entre les deux CLI. Terraform vérifie la signature du développeur de chaque provider lors de l’init et affiche Installed magodo/restful v0.25.2 (self-signed, key ID 2A3C3526EE99038E) et Installed svalabs/forgejo v1.6.1 (self-signed, key ID B50B0D17F7964C79). Le registre OpenTofu ne détient aucune clé de signature pour ces deux providers ; OpenTofu affiche donc Signature validation was skipped et s’appuie uniquement sur les sommes de contrôle. Sous OpenTofu, le fichier de verrouillage commité est par conséquent la seule vérification que le binaire est bien celui que vous avez examiné.
Si vous ne voulez pas que la CI télécharge depuis GitHub à chaque exécution, créez une fois un miroir des providers et installez-les depuis ce miroir :
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"]
}
}Jetons
Les deux providers utilisent des jetons différents, car ils communiquent avec des API différentes.
API fremforge (restful). Un jeton ffp_.... Créez un jeton d’API d’organisation sous Administration de l’organisation → Jetons d’API (frem.sh/<org>/_admin/api-tokens). Il est lié à une seule organisation, survit au départ de la personne qui l’a créé et expire au bout de 90 jours au maximum. Créez un jeton en lecture seule pour plan et réservez les scopes d’écriture au job qui exécute apply :
| Paramètres | Lecture (plan) | Écriture (apply) |
|---|---|---|
| Politique d’authentification, liste d’adresses IP autorisées, accès opérateur, domaines e-mail, autorité de certification SSH, valeurs par défaut de la protection de branche, politique d’analyse, politique Actions, rétention des artefacts, politique de SLA, politique de signature des images, endpoints SIEM | policy:read | policy:write |
| Paramètres des scanners, mode d’échec de la protection des push, releases immuables | findings:read | policy:write |
| Destinations des webhooks | webhooks:read | webhooks:write |
| Environnements de déploiement | environments:read | environments:write |
| Secrets Actions de l’organisation | orgs:read | orgs:write |
| Mises à jour des dépendances | renovate:read | renovate:write |
| Domaines SSO | sso:read | sso:write |
Depuis un job Forgejo Actions, vous pouvez éviter de stocker un jeton en échangeant le jeton OIDC du job contre un jeton ffp_ valable une heure (voir OIDC token exchange). Aujourd’hui, cet échange accorde un ensemble fixe de scopes (orgs:read, runners:read, findings:read, findings:write, sboms:write) : assez pour planifier les paramètres des scanners, de la protection des push et de l’immutabilité, mais pas pour les modifier, ni pour planifier les paramètres policy:*. Modifier un paramètre de sécurité exige policy:write, que l’échange du runner n’accorde jamais : la CI d’un dépôt ne peut donc pas désactiver les contrôles de votre organisation. Pour apply, utilisez un jeton d’API d’organisation.
API Forgejo (forgejo). Un jeton d’accès Forgejo, et non un jeton ffp_. Le provider vérifie le jeton auprès de Forgejo au démarrage, même lorsqu’il n’a rien à gérer : il ne peut donc pas rester vide. Pour ne gérer que les paramètres fremforge, supprimez forgejo.tf, le bloc provider forgejo, son entrée required_providers et la variable forgejo_token. Si la politique d’authentification de votre organisation n’autorise pas les jetons utilisateur, créez-le sous Paramètres utilisateur → PAT Git (frem.sh/user/settings#ff-git-pats), qui propose des scopes sur les dépôts et les tickets : assez pour les dépôts et la protection de branche, mais pas pour les équipes.
Le module
Le module gère chaque paramètre de politique au niveau de l’organisation qu’expose l’API fremforge. Chaque paramètre est une entrée facultative : omettez-la et le module n’y touche pas. Pour chaque paramètre que vous définissez, le module gère exactement les champs que vous nommez. Les champs omis ne sont ni envoyés ni comparés : une modification de ceux-ci dans la console n’apparaît donc pas comme une dérive.
Il se trouve dans le dépôt de la documentation, sous examples/terraform/. Les fichiers ci-dessous en sont inclus directement : cette page montre donc toujours le code que nous testons.
Entrées du module : 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 = {}
}Ressources du module : 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
}Sorties du module : 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 })
}Épinglage du provider du module : 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"
}
}
}L’appeler
Un module racine qui configure une organisation de bout en bout. Remplacez les URL et les CIDR d’exemple par les vôtres.
# 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
}# 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",
]
}Entrées de l’exemple : 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 = []
}Chaque endpoint et sa correspondance
| Paramètre, entrée du module | API (écriture, puis lecture) |
|---|---|
Politique d’authentification, auth_policy | PATCH, GET /orgs/{org}/auth-policy |
Liste d’adresses IP autorisées, ip_allowlist | PUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security |
Accès opérateur, operator_access | PUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security |
Activation de la liste de domaines e-mail autorisés, email_domain_allowlist_enforced | PUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains |
Domaines e-mail autorisés, email_domains | POST, GET, DELETE /orgs/{org}/email-domains |
Autorité de certification SSH, ssh_ca | PUT, GET /orgs/{org}/ssh-ca |
Domaines SSO, sso_domains | POST, GET, DELETE /orgs/{org}/sso/domains |
Valeurs par défaut de la protection de branche, branch_protection_defaults | PUT, GET /orgs/{org}/policy (branch_protection) |
Politique d’analyse à l’échelle de l’organisation, scan_policy | PUT, GET /orgs/{org}/policy (scan_policy) |
Politique Actions, actions_policy | PUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security |
Rétention des artefacts, artifact_retention | PUT, GET /orgs/{org}/artifact-retention |
Mises à jour des dépendances : mises à jour de version, dependency_updates | GET/PUT /orgs/{org}/dependency-updates/version-updates/settings |
Mises à jour des dépendances : correctifs de sécurité, security_fixes | GET/PUT /orgs/{org}/dependency-updates/security-fixes/settings |
Paramètres des scanners, scanner_settings | PUT, GET /orgs/{org}/code-security/{scanner}/settings |
Politique de licences par dépôt, licence_repo_overrides | PUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings |
Mode d’échec de la protection des push, push_protection_failure_mode | PUT, GET /orgs/{org}/push-protection/failure-mode |
Paramètres des exceptions (durée maximale, équipe d’approbation, approbation requise pour les rejets), exception_settings | PUT, GET /orgs/{org}/exceptions/settings |
Releases et tags immuables, immutability | PUT, GET /orgs/{org}/code-security/immutability |
Politique de SLA de sécurité, sla_policy | PUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security |
Application des signatures d’images, image_signature_policy | PUT, GET /orgs/{org}/image-signature-policy (remplace toute la politique ; le module envoie tous les champs) |
Proxy de dépendances, dependency_proxy | PUT, GET /orgs/{org}/dependency-proxy |
Destinations des webhooks, webhooks | POST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations |
Transfert vers un SIEM, siem_endpoints | POST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints |
Environnements de déploiement, environments | POST, GET, DELETE /orgs/{org}/environments |
Accès par défaut aux paquets, package_access_default | PUT, GET /orgs/{org}/packages/access-settings |
Accès et autorisations par paquet, packages | PUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo} |
Noms de paquets protégés, package_protection_rules | POST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules |
Secrets Actions de l’organisation, org_secrets | PUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets |
Dépôts, forgejo_repository | Forgejo /api/v1/orgs/{org}/repos |
Protection de branche par dépôt, forgejo_branch_protection | Forgejo /api/v1/repos/{org}/{repo}/branch_protections |
À savoir pour chaque paramètre :
- Politique d’authentification : n’importe quel sous-ensemble de champs.
max_pat_lifetime_daysacceptenullou l’une des valeurs7,30,90,180,365; toute autre valeur est refusée avec400. - Liste d’adresses IP autorisées : le
PUTremplace toute la liste, envoyez donc les trois champs. La première écriture fait aussi passer une organisation encore sur une portée historique (webouweb+git) àall, une seule liste pour toutes les surfaces ; les portées historiques ne peuvent plus être choisies. Une liste appliquée qui ne contient pas votre propre adresse est refusée avec400 invalid-allowlist: la CLI ne peut donc pas bloquer la machine sur laquelle elle s’exécute. Commencez avecaudit_only = true. - Valeurs par défaut de la protection de branche : incluent
merge_queue(default,on,off) pour les règles que ces valeurs par défaut créent. - Politique d’analyse à l’échelle de l’organisation :
image_scan.block_severityest le seuil de blocage des images pour les fusions et les pulls :low,medium,high,criticalounull(high). Les autres valeurs sont refusées avec400. - Certains paramètres sont écrits et lus sous des noms différents (accès opérateur, politique Actions, rétention des artefacts, liste de domaines e-mail autorisés, mises à jour des dépendances). Le module les fait correspondre avec un modèle de lecture, afin que
plancompare ce qui est comparable. - Mises à jour des dépendances : tous les champs sauf
schedule. - Releases immuables :
releases = trueest irréversible pour les releases publiées tant qu’il est activé. - Proxy de dépendances : activé par défaut pour chaque organisation.
malware_scan(par défautfalse) active l’analyse ClamAV de ce que le proxy récupère ;block_until_scanneden a besoin, et unPUTqui laisseraitblock_until_scannedactivé avecmalware_scandésactivé est refusé avec400 scan-pair(voir Récupérations par le proxy de dépendances).metadata_filtermasque, dans les métadonnées de paquets que sert le proxy (npm, PyPI, Maven, NuGet, Go, Helm et Cargo), les versions que votre politique de paquets refuserait : les gestionnaires de paquets se rabattent ainsi sur la version autorisée la plus récente au lieu d’échouer au téléchargement. Si aucune version n’est autorisée, les métadonnées sont servies en entier et le téléchargement est refusé avec le motif."shadow"ne masque rien et liste ce qu’il masquerait sousGET /orgs/{org}/package-policy/decisions(metadata)."on"n’est pour l’instant disponible que pour les organisations internes de fremforge ; ailleurs, il est refusé avec403 metadata-filter-internal-only. - Webhooks et endpoints SIEM : seuls
active(webhooks) ouenabled(SIEM) changent sur place. Toute autre modification remplace la destination et génère un nouveau secret. Le module gère les endpoints SIEM avec en-tête d’authentification dans une ressource distincte,siem_endpoint_with_auth; voir Limites. - Domaines SSO : enregistrement uniquement. Le module enregistre chaque domaine et le relit depuis la liste
challenges; la vérification DNS ou HTTP est une étape manuelle dans la console. - Protection de branche sur les dépôts (
forgejo_branch_protection) : fremforge ajoute un contexte de statutfremforge/<scanner>pour chaque scanner que votre politique d’analyse applique, et chaque scanner est appliqué par défaut. Listez-les tous, dans l’ordre utilisé par l’exemple, sinon unplanultérieur affichera la règle comme une dérive. - Secrets Actions de l’organisation : la valeur est en écriture seule, donc
planne peut pas voir une valeur modifiée. Effectuez la rotation avec-replace. Transmettez les valeurs depuis votre coffre de secrets, par exempleTF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'. - Arguments en écriture seule dans le plan d’OpenTofu : lorsqu’une
restful_resourcechange sur place, OpenTofu affiche~ ephemeral_body = (write-only attribute)parmi les modifications. Terraform omet cette ligne. Ce n’est pas une dérive et rien de plus n’est envoyé ; les décomptes dePlan:sont identiques avec les deux CLI.
Les secrets restent hors de l’état. Les secrets de signature des webhooks et les secrets HMAC des SIEM ne sont renvoyés qu’une fois, à leur création. Le output_attrs du module les retire de l’état ; consultez-les dans la console ou effectuez leur rotation via l’API. Les valeurs des secrets Actions et les en-têtes d’authentification SIEM sont envoyés comme arguments en écriture seule et ne sont jamais stockés.
Ce que nous testons
Le module ci-dessus s’exécute contre une organisation de test fremforge dédiée, avec les deux CLI, au moyen du script ci-dessous :
applyde l’exemple ;- de nouveau
plan -detailed-exitcode, qui doit se terminer avec0(aucune modification) ; - application d’une modification : copiez
test/change_override.tf,plandoit se terminer avec2,applyeffectue la modification, et leplanqui suit doit se terminer avec0; destroy, avecreset_on_destroy = true, après quoi chaque paramètre se relit comme avant l’exécution.
Le script de test : 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 1L’étape de modification : 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
}Dernière exécution le 2026-10-06, avec HashiCorp Terraform 1.16.5 (la version actuelle, depuis releases.hashicorp.com, SHA-256 vérifié) et OpenTofu 1.13.1 contre frem.sh (Forgejo v15), avec un jeton d’organisation détenant policy:write et sso:read/sso:write :
| Paramètres | Terraform 1.16.5 | OpenTofu 1.13.1 |
|---|---|---|
| Politique d’authentification, liste d’adresses IP autorisées, accès opérateur, liste de domaines e-mail autorisés et domaines, autorité de certification SSH, valeurs par défaut de la protection de branche, politique d’analyse, politique Actions, rétention des artefacts, politique de SLA, paramètres des scanners, mode d’échec de la protection des push, releases immuables, mises à jour des dépendances, destinations des webhooks, endpoints SIEM, environnements de déploiement, secrets Actions de l’organisation, domaines SSO | apply, plan sans modification, modification (plan avec code 2, apply, plan avec code 0), destroy : réussi | apply, plan sans modification, modification (plan avec code 2, apply, plan avec code 0), destroy : réussi |
Dépôt et protection de branche (svalabs/forgejo) | apply, plan sans modification, destroy : réussi | apply, plan sans modification, destroy : réussi |
| Modification hors bande (un webhook désactivé et un paramètre de mise à jour des dépendances modifié via l’API) | le plan avec code 2 montre les deux, apply rétablit les deux, plan avec code 0 | le plan avec code 2 montre les deux, apply rétablit les deux, plan avec code 0 |
Chaque paramètre du module a été exécuté en conditions réelles : 27 ressources appliquées, 15 modifiées lors de l’étape de modification, 28 détruites, avec chaque CLI. Les deux CLI se sont comportées de la même manière à chaque étape. Les seules différences sont la vérification de signature du provider lors de l’init et une ligne de la sortie du plan, toutes deux décrites plus haut. Non couverts par l’exécution : les endpoints SIEM avec en-tête d’authentification et la vérification des domaines SSO. L’entrée dependency_proxy a été ajoutée plus tard (2026-10-10) et exécutée seule avec OpenTofu contre une organisation de test où le proxy de dépendances était activé : création, plan sans modification, passage de "shadow" à "on", plan sans modification, puis destroy, qui a remis metadata_filter à "off".
Ce que laisse destroy avec reset_on_destroy = true, par rapport à une organisation qui n’a jamais rien défini : les valeurs par défaut de la protection de branche correspondent à la base fremforge au lieu d’être non définies, la politique d’analyse conserve une ligne par scanner que vous avez géré, à enforce (comme la valeur par défaut du plan), la politique de SLA se relit comme un objet de null au lieu de null, et les paramètres des scanners se relisent comme configured: true avec leurs valeurs par défaut. Chacun se comporte comme la valeur par défaut d’origine.
L’exécuter
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 applyPrendre en charge une organisation existante
Chaque organisation possède déjà ses paramètres singletons (politique d’authentification, liste d’adresses IP autorisées, etc.). Pour un singleton, la « création » par le module est la même écriture qu’une mise à jour : le premier apply écrit donc vos valeurs configurées par-dessus les valeurs actuelles. Pour voir la différence d’abord, importez chaque singleton, puis lancez un plan. Avec le provider restful, l’ID d’import indique le chemin de lecture et les champs que vous gérez :
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
}
})
}Les clés de body doivent correspondre aux champs que vous définissez dans l’entrée du module. Après l’import, plan ne montre que les champs dont la valeur actuelle diffère de votre configuration. Faites de même pour les autres singletons, en ajoutant read_selector lorsque le tableau ci-dessus lit depuis GET /orgs/{org}/security.
Pour la règle de protection de branche d’un dépôt existant :
import {
to = forgejo_branch_protection.service_main
id = "acme/payments-service/main"
}Détection de dérive
Quelqu’un modifie un paramètre dans la console, et le plan suivant le montre. -detailed-exitcode en fait un signal exploitable par la CI : le code 0 signifie aucune modification, 2 une dérive, 1 une erreur. Voici ce que nous avons exécuté le 2026-10-03 avec OpenTofu, avec deux modifications faites via l’API pour simuler des modifications dans la console. Le 2026-10-06, les deux CLI ont renvoyé les mêmes codes de sortie sur le même test avec un webhook et un paramètre de mise à jour des dépendances :
$ 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.Un job planifié qui exécute plan -detailed-exitcode avec un jeton en lecture seule et alerte sur le code 2 vous donne une alarme de dérive sans accès en écriture. Chaque modification effectuée via l’API figure aussi dans votre journal d’audit sous le jeton qui l’a faite.
Destroy
Les paramètres singletons ne peuvent pas être supprimés : destroy est donc aussi une écriture. Le paramètre reset_on_destroy du module détermine laquelle :
false(par défaut) :destroyréécrit vos valeurs actuelles, si bien que rien ne change et que le paramètre quitte seulement l’état. Utilisez ce mode pour une organisation dans laquelle des personnes travaillent.true:destroyécrit la valeur par défaut de la plateforme pour chaque champ que vous gérez et, pour les valeurs par défaut de la protection de branche, appellePOST /orgs/{org}/branch-defaults/reset. Utilisez ce mode pour une organisation de test.
Les collections (webhooks, endpoints SIEM, environnements, domaines e-mail, domaines SSO, secrets) sont supprimées. Un dépôt supprimé va dans la corbeille de l’organisation pendant sa période de restauration, et en créer un du même nom juste après un destroy peut échouer tant que la suppression n’est pas terminée : donnez donc un nouveau nom à un dépôt que vous créez et détruisez de façon répétée.
Pour cesser de gérer un paramètre sans y toucher, utilisez un bloc removed :
removed {
from = module.org.restful_resource.immutability
lifecycle {
destroy = false
}
}Non couvert
Ce que ce module ne gère pas, et pourquoi :
- Les équipes et leurs membres. Le provider Forgejo propose
forgejo_teametforgejo_team_member, mais une modification d’équipe exige un jeton Forgejo de portée organisation, que la page des PAT Git ne propose pas. Nous ne les avons pas testés sur fremforge. - Les sources d’authentification SSO (OIDC). Elles contiennent un secret client, et en enregistrer une exige un domaine vérifié et un IdP fonctionnel : elles ne figurent donc pas dans notre organisation de test. L’API (
/orgs/{org}/sso) prend en charge l’enregistrement et la mise à jour de la correspondance entre groupes et équipes. - L’application native du SSO (l’option, par organisation, de réauthentification récente). Il n’existe pas d’API pour cela.
- SCIM, clients OAuth, bots de CI, jetons d’API. Chaque création renvoie un identifiant une seule fois. Les conserver dans l’état serait un mauvais compromis.
- Membres et utilisateurs facturés. Ajouter un utilisateur crée un compte pour une personne ; les invitations envoient un e-mail. Consultez-les avec une source de données
restful_resourcesurGET /orgs/{org}/seats. - Les surcharges par dépôt (politique d’analyse, exemptions d’immutabilité, documentation publique), les exceptions à durée limitée (dérogations à la protection des push et à l’analyse antimalware, rejets de résultats), les synthèses de sécurité et les préférences de notification.
- Les variables d’environnement, secrets, contrôles et règles de branche au sein d’un environnement. Le module crée uniquement l’environnement.
- Le champ
scheduledes mises à jour des dépendances. Il vautnullpar défaut, et le modèle de lecture du provider ne sait pas restituer unnull. - Tout ce qui ne figure pas dans la spécification OpenAPI. Un chemin inconnu répond
501, ce qui peut aussi signaler une faute de frappe.
Limites de cette approche
- Le provider générique ne sait rien de fremforge. Il ne peut pas avertir que
releases = trueest irréversible, ni valider un corps de requête avantapply. Chaque refus arrive sous la forme de l’erreur de l’API au moment de l’apply. Les contrôles propres à l’API s’appliquent toujours, comme la vérification anti-blocage de la liste d’adresses IP autorisées. - Le provider Forgejo est un provider tiers pour Forgejo en général. Une version de Forgejo peut modifier son comportement sans préavis de notre part. Nous épinglons la version et la testons.
- Endpoints SIEM avec en-tête d’authentification.
magodo/restful0.25.2 oublie l’empreinte duephemeral_bodyen écriture seule d’une ressource après une mise à jour sur place. Une ressource qui définit cet argument planifie alors unPATCHvide à chaque exécution suivante, que l’API refuse avec400. Le module ne définit doncephemeral_bodyque sursiem_endpoint_with_auth. Pour ces endpoints, le plan qui suit une modification sur place montre une mise à jour de plus, qui renvoie l’en-tête ; le plan suivant est propre. Nous n’avons pas exécuté ce cas en conditions réelles. - L’état contient les réponses de lecture. Le module garde les secrets hors de l’état comme décrit plus haut, mais l’état décrit tout de même votre configuration de sécurité. Utilisez un backend de confiance, et le chiffrement d’état d’OpenTofu si vous utilisez OpenTofu.
Si vous avez besoin d’un paramètre que cette page ne couvre pas, écrivez-nous à support@frem.sh.
Voir aussi
- Public REST API : authentification, jetons et scopes
- Politique d’authentification
- Liste d’adresses IP autorisées
- Releases immuables
- Protection de branche : la liste complète des contextes de statut fremforge
- Transfert vers un SIEM