Documentation

Infrastructure as Code.

Last updated

Infrastructure as Code (Terraform / OpenTofu & CI Actions)

There is a guide for this. Manage AccessFlow with Terraform is the step-by-step version: get a service-account key, configure the provider, apply a first resource. This chapter is the reference behind it.

Beyond the env-driven GitOps bootstrap, AccessFlow ships an official Terraform / OpenTofu provider (bablsoft/accessflow) and reusable GitHub Actions + a GitLab CI template for managing governance resources declaratively over the REST API. Both authenticate with an API key — the provider manages datasources, review plans, routing / row-security / masking policies, SQL review rulesets and custom rules, AI configs, and notification channels with the same authoritative-upsert semantics as the bootstrap reconciler.

Can AccessFlow be managed with Terraform?

Yes. The official bablsoft/accessflow provider works with both Terraform and OpenTofu, and manages datasources, review plans, routing, row-security and masking policies, SQL review rulesets and custom SQL review rules, AI configs, and notification channels. It authenticates with a service-account API key and upserts declaratively, the same way the GitOps bootstrap reconciler does.

Service-account API keys

A pipeline needs credentials without an interactive login, so it runs as a service account — an account that can never sign in interactively, by password or single sign-on, and authenticates only with an API key. Give each pipeline its own, with a role that covers only what it manages.

From the admin screens (the usual way): Sidebar → Security & Access → Identity → Service accounts → Create service account, then Issue key on its API keys tab. The key is shown once. Keys issued there can be rotated with a grace period — the old key keeps working for 24 hours by default (ACCESSFLOW_SERVICEACCOUNTS_ROTATION_GRACE) while you swap the CI secret — and revoked instantly. The same page sets request limits, an AI-agent tool allow-list, and the people the account may act for. See service accounts.

Declared in configuration — for an account that must exist before anyone signs in, such as the Terraform identity that provisions everything else. You supply the raw key from a Secret (only its hash is stored; it is replaced in place when it changes). Set these operator env vars (or the equivalent Helm bootstrap.serviceAccounts[] with an apiKeySecretRef):

  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_EMAIL
  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_DISPLAY_NAME
  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_ROLE — defaults to ADMIN, so always set the narrowest role that works; a later change is applied on the next restart
  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_API_KEY_NAME
  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_API_KEY — the raw af_-prefixed token
  • ACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_API_KEY_EXPIRES_AT — optional ISO-8601

A declared account's name and role come from the configuration: they are set when the account is created and applied again whenever the configuration changes, so narrowing the role there and restarting narrows the account. The service-account screens will not change them, and neither will the general user-management screens or API. A custom role the account already has — one given to it before this release, or held by an account the configuration later takes over — is kept — the configuration can only name a built-in role, so it never overwrites one. Its limits, tool allow-list and description stay editable. Its declared key cannot be revoked or rotated from the screens either — the next change to the configuration would bring it back. Rotate it by changing the Secret and restarting, or deactivate the account in an emergency.

Every request an API key makes is counted against that identity — 120 per minute by default (ACCESSFLOW_SERVICEACCOUNTS_RATE_LIMIT_REQUESTS_PER_MINUTE), with an optional daily cap (…_REQUESTS_PER_DAY, unlimited by default). A service account can carry its own limits, set by an admin on its Limits tab (or PUT /api/v1/admin/service-accounts/{id}). Over the cap the API answers HTTP 429 with a Retry-After header. Every wrapper on this page waits it out rather than failing the job — the GitHub Actions and GitLab jobs for provisioning and queries within their own timeout, the deployment-gate wrappers below within the wait timeout, and the Terraform provider for up to five attempts — so a busy pipeline slows down instead of breaking. A signed-in person in the browser is never limited.

Terraform / OpenTofu provider

main.tf
terraform {
  required_providers {
    accessflow = { source = "bablsoft/accessflow" }
  }
}

provider "accessflow" {
  endpoint = "https://accessflow.example.com" # or ACCESSFLOW_ENDPOINT
  api_key  = var.accessflow_api_key            # or ACCESSFLOW_API_KEY
}

resource "accessflow_datasource" "prod" {
  name     = "prod-postgres"
  db_type  = "POSTGRESQL"
  host     = "postgres.prod.internal"
  port     = 5432
  ssl_mode = "REQUIRE"
}

Works with both tofu and terraform, and is published at registry.terraform.io/providers/bablsoft/accessflow (and the OpenTofu registry as bablsoft/accessflow). Write-only secrets (password, api_key, notification config values) are never returned by the API, so the provider applies changes to them but can't detect drift — treat the HCL as the source of truth.

Resources & data sources

The provider drives the existing REST endpoints — /datasources, /review-plans, /admin/routing-policies, /admin/ai-configs, /admin/notification-channels, /admin/sql-review-rulesets, /admin/sql-review-rules, and the nested /datasources/{id}/{row-security,masking}-policies. No AccessFlow-specific endpoints were added for it. Idempotency comes from Terraform state (create → store UUID → read / update / delete by id), matching the bootstrap reconciler's authoritative-upsert intent.

ResourceNotes
accessflow_datasourceCreate / read / update / delete, plus import by UUID
accessflow_review_planNested approvers and notify_channels
accessflow_routing_policycondition is the typed tree as a JSON string
accessflow_row_security_policyNested under a datasource; import as datasource_id/policy_id
accessflow_masking_policyNested under a datasource; import as datasource_id/policy_id
accessflow_ai_configapi_key is write-only
accessflow_notification_channelconfig map; channel_type is immutable and forces replacement
accessflow_sql_review_rulesetDeterministic SQL review rules; rules is a set of rule_id / severity / params, and leaving environment out makes it the organization-wide default. Pair it with the datasource's environment attribute
accessflow_sql_review_ruleA custom SQL review rule your organization defines: a condition (the typed criteria tree as a JSON string), a message, a category and a default severity. Its rule_id starts with custom_ and cannot change — editing it replaces the rule. List it in a ruleset by rule_id like a built-in rule
accessflow_decision_hookThe external decision hook asked about queries no routing policy matched. secret is write-only and sent only when it changes; leaving datasource_id out makes it the organization-wide hook

Two data sources — accessflow_datasource and accessflow_review_plan — look an existing resource up by id.

Local development

From terraform-provider/: make build compiles it, make test runs the unit tests with no live stack, make testacc runs acceptance tests against a real backend (needs ACCESSFLOW_ENDPOINT and ACCESSFLOW_API_KEY), and make docs regenerates the provider docs via tfplugindocs. To try an unreleased build, point a CLI dev override for bablsoft/accessflow at the go install-ed binary.

CI Actions

The provision-datasource and run-query GitHub composite actions (referenced as bablsoft/accessflow/.github/actions/<name>@v1) and the include-able GitLab template wrap provisioning a datasource and submitting a governed query from a pipeline. run-query waits for a terminal status and sends an X-AccessFlow-CI header so context-aware routing policies recognise the CI origin.

.github/workflows/migrate.yml
- id: ds
  uses: bablsoft/accessflow/.github/actions/provision-datasource@v1
  with:
    endpoint: https://accessflow.example.com
    api-key: ${{ secrets.ACCESSFLOW_API_KEY }}
    name: prod-postgres
    db-type: POSTGRESQL
    host: postgres.prod.internal
    port: "5432"
    database-name: app
    username: af_reader
    password: ${{ secrets.PROD_DB_PASSWORD }}
    ssl-mode: REQUIRE

- uses: bablsoft/accessflow/.github/actions/run-query@v1
  with:
    endpoint: https://accessflow.example.com
    api-key: ${{ secrets.ACCESSFLOW_API_KEY }}
    datasource-id: ${{ steps.ds.outputs.id }}
    sql: "SELECT count(*) FROM orders"

provision-datasource is idempotent — it looks the datasource up by name, then creates or updates it — so re-running a pipeline is safe. run-query fails the step on any terminal status other than EXECUTED, so a rejected or timed-out query breaks the build rather than passing silently. For unattended execution, pair it with an AUTO_APPROVE routing policy scoped to the CI origin.

On GitLab, include: the ci-templates/gitlab/accessflow.gitlab-ci.yml template and then extends: .accessflow_provision_datasource or .accessflow_run_query, passing the same values as AF_* variables.

Deployment gate in CI

The deployment-governance wrappers let a pipeline ask AccessFlow for permission to release. Every provider follows the same four beats — submit a deployment request (idempotent on the CI run id, so a retried step never opens a second review), poll the fail-closed gate until it answers releasable, confirm execution, then report the outcome. All of them fail the job on a rejection, a timeout, or a gate that returns 404 — an unknown or invisible release is treated as not releasable, never as approved. A passing blip is not a failure: a temporary server error, a dropped connection, or a rate-limited call (HTTP 429 — every API key has a per-identity request cap) is retried until the wait timeout, waiting as long as the server's Retry-After header asks.

.github/workflows/deploy.yml
- id: gate
  uses: bablsoft/accessflow/.github/actions/deployment-gate@v1
  with:
    accessflow-url: https://accessflow.example.com
    api-key: ${{ secrets.ACCESSFLOW_API_KEY }}
    pipeline-id: 1f0c9d02-1c2a-4a19-9f0e-6b2f8f1a4c77
    environment: production
    version: ${{ github.ref_name }}
    commit-sha: ${{ github.sha }}
    wait-timeout: 30m

- run: ./deploy.sh

- uses: bablsoft/accessflow/.github/actions/deployment-outcome@v1
  if: always()
  with:
    accessflow-url: https://accessflow.example.com
    api-key: ${{ secrets.ACCESSFLOW_API_KEY }}
    request-id: ${{ steps.gate.outputs.request-id }}
    job-status: ${{ job.status }}

A composite action has no post-run hook, so the outcome step is separate and runs under if: always() — job.status maps success to SUCCEEDED and failure to FAILED. Pass an explicit outcome: ROLLED_BACK when you roll back; on a review-required environment that opens a follow-up review the submitter can never close. The pipeline is identified by UUID, not by name: a trigger-only API key cannot list pipelines to resolve one.

On GitLab, include: the ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml template and extends: .accessflow_deployment_gate / .accessflow_deployment_outcome. On Azure Pipelines, use the step template ci-templates/azure/accessflow-deployment.yml, which takes your own deploySteps list and wraps it. Any other CI system can follow the plain-curl sequence in ci-templates/examples/generic-curl-deployment.md.

The API key a pipeline uses is an ordinary AccessFlow key belonging to a dedicated service account — issue it as described under service-account API keys above, grant that account can_trigger on the pipeline (see deployment pipelines), and store the raw key as a CI secret. Because the key's owning user is the submitter, that account can never approve its own deployments. Triggering is governed by the grant, not by the DEPLOYMENT_PIPELINE_MANAGE permission, so a CI key needs no administrative rights.

Full provider reference, all resources, and the registry-publishing runbook (the provider is released to a dedicated terraform-provider-accessflow repo that opentofu.org and registry.terraform.io ingest) are in docs/16-iac.md.