Skip to content

Config as code

CairnCMS treats roles and permissions as state that lives next to your data model that can be captured to a file, reviewed as a diff, and applied to other environments. The same machinery powers two surfaces: a cairncms config CLI that operates on a directory tree, and a /config/* HTTP API that operates on a single JSON or YAML payload. Both flow through the same plan/apply engine, with one structural difference at the input layer — see Two surfaces, one engine below.

This page covers what config-as-code captures, the CLI workflow, the HTTP equivalent, and the operator practices that make the two work together.

A snapshot contains:

  • Roles — every operator-managed role with its key, name, icon, description, access flags, and enforce_tfa setting.
  • Permissions — the operator-defined rules attached to each role, grouped by role. Each permission rule includes the collection, action, field allow-list, item-level filter, validation, and presets.

It does not contain:

  • Schema. Collections, fields, and relations belong to schema-as-code and ship in a separate snapshot.
  • Users. Account records, passwords, and personal data are intentionally out of scope.
  • The Public role record. The platform-managed Public role is not exported as a role definition in that there is no roles/public.yaml and the role row itself is excluded from the snapshot’s roles[]. Its permissions are still captured and applied through permissions/public.yaml (under the reserved key public), so editing Public access in the app and re-snapshotting produces the expected diff.
  • System-managed permissions. Some permissions are platform-managed and flagged as system-owned (the app-access minimum and recommended permissions that surface for any role with app_access: true, for example). These are projected from in-memory constants at read time rather than stored as ordinary rows, and the snapshot deliberately skips them. They are managed by the platform, not by config-as-code, and reappear automatically wherever a role’s access flags require them.

Roles with admin_access: true are captured like any other role, but the engine refuses applies that would leave the deployment with no role flagged as admin_access: true — see Validation below.

Use config-as-code for the access-control side of the model. Use schema-as-code for structure. Use database backups for the actual user data.

The CLI and the HTTP API share the same plan/apply engine but differ in how they consume input:

CLIHTTP API
FormatDirectory treeSingle flat payload
Source formatYAML filesYAML or JSON
InvocationLocal cairncms binaryBearer-authed HTTP
SafetyInteractive confirmationOpt-in query flags

The CLI suits local development and GitOps pipelines where the directory tree is committed to source control and applied by a runner that has container access. The HTTP API suits remote instances behind a load balancer, automation that lives outside the container, and tooling in any language.

The two are mostly substitutable. Using snapshot via one, and apply via the other works in practice with one structural divergence at the input layer:

  • The CLI directory reader is strict about role/permission file pairing. A permissions/<key>.yaml whose role does not have a matching roles/<key>.yaml (other than the reserved public) is rejected at read time.
  • The HTTP apply path is permissive in the same situation. A permission set whose role is omitted from roles[] in the payload is allowed if a role with that key already exists in the database. This makes payloads that only update permissions without re-stating roles a working pattern over HTTP.

Both behaviors are intentional: the CLI surface is opinionated about the directory shape it reads, while the HTTP surface accepts narrower payloads that target an already-bootstrapped database. If you need consistent validation across both, run --dry-run against the directory tree before posting any narrowed payload to HTTP.

Read the current roles and permissions and write them as a directory tree:

Terminal window
cairncms config snapshot ./config

The CLI prompts before overwriting a non-empty directory. Pass --yes to skip the prompt for unattended runs.

The output structure:

config/
├── cairncms-config.yaml # manifest (version, resources)
├── roles/
│ ├── administrator.yaml # one file per role, named after role.key
│ └── editor.yaml
└── permissions/
├── editor.yaml # one file per role's permissions
└── public.yaml # public role's permissions (no roles/public.yaml)

The split is deliberate: per-role changes show up as small, scoped diffs in source control rather than one giant file every time anyone touches a permission.

Read a config directory and reconcile the database to it:

Terminal window
cairncms config apply ./config

The flow:

  1. Load the directory tree into a single payload.
  2. Read the current database state and compute a plan (creates, updates, deletes).
  3. Validate the plan (manifest version, last-admin-role protection, undefined-role references, duplicate permission tuples).
  4. If the plan is empty, log No changes to apply and exit.
  5. Print the plan summary, prompt for confirmation, then apply.

Three flags adjust the flow:

  • --dry-run — compute and print the plan without writing. Pairs with --format json for machine-readable output. Exits with code 1 if the plan is non-empty, which makes drift detection clean to gate in CI.
  • --yes — skip the confirmation prompt.
  • --destructive — opt in to deleting roles and permissions that exist in the database but are absent from the config directory. Off by default so accidental omissions do not silently delete state.

The destructive flag is the one that makes orphan removal possible. Without it, an apply only creates and updates while orphans in the database remain. This is the safer default for environments where the config directory might not represent the full intended state.

The same workflow over HTTP, restricted to admin tokens.

GET /config/snapshot
GET /config/snapshot?export=yaml

Returns the current roles and permissions as a JSON payload, or as a YAML attachment when ?export=yaml is set. The data envelope wraps the payload the same way every other CairnCMS API response does. The endpoint opts out of response caching, so subsequent calls always reflect the current database state.

POST /config/apply
POST /config/apply?dry_run=true
POST /config/apply?destructive=true

Send a CairnConfig payload — the same shape as the data field returned by /config/snapshot, without the outer envelope. The server accepts:

  • application/json
  • application/yaml, application/x-yaml, or text/yaml

The YAML media types support a natural round-trip: fetch as YAML, edit, post the same YAML back.

Two query flags shape the apply:

  • ?dry_run=true — compute and return the plan without writing. The response shape is identical to a real apply; only the database is left unchanged.
  • ?destructive=true — required for the apply to delete orphans. Without it, only creates and updates run.

The response is a summary of what changed (or would have changed for a dry run):

{
"data": {
"roles": {
"created": ["editor"],
"updated": ["administrator"],
"deleted": []
},
"permissions": {
"created": 5,
"updated": 3,
"deleted": 0
}
}
}

Roles are tracked by key in the response; permissions are tracked as counts because per-rule attribution does not produce useful operator output at scale.

Schema-as-code uses a two-step /schema/diff then /schema/apply flow with a hash handoff to detect concurrent changes. Config-as-code does not. The apply endpoint computes the plan internally on every call because the config payload is much smaller than a typical schema, the engine is fast enough that the plan/apply round-trip in a single call is comfortable, and the dry-run flag covers the same “what would change?” use case without requiring a stateful client.

If you need to inspect the plan before applying, use ?dry_run=true and read the response.

Both CLI and API follow the same omit-versus-null rule:

  • Omitted optional role fields are preserved, not cleared. If a role payload omits icon, description, or enforce_tfa, the database value is left untouched.
  • Explicitly null fields are cleared. To unset a nullable field like description, ip_access, and others, set it to null in the payload.

This matches the schema-as-code semantic: the snapshot describes the desired state of the fields it mentions, not the desired state of every field.

Before applying, the engine validates the plan and rejects the entire apply if any check fails. The validation surface:

  • Manifest version — only versions the engine recognizes are accepted. Future-format payloads are rejected rather than partially applied.
  • Last admin role protection — an apply that would leave the deployment with no role flagged as admin_access: true is rejected. There is no override and no special “Administrator” entity. The protection is purely about the flag, on whatever roles carry it.
  • Undefined role references — a permission set whose role is missing from both the payload’s roles[] and the existing database is rejected. The HTTP path tolerates references to roles that exist in the database but are absent from the payload; the CLI rejects them at directory-read time before the engine sees them.
  • Duplicate permission tuples — two rules in the same role’s set targeting the same (collection, action) are rejected. Permissions must be unique on that tuple.
  • Reserved key misuse — the public key in roles[] is rejected. The Public role record is platform-managed and cannot be created or updated as a role definition. The same public key in permissions[] is the supported way to manage Public access.

When validation fails, the API returns a 400 with a flat errors array of human-readable messages so all failures surface in one response. The CLI prints the messages and exits with a distinct code (2) so CI can distinguish validation failures from connection failures or runtime errors.

The intended pattern for a multi-environment project mirrors schema-as-code:

  1. Make role and permission changes in your dev instance (the app’s Settings → Access Control surface is the easiest editor).
  2. Run cairncms config snapshot ./config to write the directory tree.
  3. Commit. The diff in the pull request shows scoped per-role changes.
  4. CI runs cairncms config apply --dry-run --format json ./config against staging; if the exit code is 1, the deploy step proceeds with cairncms config apply --yes ./config.
  5. Production deploys the same way, after staging verification.

For destructive changes — removing a role, dropping permissions — pass --destructive and review the dry-run output carefully before merge. The destructive flag is intentionally a per-apply opt-in rather than a setting somewhere; cumulative defaults that quietly become destructive are how state gets deleted by accident.

When promoting changes between environments, apply schema first, then config:

Terminal window
cairncms schema apply ./schema.yaml
cairncms config apply ./config

The order matters: permissions reference collections, so the collections have to exist before the permissions that gate them can be applied. Reversing the order produces undefined-collection validation failures.

Both surfaces tolerate empty diffs gracefully by applying an unchanged schema or config is a no-op that exits cleanly. Running both in a deploy pipeline as a matter of course, even when only one has changed, is safe and removes the cognitive load of remembering which one to run when.