Config Sync¶
Promote definitional platform config — permissions, roles (and the permission codes they grant) and menus — from one environment to another as a portable, code-keyed bundle, instead of hand-editing each target database.
The admin console can manage the whole RBAC graph at runtime, but that config lives in the database, not in code. A team designs it locally, then needs the same structure in dev / staging / production — each with its own database. Config sync is the first-class way to move it. See ADR 0003 for the rationale and design.
Off by default, and never in production
Config sync is a dev/staging convenience, disabled unless you opt in, and it
refuses to start under a prod / production profile. Production config is promoted
by applying the git-committed bundle on deploy — not by an ad-hoc push to a live system.
Enable it¶
# src/main/resources/application.yml
devslab:
kit:
config-sync:
enabled: true # default false — the whole surface (endpoints + UI) is inert otherwise
If enabled=true while a prod/production profile is active, the application fails fast
at startup with a clear message rather than silently disabling the feature.
What's in the bundle¶
| Included (definitional) | Excluded |
|---|---|
Permissions (code, description) |
Audit logs (history) |
Roles (code, name, permission codes) |
ABAC policies (these are code, not data) |
Menus (code, parent code, label, path, icon, required permission code, order) |
Passwords / secrets |
| Users — opt-in only, by login id, with role codes and no password |
Everything is keyed by natural codes, never database UUIDs, so a bundle exported from one environment applies cleanly to another whose ids differ.
Endpoints¶
GET /admin/api/v1/config/export?tenantId={t}&includeUsers=false |
Returns the bundle as JSON. |
POST /admin/api/v1/config/import?mode=merge&dryRun=true&includeUsers=false |
Applies a bundle; returns a per-section diff. |
A full round trip¶
Move config from your local box to staging — copy-paste:
# 1. on LOCAL — export the definitional bundle to a file
curl -G localhost:8080/admin/api/v1/config/export \
-H 'Authorization: Bearer <local-token>' \
--data-urlencode 'tenantId=default' \
--data-urlencode 'includeUsers=false' \
-o config-bundle.json
# 2. commit it so the promotion is reviewable + versioned
git add config-bundle.json && git commit -m "chore: rbac bundle"
# 3. on STAGING — DRY-RUN first (writes nothing, returns the diff)
curl -X POST 'https://staging.example.com/admin/api/v1/config/import?mode=merge&dryRun=true' \
-H 'Authorization: Bearer <staging-token>' \
-H 'Content-Type: application/json' \
--data-binary @config-bundle.json
# 4. review the diff, then apply for real
curl -X POST 'https://staging.example.com/admin/api/v1/config/import?mode=merge&dryRun=false' \
-H 'Authorization: Bearer <staging-token>' \
-H 'Content-Type: application/json' \
--data-binary @config-bundle.json
Modes¶
merge(default) — additive. Creates and updates; never deletes, and never revokes a role's existing grants. Idempotent: re-applying the same bundle changes nothing.mirror— makes the target match the bundle exactly. On top of the merge it reconciles each role's grants (revoking permissions the bundle omits) and deletes definitional entities absent from the bundle:- menus are deleted leaf-first (a child before its parent);
- a role still assigned to a user is skipped — mirror never strips a user's role;
- permissions are revoked from the tenant's roles, then deleted.
Mirror deletes
mirror removes things. Always review the dry-run diff before applying, and prefer it
only for single-tenant-per-deployment setups (permissions are global).
Dry-run first¶
dryRun=true is the default. The import computes the full diff and writes nothing. The
result reports, per section (permissions / roles / menus / users), what would be:
- created, updated, deleted (mirror only), and skipped (a role in use, or an existing user).
Apply for real with dryRun=false once the preview matches your intent.
User sync (opt-in)¶
With includeUsers=true:
- export carries users by login id — email, status and role codes — but never a password.
- import is create-only: a missing user is created with no usable password and
mustChangePassword, then assigned its roles by code. An existing user is never overwritten (it is reported asskipped). Set the password via the admin console afterwards.
Users are operational data; leave includeUsers off unless you specifically want to seed
accounts into a fresh environment.
Recommended workflow¶
- Design config locally against your local database (admin console or API).
- Export the bundle (
includeUsers=falsefor definitional-only). - Commit the bundle JSON to git — it is now reviewable, versioned config.
- On the target, dry-run the import and review the diff.
- Apply (
dryRun=false). Usemergeto add/update;mirroronly when you intend the target to match the bundle exactly.
Admin console¶
The admin console has a Config Sync page that drives the
whole flow: export (view / download / copy), import (paste or upload → dry-run diff → apply),
the merge/mirror switch, and the user-sync toggle.