Running it rapidvalue.eu →

Environments and staged config

How to run development, acceptance and production of the same IGA configuration without the three of them quietly drifting apart — and how a change to a live system gets reviewed before it takes effect.

Two different kinds of drift

"Drift" is used loosely here, and the two meanings need different mechanisms.

The first is drift between environments: acceptance and production started as the same configuration and no longer are — because someone changed one and not the other, or because they were built separately and were never quite the same. The mechanism is cloning from a known source, plus a controlled vocabulary for what an environment is.

The second is drift inside one environment: a live system's configuration changes and nobody can afterwards say what changed, who changed it or what it was before. The mechanism is staging — every edit becomes a reviewable field-level diff that an explicit act applies, and the applied delta lands in an immutable audit record.

This page covers both. Neither is version control for configuration, and the page says where the edges are.

Environments as a controlled vocabulary

An environment tag on a system (dev, acc, prd) is only useful if the whole tenant agrees on the vocabulary. When each clone supplies its own free-text tag, you get dev, Dev, development, test-2 and Entra Dev on five systems that are all the same thing, and every filter, badge and grouping that reads the tag becomes unreliable. That is taxonomy drift, created one harmless-looking text field at a time.

So the tenant holds a list of environment presets{label, key} pairs, defaulting to Development / Acceptance / Production — administered under Environment & connections. The key is slug-safe by validation (^[a-z0-9_-]+$, unique within the list), because it becomes both the system's environment tag and the suffix of its identifier. The "add environment" flow binds a picker to that list; there is no free-text path into it.

The vocabulary is enforced at the point of authoring, not in the data model. The clone service accepts whatever tag it is given, so a system carrying an arbitrary tag from before this existed keeps working rather than failing validation on read. The controlled list governs new clones; it does not retrospectively police old ones, and nothing backfills or renames them.

A cloned system also joins its source's environment group, so the siblings of any system are a query rather than a naming convention someone maintains.

Cloning: what travels, and what deliberately does not

Cloning a system creates a new sibling in a different environment from a known source. It copies the parts that are genuinely the same configuration: the engine and role, the object and attribute configuration, read mappings, provisioning mappings, the authoritative-source declaration, the sync schedule shape, the execution mode and agent binding.

It does not copy two things, for two different reasons.

Secrets never travel. No client secret, password, token or private key is copied to the clone, and there is no code path that would do it. Credentials reach the new system afterwards through credential rotation — the same single-purpose path every other credential change uses. This is the same posture as the hybrid deployment model, where secrets are never sent from the control plane to an on-premise agent: a secret's blast radius is kept to the one place it was put.

Plain connection fields are collected fresh, not inherited. A different environment legitimately targets a different directory tenant, application registration or service account. Copying those forward would be worse than leaving them blank: you would get a system that looks configured and points at production. So the clone form asks for them — rendered empty, never pre-filled from the source — alongside the new base URL.

The plain/secret split is derived, not a list someone maintains

Which connection fields are "plain" and which are secret is not a hand-kept table per connector type. Such a list is correct on the day it is written and wrong the first time a connector gains a field; the failure is silent and the direction is bad — a missed entry means a secret treated as plain config.

Instead the split is derived from two things that already exist: the secret-field detection the vault uses when deciding what to encrypt, and the catalogue's own declaration of each authentication field (a password-typed field is a secret by definition). A field is plain only if neither says otherwise; vault-managed handles and internal keys are excluded outright. So the plain-field path cannot be used to write a secret — an attempt is rejected rather than quietly stored inline, on the clone form and the post-creation edit alike.

The field list is enumerated from the catalogue rather than from whatever the system happens to have stored, with current values overlaid. That matters here: a freshly cloned system has an empty configuration, so enumerating from stored values would show no fields and imply, wrongly, that none needed filling in.

A clone is not yet a working system

The clone is created as a draft, with sync disabled and portal requestability off. It has no credentials until you provide them. Its base URL is validated on the way in under the same rules as any new system, which is why an agent-mode clone may point at a private address and a control-plane one may not.

This is stated plainly because the opposite is easy to assume: cloning gives you the configuration of a working system, not a working system. Credentials, connection test and activation are the same steps any new system goes through, and are not skipped because the configuration came from somewhere trusted.

Staged config: edits become a diff, not a write

On a system's configuration surface, editing does not write through. Every change — the engine configuration, the object and mapping structure, the scalar settings, the governance toggles — stages into a draft. Nothing reaches the live system until an explicit apply.

The staged surface is derived from the update surface itself: everything that can be changed on a system stages, minus credentials. It is defined by subtraction from the API's own field set, so a field added later is stageable automatically. A hand-maintained list would let the surface slip back into a half-staged state — some controls staging, others writing through — which is worse than either extreme, because the reviewable diff would then be quietly incomplete.

Credentials are the one deliberate exclusion, and they are excluded rather than staged: secrets travel the rotation path, and are never staged, never diffed and never written into an audit payload.

One draft per system, not per administrator

The draft is unique per system. Two administrators editing the same system share it, and both see who staged it and who last touched it.

Per-administrator drafts sound more considerate and are the wrong answer. They make silent last-write-wins the normal case: two people stage overlapping edits, both see a clean diff of their own change, both apply, and the second one silently erases the first — with each diff having been accurate in isolation. A reviewable diff exists precisely to prevent a change nobody reviewed; a per-person draft reintroduces it as the default.

The draft lives on the server, so it survives a reload, a different browser and a colleague picking the work up.

The diff is a diff, not a JSON dump

Two properties make it readable at the point of review:

Lists are matched on a natural key, not on position. Objects are keyed by name, their mappings by target field, provisioning mappings by job type and field. Reordering an object list is therefore not a change, and inserting one object does not present as "everything after it changed". These nested lists are what administrators break in practice, so they are what the diff must be precise about.

Secrets are redacted at the one point every consumer reads through. If a token has been parked in free-form configuration, the diff renders it masked and marks it secret. The check runs on every segment of the path, not just the leaf, so a whole credential-shaped subtree stays redacted rather than leaking one level down; and the mask is total, because a secret's length and prefix are themselves signal. That matters more than it first appears: the same redacted structure is what goes into the audit record, and audit rows are immutable — a secret leaked there would be permanent.

Worked example

An administrator retargets the email mapping on the users object, drops the groups object, tightens the drift policy, and — inadvertently — pastes a token into free-form configuration. This is what stages:

engine_config.objects[users].mappings[email].expression
    changed   "{{ mail }}"  →  "{{ userPrincipalName }}"

engine_config.objects[groups]
    removed

unapproved_action
    changed   "keep"  →  "auto_remove"

engine_config.extra.api_token
    added     ••••••          (secret — value never rendered or stored in audit)

4 changes staged by anna@example.com · reviewed by ben@example.com

Meanwhile a colleague changes the drift policy directly. Apply refuses:

409  stale_base
"This system's configuration changed on the server while these edits were
 staged. Review the refreshed diff before applying."
drifted_paths: ["unapproved_action"]

Nothing is applied — not the three uncontested changes either. The colleague's value survives, and so does the draft, so the four staged changes can be re-reviewed against the new reality rather than retyped.

Had the colleague instead changed something unrelated, apply would have proceeded. The staleness check recomputes the base for exactly the staged fields, so an unrelated change elsewhere on the same system does not falsely block the edit. A whole-record check would produce refusals that look arbitrary, and refusals that look arbitrary get worked around.

A second, narrower check sits on top: the client may assert which diff it rendered, so a page left open while a colleague re-staged cannot apply a diff the person pressing the button never saw.

In both cases the fail-safe is the same, and it is the important part: apply nothing. Never a partial application, and never a clobber.

Apply is atomic with its audit

Applying replays exactly the staged fields — nothing else is re-validated, so untouched configuration cannot be clobbered by a stale copy of itself. The draft deletion, the write and the audit record commit together as one transaction.

If the write is rejected — an engine configuration that fails validation, a technical field on a locked system — nothing commits and the draft survives. That is deliberate: losing a staged edit to a validation error would be the worst available failure for a stage-then-apply flow, because it destroys the work at precisely the moment the person is told they made a mistake.

An apply whose diff turns out to be empty — a colleague already made the same change — clears the draft without fabricating a change event.

The resulting record carries the field-level delta, who staged it, who applied it and any note, and it fills the system's change history. It is an audit event like any other: immutable at the database level and part of the tenant's hash chain.

No approval gate on apply — a considered position

There is deliberately no second approver. Apply is a reviewable diff, not a two-person rule, and we would rather say that plainly than let the phrase "staged config" imply a gate that does not exist.

The reasoning: the diff's value is that a change is legible before it takes effect and attributable after. An approval step on top would need its own routing, escalation and timeout semantics, duplicating machinery this platform already has for access decisions, in service of a change class where the reviewer and the author are usually the same small team.

The seam is left open rather than designed away: the draft carries a status field with an unused pending-approval value, and apply is already a separate, explicit endpoint that a gate could sit in front of. Today only the draft status is ever written.

Known edges, published rather than discovered


Further reading: