Aller au contenu principal

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

ProviderVersionLicenceCommunique avec
svalabs/forgejo1.6.1MPL-2.0Forgejo, https://frem.sh/api/v1
magodo/restful0.25.2MPL-2.0fremforge, 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ètresLecture (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 SIEMpolicy:readpolicy:write
Paramètres des scanners, mode d’échec de la protection des push, releases immuablesfindings:readpolicy:write
Destinations des webhookswebhooks:readwebhooks:write
Environnements de déploiementenvironments:readenvironments:write
Secrets Actions de l’organisationorgs:readorgs:write
Mises à jour des dépendancesrenovate:readrenovate:write
Domaines SSOsso:readsso: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
terraform/modules/fremforge-org/variables.tf
# Every setting is optional. Leave a variable at null and the module does not
# touch that setting at all. Set it to an object and the module owns exactly the
# fields you name: fields you leave out are neither sent nor compared, so the
# console can still change them without showing up as drift.
#
# Field names are the API's own. The OpenAPI spec is at
# https://docs.frem.sh/api/openapi.yaml.

variable "org" {
  description = "The fremforge organisation slug, as in frem.sh/<org>."
  type        = string
}

variable "reset_on_destroy" {
  description = <<-EOT
    What `destroy` does to a singleton setting (one that always exists and has
    no DELETE). false (the default): destroy writes the current values back, so
    the live setting is left exactly as it is and only state forgets it. true:
    destroy writes the platform default for every field you manage. Use true for
    a throwaway test org, never for an org people work in.
  EOT
  type        = bool
  default     = false
}

# --- Authentication and access ---------------------------------------------

variable "auth_policy" {
  description = "PATCH /orgs/{org}/auth-policy. E.g. { max_pat_lifetime_days = 30, ssh_disabled = true, allow_user_pats = false }."
  type        = any
  default     = null
}

variable "ip_allowlist" {
  description = "PUT /orgs/{org}/security/ip-policy. Send all three fields: { cidrs = [...], audit_only = true, allow_platform_runners = true }. The PUT replaces the list."
  type        = any
  default     = null
}

variable "operator_access" {
  description = "PUT /orgs/{org}/security/operator-access. { allow = false } stops fremforge operators managing users in your org."
  type        = any
  default     = null
}

variable "email_domain_allowlist_enforced" {
  description = "PUT /orgs/{org}/email-domains/policy. true: members may only add email addresses on the domains in email_domains."
  type        = bool
  default     = null
}

variable "email_domains" {
  description = "POST/DELETE /orgs/{org}/email-domains. Domain patterns members may use, e.g. [\"acme.com\"]."
  type        = set(string)
  default     = []
}

variable "ssh_ca" {
  description = "PUT /orgs/{org}/ssh-ca. { ca_keys = [...], require_cert = false }. (principal_policy was removed 2026-10-14: ignored by the API and no longer returned, so leave it out or plan shows a permanent diff.)"
  type        = any
  default     = null
}

variable "sso_domains" {
  description = "POST/DELETE /orgs/{org}/sso/domains. Registers domains for SSO. Verification (DNS or HTTP) is a separate, manual step."
  type        = set(string)
  default     = []
}

# --- Repository and Actions defaults ----------------------------------------

variable "branch_protection_defaults" {
  description = "PUT /orgs/{org}/policy, body under branch_protection. E.g. { enabled = true, required_approvals = 1, require_status_checks = true }."
  type        = any
  default     = null
}

variable "actions_policy" {
  description = "PUT /orgs/{org}/security/actions-policy. { only_protected_branches = true } lets Actions run only on protected branches."
  type        = any
  default     = null
}

variable "artifact_retention" {
  description = "PUT /orgs/{org}/artifact-retention. { days = 30 } (1 to 90)."
  type        = any
  default     = null
}

variable "dependency_updates" {
  description = "Version updates: PUT /orgs/{org}/dependency-updates/version-updates/settings. E.g. { automerge_patch = true, major_approval = true }. Every field except `schedule` and `merge_window`. `security_automerge` is no longer taken here: use security_fixes.merge."
  type        = any
  default     = null
}

variable "security_fixes" {
  description = "Security fixes: PUT /orgs/{org}/dependency-updates/security-fixes/settings. E.g. { mode = \"auto_important\", merge = \"review\" }. Fields: mode, include_unknown, merge, max_open_per_repo, max_per_day, soak_days (`repos` is not managed by this module)."
  type        = any
  default     = null
}

# --- Code security ----------------------------------------------------------

variable "scan_policy" {
  description = "PUT /orgs/{org}/policy, body under scan_policy. Org-wide mode per scanner, e.g. { sast = { mode = \"enforce\", block_severity = \"high\" } }."
  type        = any
  default     = null
}

variable "scanner_settings" {
  description = <<-EOT
    PUT /orgs/{org}/code-security/<scanner>/settings, keyed by scanner:
    sast, deps, images, secrets, licenses. E.g.
    { sast = { enabled = true, block_severity = "error" }, licenses = { enabled = true } }.
    licenses also takes the licence policy: allowlisted_license_ids,
    denied_license_ids, warned_license_ids, unknown_action, multiple_mode.
  EOT
  type        = any
  default     = {}
}

variable "licence_repo_overrides" {
  description = <<-EOT
    PUT /orgs/{org}/repos/<repo>/code-security/licenses/settings, keyed by
    repository name: a per-repository licence policy override. Fields:
    allowlisted_license_ids, denied_license_ids, warned_license_ids (lists of
    SPDX ids), unknown_action (threshold|allow|warn|deny), multiple_mode
    (all|any). An omitted field inherits the org's value. E.g.
    { legacy-tool = { denied_license_ids = ["GPL-3.0-only"], unknown_action = "deny" } }.
  EOT
  type        = any
  default     = {}
}

variable "push_protection_failure_mode" {
  description = "PUT /orgs/{org}/push-protection/failure-mode. \"block\" or \"allow\": what a push does when the secret scanner cannot run."
  type        = string
  default     = null
}

variable "exception_settings" {
  description = <<-EOT
    PUT /orgs/{org}/exceptions/settings. Fields: max_days (1..366, the longest
    an exception may run), approver_team (a team of the org, or null for the
    owners plus the package-policy approver list), dismissals_require_approval
    (true: a finding dismissal by a non-approver waits for an approver other
    than the requester, and the finding stays open until then; default false).
    E.g. { max_days = 90, dismissals_require_approval = true }.
  EOT
  type        = any
  default     = null
}

variable "immutability" {
  description = "PUT /orgs/{org}/code-security/immutability. WARNING: releases = true is one-way for every release published while it is on."
  type        = any
  default     = null
}

variable "dependency_proxy" {
  description = <<-EOT
    PUT /orgs/{org}/dependency-proxy. Fields: enabled, block_until_scanned,
    retention_days (7..365), metadata_filter ("off" | "shadow" | "on": hide the
    versions the package policy would refuse from the package metadata the proxy
    serves; "on" is refused with 403 outside fremforge's internal organisations
    for now). E.g. { enabled = true, metadata_filter = "shadow" }.
  EOT
  type        = any
  default     = null
}

variable "image_signature_policy" {
  description = <<-EOT
    PUT /orgs/{org}/image-signature-policy: which container image signatures
    the org trusts at pull. { enabled = true, mode = "shadow",
    identities = [{ issuer = "https://frem.sh/_app/runner/oidc",
    subject_regex = "https://frem\\.sh/acme/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/tags/v.+" }],
    keys = [{ name = "release", public_key_pem = file("cosign.pub") }], scope = ["api"] }.
    Fields left out take their defaults.
  EOT
  type        = any
  default     = null
}

variable "sla_policy" {
  description = "PUT /orgs/{org}/security/sla-policy. { critical_sla_days = 7, high_sla_days = 30, medium_sla_days = 90, low_sla_days = null }."
  type        = any
  default     = null
}

# --- Integrations -----------------------------------------------------------

variable "webhooks" {
  description = <<-EOT
    POST/PATCH/DELETE /orgs/{org}/webhooks/destinations, keyed by name:
    { audit = { target_url = "https://hooks.acme.com/fremforge", events = ["push"], repo_filter_regex = null, active = true } }.
    Only `active` can change in place; any other change replaces the destination
    and mints a new signing secret.
  EOT
  type        = any
  default     = {}
}

variable "siem_endpoints" {
  description = <<-EOT
    POST/PATCH/DELETE /orgs/{org}/siem/endpoints, keyed by name:
    { splunk = { url = "https://siem.acme.com/hec", action_prefixes = ["auth."], enabled = true } }.
    Only `enabled` can change in place; any other change replaces the endpoint
    and mints a new HMAC secret.
  EOT
  type        = any
  default     = {}
}

variable "siem_auth_headers" {
  description = "Optional auth header per SIEM endpoint name, { splunk = { name = \"Authorization\", value = \"Splunk ...\" } }. Sent once, never stored in state."
  type        = map(object({ name = string, value = string }))
  default     = {}
  sensitive   = true
}

variable "environments" {
  description = "POST/DELETE /orgs/{org}/environments. Deployment environment names, e.g. [\"staging\", \"production\"]."
  type        = set(string)
  default     = []
}

variable "org_secrets" {
  description = "PUT/DELETE /orgs/{org}/secrets/{name}. Actions secrets for every repo in the org, { NAME = \"value\" }. Values are write-only: never read back and never in state."
  type        = map(string)
  default     = {}
  sensitive   = true
}

# --- Package access ---------------------------------------------------------

variable "package_access_default" {
  description = "PUT /orgs/{org}/packages/access-settings: \"legacy\" (organisation-wide package access, the default) or \"granular\" (every package of a supported type is on per-package access unless it says otherwise)."
  type        = string
  default     = null
}

variable "packages" {
  description = <<-EOT
    Per-package access, keyed by "<type>/<name>" (the first "/" separates the
    type; a name may contain more): { "container/team/app" = { mode = "granular",
    inherit_repo = true, teams = { release-managers = "admin" }, actions = { api = "write" } } }.
    mode: "granular", "legacy" or null (follow the organisation). teams: read,
    write or admin per team name. actions: read or write per repository name,
    for that repository's Actions jobs. Removing a package from the map
    removes its configuration and grants.
  EOT
  type = map(object({
    mode         = optional(string)
    inherit_repo = optional(bool, true)
    teams        = optional(map(string), {})
    actions      = optional(map(string), {})
  }))
  default = {}
}

variable "package_protection_rules" {
  description = <<-EOT
    Protected package names, keyed by any label: { releases = { type = "*",
    name_pattern = "release/*", push = { owners = false, robots = true,
    teams = ["release-managers"], actions = ["api"] }, delete = { owners = true } } }.
    Only the listed principals may publish to, or delete, matching packages.
  EOT
  type        = any
  default     = {}
}
Ressources du module : main.tf
terraform/modules/fremforge-org/main.tf
locals {
  org = "/orgs/${var.org}"

  # Platform defaults, written back on destroy when reset_on_destroy = true.
  # Only the fields the caller manages are reset.
  defaults = {
    auth_policy = {
      require_hardware_backed_keys      = false
      max_pat_lifetime_days             = 90
      audit_full_payload_retention_days = 365
      require_signed_commits            = false
      require_oidc_signed_commits       = false
      allowed_oidc_issuers              = []
      allow_user_pats                   = false
      allow_repo_deploy_keys            = false
      allowed_user_pat_scopes           = []
      ssh_disabled                      = true
    }
    ip_allowlist                 = { cidrs = [], audit_only = false, allow_platform_runners = true }
    operator_access              = { allow = true }
    actions_policy               = { only_protected_branches = false }
    artifact_retention           = { days = 90 }
    sla_policy                   = { critical_sla_days = null, high_sla_days = null, medium_sla_days = null, low_sla_days = null }
    ssh_ca                       = { ca_keys = [], require_cert = false }
    dependency_proxy             = { enabled = false, block_until_scanned = false, retention_days = 90, metadata_filter = "off" }
    immutability                 = { releases = false, allow_repo_opt_out = false, oci_mode = "off", oci_mutable_tags = [] }
    image_signature_policy       = { enabled = false, mode = "shadow", issuers = [], identities = [], keys = [], scope = [], trust_public_sigstore = false }
    push_protection_failure_mode = "block"
    exception_settings           = { max_days = 366, approver_team = null, dismissals_require_approval = false }
    # GET .../code-security/<scanner>/settings on an org that never saved them.
    scanner_settings = {
      sast    = { enabled = true, block_severity = "error", custom_rules_url = null }
      deps    = { enabled = true, block_severity = "critical" }
      images  = { enabled = true, block_action = "warn" }
      secrets = { enabled = true }
      licenses = {
        enabled                 = true
        block_severity          = "restricted"
        allowlisted_license_ids = []
        denied_license_ids      = []
        warned_license_ids      = []
        unknown_action          = "threshold"
        multiple_mode           = "all"
      }
    }
    # Version updates (hosted Renovate). Since 2026-10-15 a new settings row
    # defaults to 2 / 5 for the PR limits, as the runner always applied.
    dependency_updates = {
      enabled             = true
      automerge_patch     = true
      automerge_minor     = false
      major_approval      = true
      pr_hourly_limit     = 2
      pr_concurrent_limit = 5
    }
    # Security fixes (Dependency updates, the other lane).
    security_fixes = {
      mode              = "on_click"
      include_unknown   = false
      merge             = "review"
      max_open_per_repo = 3
      max_per_day       = 20
      soak_days         = 3
    }
  }

  # What destroy writes for a singleton: the platform default for each managed
  # field, or the managed fields unchanged (a no-op write that leaves the live
  # value alone).
  reset = {
    for k, v in {
      auth_policy        = var.auth_policy
      ip_allowlist       = var.ip_allowlist
      operator_access    = var.operator_access
      actions_policy     = var.actions_policy
      artifact_retention = var.artifact_retention
      sla_policy         = var.sla_policy
      ssh_ca             = var.ssh_ca
      immutability       = var.immutability
      dependency_updates = var.dependency_updates
      security_fixes     = var.security_fixes
      # PUT replaces the whole policy, so the reset writes every field.
      image_signature_policy = var.image_signature_policy == null ? null : merge(local.defaults.image_signature_policy, var.image_signature_policy)
      dependency_proxy       = var.dependency_proxy
      exception_settings     = var.exception_settings
    } :
    k => v == null ? null : (
      var.reset_on_destroy ? { for f in keys(v) : f => local.defaults[k][f] } : v
    )
  }
}

# ============================================================================
# Singletons: every org has exactly one of each, it always exists, and there is
# no DELETE. "Create" is the same write as "update"; "destroy" is a write too
# (see reset_on_destroy). To bring an existing org under management without a
# write, import first: see docs.frem.sh/admin/configuration-as-code/#bring-an-existing-org-under-management.
# ============================================================================

# --- Authentication policy --------------------------------------------------
resource "restful_resource" "auth_policy" {
  count = var.auth_policy == null ? 0 : 1

  path                 = "${local.org}/auth-policy"
  create_method        = "PATCH"
  update_method        = "PATCH"
  delete_method        = "PATCH"
  merge_patch_disabled = true

  body        = var.auth_policy
  delete_body = local.reset.auth_policy
}

# --- IP allowlist -----------------------------------------------------------
# PUT replaces the whole list; it is read back from GET /orgs/{org}/security.
# An enforcing list that does not contain the caller's own address is refused
# (400 invalid-allowlist), so tofu/terraform cannot lock out the machine it
# runs on. Roll out with audit_only = true first.
resource "restful_resource" "ip_allowlist" {
  count = var.ip_allowlist == null ? 0 : 1

  path          = "${local.org}/security/ip-policy"
  read_path     = "${local.org}/security"
  read_selector = "ip_allowlist"
  create_method = "PUT"
  update_method = "PUT"
  update_path   = "${local.org}/security/ip-policy"
  delete_method = "PUT"
  delete_path   = "${local.org}/security/ip-policy"

  body        = var.ip_allowlist
  delete_body = local.reset.ip_allowlist
}

# --- Operator access --------------------------------------------------------
resource "restful_resource" "operator_access" {
  count = var.operator_access == null ? 0 : 1

  path                   = "${local.org}/security/operator-access"
  read_path              = "${local.org}/security"
  read_response_template = "{\"allow\": $(body.allow_operator_user_management)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/security/operator-access"
  delete_method          = "PUT"
  delete_path            = "${local.org}/security/operator-access"

  body        = var.operator_access
  delete_body = local.reset.operator_access
}

# --- Email-domain allowlist (on/off) ----------------------------------------
resource "restful_resource" "email_domain_policy" {
  count = var.email_domain_allowlist_enforced == null ? 0 : 1

  path                   = "${local.org}/email-domains/policy"
  read_path              = "${local.org}/email-domains"
  read_response_template = "{\"enabled\": $(body.allowlist_enforced)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/email-domains/policy"
  delete_method          = "PUT"
  delete_path            = "${local.org}/email-domains/policy"

  body        = { enabled = var.email_domain_allowlist_enforced }
  delete_body = { enabled = var.reset_on_destroy ? true : var.email_domain_allowlist_enforced }
}

# --- SSH certificate authority ----------------------------------------------
resource "restful_resource" "ssh_ca" {
  count = var.ssh_ca == null ? 0 : 1

  path          = "${local.org}/ssh-ca"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.ssh_ca
  delete_body = local.reset.ssh_ca
}

# --- Branch-protection defaults ---------------------------------------------
# Applied to every new repository, and by an hourly sweep to any repository
# with no rule at all. Destroy with reset_on_destroy = true restores the
# fremforge baseline (POST /orgs/{org}/branch-defaults/reset).
resource "restful_resource" "branch_protection_defaults" {
  count = var.branch_protection_defaults == null ? 0 : 1

  path          = "${local.org}/policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = var.reset_on_destroy ? "POST" : "PUT"
  delete_path   = var.reset_on_destroy ? "${local.org}/branch-defaults/reset" : "${local.org}/policy"

  body        = { branch_protection = var.branch_protection_defaults }
  delete_body = var.reset_on_destroy ? {} : { branch_protection = var.branch_protection_defaults }
}

# --- Org-wide scan policy ---------------------------------------------------
resource "restful_resource" "scan_policy" {
  count = var.scan_policy == null ? 0 : 1

  path          = "${local.org}/policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body = { scan_policy = var.scan_policy }
  # There is no DELETE for a scanner's org-wide row. Reset writes the plan
  # default (enforce, with the scanner's default severity) for each scanner you
  # manage; the row stays, so GET still lists those scanners.
  delete_body = { scan_policy = var.reset_on_destroy ? { for k, v in var.scan_policy : k => { mode = "enforce", block_severity = null } } : var.scan_policy }
}

# --- Actions policy ---------------------------------------------------------
resource "restful_resource" "actions_policy" {
  count = var.actions_policy == null ? 0 : 1

  path                   = "${local.org}/security/actions-policy"
  read_path              = "${local.org}/security"
  read_response_template = "{\"only_protected_branches\": $(body.actions_only_protected_branches)}"
  create_method          = "PUT"
  update_method          = "PUT"
  update_path            = "${local.org}/security/actions-policy"
  delete_method          = "PUT"
  delete_path            = "${local.org}/security/actions-policy"

  body        = var.actions_policy
  delete_body = local.reset.actions_policy
}

# --- Actions artifact retention ---------------------------------------------
resource "restful_resource" "artifact_retention" {
  count = var.artifact_retention == null ? 0 : 1

  path                   = "${local.org}/artifact-retention"
  read_response_template = "{\"days\": $(body.retention_days)}"
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.artifact_retention
  delete_body = local.reset.artifact_retention
}

# --- Dependency updates: Version updates -------------------------------------
# GET/PUT /orgs/{org}/dependency-updates/version-updates/settings: snake_case
# both ways, the read wrapped in `settings`. `schedule` and `merge_window` are
# not managed here: both are null by default, and the provider's response
# template cannot render a null. (Until 2026-10-15 this wrote
# /dependency-updates/settings, which now answers with Deprecation headers and
# still takes security_automerge; that setting is `merge` under security_fixes.)
resource "restful_resource" "dependency_updates" {
  count = var.dependency_updates == null ? 0 : 1

  path                   = "${local.org}/dependency-updates/version-updates/settings"
  read_response_template = <<-EOT
    {
      "enabled": $(body.settings.enabled),
      "automerge_patch": $(body.settings.automerge_patch),
      "automerge_minor": $(body.settings.automerge_minor),
      "major_approval": $(body.settings.major_approval),
      "pr_hourly_limit": $(body.settings.pr_hourly_limit),
      "pr_concurrent_limit": $(body.settings.pr_concurrent_limit)
    }
  EOT
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.dependency_updates
  delete_body = local.reset.dependency_updates
}

# --- Dependency updates: Security fixes --------------------------------------
# GET/PUT /orgs/{org}/dependency-updates/security-fixes/settings. `repos` is not
# managed here (the response template renders scalars only); set it in the
# console or with the fremforge provider's fremforge_dependency_updates. The lane
# switch (who opens security PRs) is an owner decision and is not managed.
resource "restful_resource" "security_fixes" {
  count = var.security_fixes == null ? 0 : 1

  path                   = "${local.org}/dependency-updates/security-fixes/settings"
  read_response_template = <<-EOT
    {
      "mode": "$(body.settings.mode)",
      "include_unknown": $(body.settings.include_unknown),
      "merge": "$(body.settings.merge)",
      "max_open_per_repo": $(body.settings.max_open_per_repo),
      "max_per_day": $(body.settings.max_per_day),
      "soak_days": $(body.settings.soak_days)
    }
  EOT
  create_method          = "PUT"
  update_method          = "PUT"
  delete_method          = "PUT"

  body        = var.security_fixes
  delete_body = local.reset.security_fixes
}

# --- Scanner settings (SAST, dependencies, images, secrets, licences) -------
resource "restful_resource" "scanner_settings" {
  for_each = var.scanner_settings

  path          = "${local.org}/code-security/${each.key}/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body = each.value
  delete_body = var.reset_on_destroy ? {
    for f, v in each.value : f => try(local.defaults.scanner_settings[each.key][f], v)
  } : each.value
}

# --- Licence policy overrides per repository ---------------------------------
# A field that is set replaces the org's value for that repository; null
# inherits it. Destroy removes the override (the repository inherits again).
resource "restful_resource" "licence_repo_overrides" {
  for_each = var.licence_repo_overrides

  path          = "${local.org}/repos/${each.key}/code-security/licenses/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = each.value
}

# --- Push protection: what a push does when the secret scanner cannot run --
resource "restful_resource" "push_protection_failure_mode" {
  count = var.push_protection_failure_mode == null ? 0 : 1

  path          = "${local.org}/push-protection/failure-mode"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = { mode = var.push_protection_failure_mode }
  delete_body = { mode = var.reset_on_destroy ? local.defaults.push_protection_failure_mode : var.push_protection_failure_mode }
}

# --- Image signature enforcement at pull -----------------------------------
# PUT replaces the whole policy: a field left out of the body is set to its
# default, so the module always sends every field (the defaults merged with
# yours). mode = "enforce" refuses unsigned pulls; roll out in "shadow" first.
resource "restful_resource" "image_signature_policy" {
  count = var.image_signature_policy == null ? 0 : 1

  path          = "${local.org}/image-signature-policy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = merge(local.defaults.image_signature_policy, var.image_signature_policy)
  delete_body = local.reset.image_signature_policy
}

# --- Exception settings: expiry cap, approvers, dismissals need approval ----
# dismissals_require_approval = true: a finding dismissal by someone who is not
# an approver becomes an exception request, and the finding stays open until an
# approver other than the requester approves it.
resource "restful_resource" "exception_settings" {
  count = var.exception_settings == null ? 0 : 1

  path          = "${local.org}/exceptions/settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.exception_settings
  delete_body = local.reset.exception_settings
}

# --- Immutable releases and container tags ----------------------------------
# WARNING: releases = true cannot be undone for the releases published while it
# is on. Setting it back to false stops new releases being frozen; it unfreezes
# nothing.
resource "restful_resource" "immutability" {
  count = var.immutability == null ? 0 : 1

  path          = "${local.org}/code-security/immutability"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.immutability
  delete_body = local.reset.immutability
}

# --- Dependency proxy -------------------------------------------------------
# GET returns usage, cap and platform fields as well; only the fields set in
# var.dependency_proxy are sent and compared.
resource "restful_resource" "dependency_proxy" {
  count = var.dependency_proxy == null ? 0 : 1

  path          = "${local.org}/dependency-proxy"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = var.dependency_proxy
  delete_body = local.reset.dependency_proxy
}

# --- Security SLA policy ----------------------------------------------------
resource "restful_resource" "sla_policy" {
  count = var.sla_policy == null ? 0 : 1

  path          = "${local.org}/security/sla-policy"
  read_path     = "${local.org}/security"
  read_selector = "sla_policy"
  create_method = "PUT"
  update_method = "PUT"
  update_path   = "${local.org}/security/sla-policy"
  delete_method = "PUT"
  delete_path   = "${local.org}/security/sla-policy"

  body        = var.sla_policy
  delete_body = local.reset.sla_policy
}

# ============================================================================
# Collections: ordinary create / read / delete.
# ============================================================================

# --- Allowed email domains --------------------------------------------------
resource "restful_resource" "email_domain" {
  for_each = var.email_domains

  path          = "${local.org}/email-domains"
  read_path     = "${local.org}/email-domains"
  read_selector = "patterns.#(id==\"$(body.id)\")"
  delete_path   = "${local.org}/email-domains/$(body.id)"
  output_attrs  = ["id", "pattern"]

  body = { pattern = each.value }
}

# --- SSO domains (registration only; verification is manual) ----------------
resource "restful_resource" "sso_domain" {
  for_each = var.sso_domains

  path      = "${local.org}/sso/domains"
  read_path = "${local.org}/sso/domains"
  # GET lists every registration under `challenges` (pending, verified or
  # expired; the row and its id stay after verification).
  read_selector = "challenges.#(id==\"$(body.id)\")"
  delete_path   = "${local.org}/sso/domains/$(body.id)"
  output_attrs  = ["id", "domain", "status"]

  body = { domain = each.value }
}

# --- Webhook destinations ---------------------------------------------------
# The signing secret is returned once at create. output_attrs keeps it out of
# state; read it from the create response in the console instead, or rotate it
# with POST .../rotate.
resource "restful_resource" "webhook" {
  for_each = var.webhooks

  path          = "${local.org}/webhooks/destinations"
  read_path     = "${local.org}/webhooks/destinations"
  read_selector = "destinations.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/webhooks/destinations/$(body.id)"
  delete_path   = "${local.org}/webhooks/destinations/$(body.id)"
  output_attrs  = ["id", "name", "target_url", "events", "repo_filter_regex", "active"]

  body = merge({ name = each.key }, each.value)

  force_new_attrs = ["name", "target_url", "events", "repo_filter_regex"]
}

# --- SIEM forwarding endpoints ----------------------------------------------
# Two resources, split on whether the endpoint has an auth header. magodo/restful
# 0.25.2 forgets an ephemeral_body's fingerprint after an in-place update, so a
# resource that sets the attribute at all -- even to null, which a conditional
# types as a null object -- plans an empty PATCH on every run after its first
# update, and the API refuses it (400). Endpoints without a header therefore
# never set ephemeral_body.
locals {
  siem_auth_names = nonsensitive(toset(keys(var.siem_auth_headers)))
}

resource "restful_resource" "siem_endpoint" {
  for_each = { for k, v in var.siem_endpoints : k => v if !contains(local.siem_auth_names, k) }

  path          = "${local.org}/siem/endpoints"
  read_path     = "${local.org}/siem/endpoints"
  read_selector = "endpoints.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/siem/endpoints/$(body.id)"
  delete_path   = "${local.org}/siem/endpoints/$(body.id)"
  output_attrs  = ["id", "name", "url", "action_prefixes", "enabled", "signature_mode", "auth_header_name"]

  body = merge({ name = each.key }, each.value)

  force_new_attrs = ["name", "url", "action_prefixes", "signature_mode", "batch"]
}

resource "restful_resource" "siem_endpoint_with_auth" {
  for_each = { for k, v in var.siem_endpoints : k => v if contains(local.siem_auth_names, k) }

  path          = "${local.org}/siem/endpoints"
  read_path     = "${local.org}/siem/endpoints"
  read_selector = "endpoints.#(id==\"$(body.id)\")"
  update_method = "PATCH"
  update_path   = "${local.org}/siem/endpoints/$(body.id)"
  delete_path   = "${local.org}/siem/endpoints/$(body.id)"
  output_attrs  = ["id", "name", "url", "action_prefixes", "enabled", "signature_mode", "auth_header_name"]

  body = merge({ name = each.key }, each.value)

  # The auth header value is sent once and never stored in state. After an
  # in-place change the next plan shows one more update, which re-sends the
  # header; the plan after that is clean (the same provider behaviour).
  ephemeral_body = { auth_header = var.siem_auth_headers[each.key] }

  force_new_attrs = ["name", "url", "action_prefixes", "signature_mode", "batch"]
}

# --- Deployment environments ------------------------------------------------
resource "restful_resource" "environment" {
  for_each = var.environments

  path         = "${local.org}/environments"
  read_path    = "${local.org}/environments/${each.value}"
  delete_path  = "${local.org}/environments/${each.value}"
  output_attrs = ["name"]

  body = { name = each.value }
}

# --- Org-level Actions secrets ----------------------------------------------
# The value is write-only: sent on create, never read back, never in state. A
# changed value is therefore not detected by plan; to rotate, replace the
# resource (`-replace=...`).
resource "restful_resource" "org_secret" {
  for_each = nonsensitive(toset(keys(var.org_secrets)))

  path          = "${local.org}/secrets/${each.key}"
  create_method = "PUT"
  read_path     = "${local.org}/secrets"
  read_selector = "secrets.#(name==\"${each.key}\")"
  delete_path   = "${local.org}/secrets/${each.key}"
  output_attrs  = ["name"]

  body           = { name = each.key }
  ephemeral_body = { data = var.org_secrets[each.key] }
}

# ============================================================================
# Package access: per-package access and protected names.
# ============================================================================

locals {
  package_refs = {
    for k, v in var.packages : k => {
      type = split("/", k)[0]
      name = join("/", slice(split("/", k), 1, length(split("/", k))))
      path = "${local.org}/packages/${split("/", k)[0]}/${urlencode(join("/", slice(split("/", k), 1, length(split("/", k)))))}/access"
    }
  }
  package_team_grants = merge([
    for k, v in var.packages : { for team, perm in v.teams : "${k}#${team}" => { pkg = k, team = team, permission = perm } }
  ]...)
  package_actions_grants = merge([
    for k, v in var.packages : { for repo, perm in v.actions : "${k}#${repo}" => { pkg = k, repo = repo, permission = perm } }
  ]...)
}

# --- Organisation default ---------------------------------------------------
resource "restful_resource" "package_access_default" {
  count = var.package_access_default == null ? 0 : 1

  path          = "${local.org}/packages/access-settings"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "PUT"

  body        = { default_mode = var.package_access_default }
  delete_body = { default_mode = var.reset_on_destroy ? "legacy" : var.package_access_default }
}

# --- One package's settings -------------------------------------------------
resource "restful_resource" "package_access" {
  for_each = var.packages

  path          = local.package_refs[each.key].path
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { mode = each.value.mode, inherit_repo = each.value.inherit_repo }
}

# --- Team grants on a package -----------------------------------------------
resource "restful_resource" "package_team_grant" {
  for_each = local.package_team_grants

  path      = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  read_path = local.package_refs[each.value.pkg].path
  # The resource id is the read path (the package); writes and the delete must
  # name the grant itself, or they would hit the package's own settings.
  update_path   = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  delete_path   = "${local.package_refs[each.value.pkg].path}/teams/${each.value.team}"
  read_selector = "grants.teams.#(team==\"${each.value.team}\")"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { permission = each.value.permission }

  depends_on = [restful_resource.package_access]
}

# --- Actions grants on a package --------------------------------------------
resource "restful_resource" "package_actions_grant" {
  for_each = local.package_actions_grants

  path      = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  read_path = local.package_refs[each.value.pkg].path
  # The resource id is the read path (the package); writes and the delete must
  # name the grant itself, or they would hit the package's own settings.
  update_path   = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  delete_path   = "${local.package_refs[each.value.pkg].path}/actions/${each.value.repo}"
  read_selector = "grants.actions.#(repository==\"${each.value.repo}\")"
  create_method = "PUT"
  update_method = "PUT"
  delete_method = "DELETE"

  body = { permission = each.value.permission }

  depends_on = [restful_resource.package_access]
}

# --- Protected package names ------------------------------------------------
resource "restful_resource" "package_protection_rule" {
  for_each = var.package_protection_rules

  path          = "${local.org}/packages/protection-rules"
  read_path     = "${local.org}/packages/protection-rules"
  read_selector = "rules.#(id==\"$(body.id)\")"
  update_method = "PUT"
  update_path   = "${local.org}/packages/protection-rules/$(body.id)"
  delete_path   = "${local.org}/packages/protection-rules/$(body.id)"
  output_attrs  = ["id", "type", "name_pattern"]

  body = each.value
}
Sorties du module : outputs.tf
terraform/modules/fremforge-org/outputs.tf
output "webhook_ids" {
  description = "Webhook destination IDs by name."
  value       = { for k, r in restful_resource.webhook : k => r.output.id }
}

output "siem_endpoint_ids" {
  description = "SIEM endpoint IDs by name."
  value       = merge({ for k, r in restful_resource.siem_endpoint : k => r.output.id }, { for k, r in restful_resource.siem_endpoint_with_auth : k => r.output.id })
}
Épinglage du provider du module : versions.tf
terraform/modules/fremforge-org/versions.tf
# fremforge org settings as code: a module for Terraform and OpenTofu.
#
# Every fremforge setting this module manages goes through the fremforge API
# (https://frem.sh/_app/api/v1) with the generic magodo/restful provider. The
# module declares no provider block; the caller configures `restful` (see
# ../../example/providers.tf) and passes it in.
#
# Provider licence: magodo/restful is MPL-2.0. Tested with Terraform 1.16 and
# OpenTofu 1.13 (docs.frem.sh/admin/configuration-as-code/).

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    restful = {
      source  = "magodo/restful"
      version = "0.25.2"
    }
  }
}

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.

terraform/example/main.tf
# A whole org's fremforge settings in one module call. Every argument is
# optional; drop the ones you do not want to manage.
module "org" {
  source = "../modules/fremforge-org"

  org              = var.org
  reset_on_destroy = var.reset_on_destroy

  # Authentication and access
  auth_policy = {
    max_pat_lifetime_days  = 30
    ssh_disabled           = true
    allow_user_pats        = false
    allow_repo_deploy_keys = false
    require_signed_commits = false
  }
  ip_allowlist = {
    cidrs                  = var.office_cidrs
    audit_only             = true
    allow_platform_runners = true
  }
  operator_access                 = { allow = true }
  email_domain_allowlist_enforced = true
  email_domains                   = ["example.com"]
  ssh_ca = {
    ca_keys      = []
    require_cert = false
  }

  # Repository and Actions defaults
  branch_protection_defaults = {
    enabled                   = true
    required_approvals        = 1
    dismiss_stale_reviews     = true
    require_status_checks     = true
    require_pull_request      = true
    require_codeowners_review = false
    merge_queue               = "default"
  }
  actions_policy     = { only_protected_branches = true }
  artifact_retention = { days = 30 }
  dependency_updates = {
    automerge_patch = true
    automerge_minor = false
    major_approval  = true
  }
  security_fixes = {
    mode  = "auto_important"
    merge = "review"
  }

  # Code security
  scan_policy = {
    dep_scan = { mode = "enforce", block_severity = "critical" }
    sast     = { mode = "enforce", block_severity = "error" }
  }
  scanner_settings = {
    sast     = { enabled = true, block_severity = "error" }
    deps     = { enabled = true }
    images   = { enabled = true }
    secrets  = { enabled = true }
    licenses = { enabled = true }
  }
  push_protection_failure_mode = "block"
  exception_settings = {
    max_days                    = 180
    approver_team               = null
    dismissals_require_approval = true
  }
  immutability = {
    releases           = false
    allow_repo_opt_out = false
    oci_mode           = "off"
    oci_mutable_tags   = []
  }
  image_signature_policy = {
    enabled = true
    mode    = "shadow"
    identities = [{
      issuer        = "https://frem.sh/_app/runner/oidc"
      subject_regex = "https://frem\\.sh/${var.org}/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/heads/main"
    }]
    scope = []
  }
  sla_policy = {
    critical_sla_days = 7
    high_sla_days     = 30
    medium_sla_days   = 90
    low_sla_days      = null
  }

  # Integrations
  webhooks = {
    audit-feed = {
      target_url = var.webhook_url
      events     = ["push", "pull_request"]
      active     = true
    }
  }
  siem_endpoints = {
    siem = {
      url             = var.siem_url
      action_prefixes = ["auth.", "security."]
      enabled         = true
    }
  }
  environments = ["staging", "production"]
  org_secrets  = var.org_secrets

  # SSO domain registration. Verification (DNS or HTTP) stays a manual step.
  sso_domains = var.sso_domains
}
terraform/example/forgejo.tf
# Repositories and branch protection through Forgejo's own API. Delete this
# file (and the forgejo provider, see providers.tf) to manage only fremforge
# settings.
#
# fremforge manages part of every default-branch rule itself: a new repository
# gets the org's branch-protection defaults, and every scanner your scan policy
# sets to enforce adds its fremforge/<scanner> status context. List exactly
# those contexts here (docs.frem.sh/security/branch-protection/ has them all):
# leave one out and plan shows it as drift; add one nothing posts and merges
# block waiting for it.

resource "forgejo_repository" "service" {
  owner          = var.org
  name           = var.repo_name
  private        = true
  default_branch = "main"
  auto_init      = true

  # fremforge creates repositories with squash as the default merge style. The
  # provider's own default is "merge", so without this line every plan after
  # the first shows a change.
  default_merge_style = "squash"
}

resource "forgejo_branch_protection" "service_main" {
  repository_id       = forgejo_repository.service.id
  branch_name         = "main"
  required_approvals  = 1
  enable_status_check = true
  # Every scanner enforces by default, so fremforge adds all of these to the
  # rule soon after the repository is created (in this order). A shorter list
  # shows as drift on a later plan.
  status_check_contexts = [
    "fremforge/cosign-verify",
    "fremforge/dep-scan",
    "fremforge/image-scan",
    "fremforge/license-scan",
    "fremforge/release-age",
    "fremforge/sast",
    "fremforge/secret-scan",
  ]
}
Entrées de l’exemple : variables.tf
terraform/example/variables.tf
variable "org" {
  description = "Your fremforge organisation slug, as in frem.sh/<org>."
  type        = string
}

variable "fremforge_token" {
  description = "fremforge API token (ffp_...): an org API token, or a one-hour token from the OIDC token exchange."
  type        = string
  sensitive   = true
}

variable "forgejo_token" {
  description = "Forgejo access token for the repository part (forgejo.tf). Required while forgejo.tf is in the configuration."
  type        = string
  sensitive   = true
}

variable "office_cidrs" {
  description = "CIDR ranges for the IP allowlist. Kept in audit-only mode in this example."
  type        = list(string)
  default     = ["192.0.2.0/24"]
}

variable "siem_url" {
  description = "Where SIEM events are forwarded. Must be a public HTTPS URL."
  type        = string
  default     = "https://siem.example.com/fremforge"
}

variable "webhook_url" {
  description = "Where org webhooks are delivered. Must be a public HTTPS URL."
  type        = string
  default     = "https://hooks.example.com/fremforge"
}

variable "reset_on_destroy" {
  description = "true only for a throwaway test org: destroy then restores platform defaults."
  type        = bool
  default     = false
}

variable "repo_name" {
  description = "Name of the example repository. A deleted repository stays in the org's trash for its restore window, so reuse of a name can fail until it is purged."
  type        = string
  default     = "iac-example-service"
}

variable "org_secrets" {
  description = "Org-level Actions secrets, { NAME = \"value\" }. Pass them from your secret store (TF_VAR_org_secrets); values are write-only and never stored in state."
  type        = map(string)
  default     = {}
  sensitive   = true
}

variable "sso_domains" {
  description = "Domains to register for SSO, e.g. [\"acme.com\"]. Registration only: verify each one in the console."
  type        = set(string)
  default     = []
}

Chaque endpoint et sa correspondance

Paramètre, entrée du moduleAPI (écriture, puis lecture)
Politique d’authentification, auth_policyPATCH, GET /orgs/{org}/auth-policy
Liste d’adresses IP autorisées, ip_allowlistPUT /orgs/{org}/security/ip-policy, GET /orgs/{org}/security
Accès opérateur, operator_accessPUT /orgs/{org}/security/operator-access, GET /orgs/{org}/security
Activation de la liste de domaines e-mail autorisés, email_domain_allowlist_enforcedPUT /orgs/{org}/email-domains/policy, GET /orgs/{org}/email-domains
Domaines e-mail autorisés, email_domainsPOST, GET, DELETE /orgs/{org}/email-domains
Autorité de certification SSH, ssh_caPUT, GET /orgs/{org}/ssh-ca
Domaines SSO, sso_domainsPOST, GET, DELETE /orgs/{org}/sso/domains
Valeurs par défaut de la protection de branche, branch_protection_defaultsPUT, GET /orgs/{org}/policy (branch_protection)
Politique d’analyse à l’échelle de l’organisation, scan_policyPUT, GET /orgs/{org}/policy (scan_policy)
Politique Actions, actions_policyPUT /orgs/{org}/security/actions-policy, GET /orgs/{org}/security
Rétention des artefacts, artifact_retentionPUT, GET /orgs/{org}/artifact-retention
Mises à jour des dépendances : mises à jour de version, dependency_updatesGET/PUT /orgs/{org}/dependency-updates/version-updates/settings
Mises à jour des dépendances : correctifs de sécurité, security_fixesGET/PUT /orgs/{org}/dependency-updates/security-fixes/settings
Paramètres des scanners, scanner_settingsPUT, GET /orgs/{org}/code-security/{scanner}/settings
Politique de licences par dépôt, licence_repo_overridesPUT, GET, DELETE /orgs/{org}/repos/{repo}/code-security/licenses/settings
Mode d’échec de la protection des push, push_protection_failure_modePUT, GET /orgs/{org}/push-protection/failure-mode
Paramètres des exceptions (durée maximale, équipe d’approbation, approbation requise pour les rejets), exception_settingsPUT, GET /orgs/{org}/exceptions/settings
Releases et tags immuables, immutabilityPUT, GET /orgs/{org}/code-security/immutability
Politique de SLA de sécurité, sla_policyPUT /orgs/{org}/security/sla-policy, GET /orgs/{org}/security
Application des signatures d’images, image_signature_policyPUT, GET /orgs/{org}/image-signature-policy (remplace toute la politique ; le module envoie tous les champs)
Proxy de dépendances, dependency_proxyPUT, GET /orgs/{org}/dependency-proxy
Destinations des webhooks, webhooksPOST, PATCH, GET, DELETE /orgs/{org}/webhooks/destinations
Transfert vers un SIEM, siem_endpointsPOST, PATCH, GET, DELETE /orgs/{org}/siem/endpoints
Environnements de déploiement, environmentsPOST, GET, DELETE /orgs/{org}/environments
Accès par défaut aux paquets, package_access_defaultPUT, GET /orgs/{org}/packages/access-settings
Accès et autorisations par paquet, packagesPUT, GET, DELETE /orgs/{org}/packages/{type}/{name}/access, .../access/teams/{team}, .../access/actions/{repo}
Noms de paquets protégés, package_protection_rulesPOST, PUT, GET, DELETE /orgs/{org}/packages/protection-rules
Secrets Actions de l’organisation, org_secretsPUT, DELETE /orgs/{org}/secrets/{name}, GET /orgs/{org}/secrets
Dépôts, forgejo_repositoryForgejo /api/v1/orgs/{org}/repos
Protection de branche par dépôt, forgejo_branch_protectionForgejo /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_days accepte null ou l’une des valeurs 7, 30, 90, 180, 365 ; toute autre valeur est refusée avec 400.
  • Liste d’adresses IP autorisées : le PUT remplace toute la liste, envoyez donc les trois champs. La première écriture fait aussi passer une organisation encore sur une portée historique (web ou web+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 avec 400 invalid-allowlist : la CLI ne peut donc pas bloquer la machine sur laquelle elle s’exécute. Commencez avec audit_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_severity est le seuil de blocage des images pour les fusions et les pulls : low, medium, high, critical ou null (high). Les autres valeurs sont refusées avec 400.
  • 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 plan compare ce qui est comparable.
  • Mises à jour des dépendances : tous les champs sauf schedule.
  • Releases immuables : releases = true est 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éfaut false) active l’analyse ClamAV de ce que le proxy récupère ; block_until_scanned en a besoin, et un PUT qui laisserait block_until_scanned activé avec malware_scan désactivé est refusé avec 400 scan-pair (voir Récupérations par le proxy de dépendances). metadata_filter masque, 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 sous GET /orgs/{org}/package-policy/decisions (metadata). "on" n’est pour l’instant disponible que pour les organisations internes de fremforge ; ailleurs, il est refusé avec 403 metadata-filter-internal-only.
  • Webhooks et endpoints SIEM : seuls active (webhooks) ou enabled (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 statut fremforge/<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 un plan ultérieur affichera la règle comme une dérive.
  • Secrets Actions de l’organisation : la valeur est en écriture seule, donc plan ne peut pas voir une valeur modifiée. Effectuez la rotation avec -replace. Transmettez les valeurs depuis votre coffre de secrets, par exemple TF_VAR_org_secrets='{"DEPLOY_KEY":"..."}'.
  • Arguments en écriture seule dans le plan d’OpenTofu : lorsqu’une restful_resource change 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 de Plan: 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 :

  1. apply de l’exemple ;
  2. de nouveau plan -detailed-exitcode, qui doit se terminer avec 0 (aucune modification) ;
  3. application d’une modification : copiez test/change_override.tf, plan doit se terminer avec 2, apply effectue la modification, et le plan qui suit doit se terminer avec 0 ;
  4. destroy, avec reset_on_destroy = true, après quoi chaque paramètre se relit comme avant l’exécution.
Le script de test : run-cycle.sh
terraform/run-cycle.sh
#!/usr/bin/env bash
# run-cycle.sh: the whole lifecycle of the example, with either CLI.
#
#   run-cycle.sh terraform   # or: run-cycle.sh tofu
#
# init -> apply -> plan again (must report no changes) -> apply a change
# (test/change_override.tf: plan must show it, apply makes it, the plan after
# must be clean) -> destroy. The no-change plans are the checks that matter:
# they prove every setting reads back exactly as it was written, which is what
# drift detection depends on.
#
# Expects TF_VAR_org, TF_VAR_fremforge_token and TF_VAR_forgejo_token in the
# environment, and TF_VAR_repo_name set to a name not used before (a destroyed
# repository sits in the org's trash under its old name). Set TF_VAR_org_secrets
# to cover org Actions secrets. Run it against a test org only: it sets
# reset_on_destroy, so destroy puts platform defaults back.
set -euo pipefail

cli="${1:?usage: run-cycle.sh terraform|tofu}"
case "$cli" in
  terraform | tofu) ;;
  *)
    echo "run-cycle.sh: unknown CLI '$cli' (want terraform or tofu)" >&2
    exit 2
    ;;
esac

cd "$(dirname "$0")/example"
export TF_VAR_reset_on_destroy=true
export TF_IN_AUTOMATION=1

# A fresh working directory per run: the two CLIs record providers under
# different registry hostnames, so neither may reuse the other's lock or state.
rm -rf .terraform .terraform.lock.hcl terraform.tfstate terraform.tfstate.backup
rm -f zz_change_override.tf

destroy_on_exit() {
  local rc=$?
  echo "== $cli destroy"
  "$cli" destroy -auto-approve -input=false -no-color || rc=1
  rm -f zz_change_override.tf
  exit "$rc"
}

# plan_expect <0|2> <label>: plan with -detailed-exitcode and fail unless it
# exits as expected (0 no changes, 2 changes).
plan_expect() {
  local want="$1" label="$2" rc
  echo "== $cli plan $label (expect exit $want)"
  set +e
  "$cli" plan -detailed-exitcode -input=false -no-color
  rc=$?
  set -e
  if [ "$rc" = "$want" ]; then
    echo "OK: plan $label exited $rc"
    return 0
  fi
  case "$rc" in
    0 | 2) echo "FAILED: plan $label exited $rc, expected $want" >&2 ;;
  esac
  return 1
}

echo "== $cli $("$cli" version | head -1)"
"$cli" init -input=false -no-color
"$cli" validate -no-color

trap destroy_on_exit EXIT

echo "== $cli apply"
"$cli" apply -auto-approve -input=false -no-color

plan_expect 0 "after apply" || exit 1

echo "== $cli apply a change"
cp ../test/change_override.tf zz_change_override.tf
plan_expect 2 "with the change" || exit 1
"$cli" apply -auto-approve -input=false -no-color
plan_expect 0 "after the change" || exit 1
L’étape de modification : test/change_override.tf
terraform/test/change_override.tf
# The "apply a change" step of run-cycle.sh. It is copied into ../example as
# zz_change_override.tf after the first no-drift plan, so the next plan must
# show exactly these changes, an apply must make them, and the plan after that
# must be clean again. Override files replace whole arguments, so each object
# is repeated in full with the changed fields marked.
module "org" {
  auth_policy = {
    max_pat_lifetime_days  = 7 # was 30; must be one the console offers
    ssh_disabled           = true
    allow_user_pats        = false
    allow_repo_deploy_keys = false
    require_signed_commits = false
  }
  ip_allowlist = {
    cidrs                  = ["192.0.2.0/24", "198.51.100.0/24"] # one added
    audit_only             = true
    allow_platform_runners = true
  }
  operator_access = { allow = false } # was true
  ssh_ca = {
    ca_keys      = []
    require_cert = false
  }
  branch_protection_defaults = {
    enabled                   = true
    required_approvals        = 2 # was 1
    dismiss_stale_reviews     = true
    require_status_checks     = true
    require_pull_request      = true
    require_codeowners_review = false
    merge_queue               = "default"
  }
  actions_policy     = { only_protected_branches = false } # was true
  artifact_retention = { days = 14 }                       # was 30
  dependency_updates = {
    automerge_patch = true
    automerge_minor = true # was false
    major_approval  = true
  }
  scan_policy = {
    dep_scan = { mode = "enforce", block_severity = "high" } # was critical
    sast     = { mode = "enforce", block_severity = "error" }
  }
  scanner_settings = {
    sast     = { enabled = true, block_severity = "warning" } # was error
    deps     = { enabled = true }
    images   = { enabled = true }
    secrets  = { enabled = true }
    licenses = { enabled = true }
  }
  push_protection_failure_mode = "allow" # was block
  exception_settings = {
    max_days                    = 90 # was 180
    approver_team               = null
    dismissals_require_approval = false # was true
  }
  immutability = {
    releases           = false
    allow_repo_opt_out = true # was false
    oci_mode           = "off"
    oci_mutable_tags   = []
  }
  image_signature_policy = {
    enabled = true
    mode    = "shadow"
    identities = [{
      issuer        = "https://frem.sh/_app/runner/oidc"
      subject_regex = "https://frem\\.sh/${var.org}/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/heads/main"
    }]
    scope = ["api"] # was []
  }
  sla_policy = {
    critical_sla_days = 3 # was 7
    high_sla_days     = 30
    medium_sla_days   = 90
    low_sla_days      = null
  }
  webhooks = {
    audit-feed = {
      target_url = var.webhook_url
      events     = ["push", "pull_request"]
      active     = false # was true: changes in place
    }
  }
  siem_endpoints = {
    siem = {
      url             = var.siem_url
      action_prefixes = ["auth.", "security."]
      enabled         = false # was true: changes in place
    }
  }
  environments = ["staging", "production", "qa"] # one added
}

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ètresTerraform 1.16.5OpenTofu 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 SSOapply, plan sans modification, modification (plan avec code 2, apply, plan avec code 0), destroy : réussiapply, 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éussiapply, 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 0le 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 apply

Prendre 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) : destroy réé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, appelle POST /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_team et forgejo_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_resource sur GET /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 schedule des mises à jour des dépendances. Il vaut null par défaut, et le modèle de lecture du provider ne sait pas restituer un null.
  • 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 = true est irréversible, ni valider un corps de requête avant apply. 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/restful 0.25.2 oublie l’empreinte du ephemeral_body en écriture seule d’une ressource après une mise à jour sur place. Une ressource qui définit cet argument planifie alors un PATCH vide à chaque exécution suivante, que l’API refuse avec 400. Le module ne définit donc ephemeral_body que sur siem_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