Infrastructure as Code.
Last updated
Infrastructure as Code (Terraform / OpenTofu & CI Actions)
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_EMAILACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_DISPLAY_NAMEACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_ROLE— defaults toADMIN, so always set the narrowest role that works; a later change is applied on the next restartACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_API_KEY_NAMEACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS_0_API_KEY— the rawaf_-prefixed tokenACCESSFLOW_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
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.
| Resource | Notes |
|---|---|
accessflow_datasource | Create / read / update / delete, plus import by UUID |
accessflow_review_plan | Nested approvers and notify_channels |
accessflow_routing_policy | condition is the typed tree as a JSON string |
accessflow_row_security_policy | Nested under a datasource; import as datasource_id/policy_id |
accessflow_masking_policy | Nested under a datasource; import as datasource_id/policy_id |
accessflow_ai_config | api_key is write-only |
accessflow_notification_channel | config map; channel_type is immutable and forces replacement |
accessflow_sql_review_ruleset | Deterministic 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_rule | A 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_hook | The 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.
- 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.
- 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.