Skip to content

Manage the configuration through the API

A node’s file configures one process. What the deployment is, the providers, credentials, budgets, profiles and guardrails, lives in the raft log and is written through the admin API. This guide covers reading and writing that stored configuration. A Terraform provider, an operator and a person with curl can each maintain their own part of one deployment, without any of them being able to flatten the others.

You need a cluster. Without one there is nowhere to keep a source, and these endpoints answer 501.

Address any node. Writes need the raft leader, but finding it is not your job. The node you called forwards the write and waits for its own copy before answering. A write is readable back from the same node the moment the request returns, and a load balancer in front of the admin listener needs no leader affinity. The one exception is a leader election, which answers 503. See below.

The stored configuration is not one document. It is a set of named sources, and each is written independently:

GET /admin/v1/config/sources list them
GET /admin/v1/config/sources/{source} read one, with its revision in the ETag
PUT /admin/v1/config/sources/{source} write one
DELETE /admin/v1/config/sources/{source} remove one

The gateway serves all of them composed together. The name is yours to choose, such as platform, terraform or team-a. A cluster starts with none, and its data plane answers 503 until the first is written.

The rule is that sources must not overlap. Two of them defining the same provider, or both setting auth, is refused rather than merged, and the refusal names both:

config: provider openai is defined by two sources, "platform" and "terraform";
sources must not overlap, so one of them has to give it up

That refusal is the write model. A collision between two sources is a mistake somebody has to decide about rather than one the gateway silently resolves. Who may write what is decided per object, on each provider, profile or block a write touches, and the source is the document it lands in. See Say who may change what.

The listing says who last wrote each source, and the history says who made each revision:

{"name": "k8s-team-a", "revision": 41, "bytes": 812,
"written_by": {"kind": "oidc", "name": "system:serviceaccount:kirto-gate:operator",
"issuer": "https://kubernetes.default.svc", "subject": "…",
"at": "2026-08-26T09:14:02Z"}}

It is attribution, not ownership. Who may write is still the policy over the source’s name. Ask with GET /admin/v1/whoami?instance=config/k8s-team-a. It exists for the one mistake the precondition cannot catch: a person editing a source that a tool maintains. The operator and the Terraform provider both write their sources whole, at the revision they read. An edit made anywhere else is not a conflict to them, and it is gone on their next run. The console shows the last writer on every source and says so when it is not you, and the API gives you the same fact to act on. One last written by a version that did not record writers has no written_by.

The audit trail records the same writer for the same write, on every attempt, refused ones included. A successful config_source_write record carries source_revision, the number the history reports for it, so the two can be joined. (Its revision is the applied snapshot revision, which moves on every rebuild and cannot serve.)

The body is a deployment document, the same shape as the one the node file names, as YAML or JSON. It is stored as sent, so comments survive.

$TOKEN is a JWT from the issuer this gateway trusts. There is no admin secret to hold. kirto-gate login caches one and kirto-gate admin -url presents it for you. The curl form is here because it is what a script or an SDK sends. See Sign in from the command line.

A precondition is required, and the one you send says what you mean:

Terminal window
# Create. Fails if the source already exists.
curl -sS -X PUT https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/yaml' \
-H 'If-None-Match: *' \
--data-binary @platform.yaml

Use the following example:

Terminal window
# Replace. Read first, write back at the revision you read.
etag=$(curl -sSI https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -sS -X PUT https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/yaml' \
-H "If-Match: $etag" \
--data-binary @platform.yaml

There is no unconditional write. A source exists so two writers never contend for one document. Nothing stops two copies of the same writer from arriving at once, such as a retried apply or two operator replicas mid-handover. A blind overwrite is how the second one discards the first without either noticing. A 412 means somebody wrote in between: re-read, re-apply your change, try again.

A source is stored in the raft log, kept in its revision history and readable by anyone the policy lets read config. A write that carries a credential as a literal is refused with a 400 naming the field, whether that is an api_key: sk-…, an access_key_id or a client_secret. Write ${secret:NAME} instead. Each node resolves the name from wherever the platform delivered it: a file NAME under its secrets_dir, then the environment variable NAME, then the cluster’s secret store. A KIRTO_GATE_* name skips the environment: those variables configure the node, the cluster secret among them, and a document never reads one. A source that references ${KIRTO_GATE_*} directly is refused with a 400, whatever grant its writer holds. The store holds a value written once with PUT /admin/v1/secrets/NAME, for the person with a console and no way to put a file on a node. See Store a secret through the API.

The document itself never carries the value, and reads the same whichever way the value arrives.

The write is validated all the way to a snapshot before anything is stored. A document that cannot be served is refused here rather than agreed by every node and then broken on all of them at once. The status says which step said no:

Status Meaning What to do
400 The document does not parse, or has a key nothing reads Fix the document
409 It overlaps another source One of you gives that object up
412 The source moved since you read it, or another source did while this write was being validated Re-read and re-apply
422 It parses, but the deployment it would make does not build Fix what it references
428 No precondition Send If-None-Match: * or If-Match
503 The cluster is mid-election, so nobody can take the write Retry; Retry-After says when

Nothing is written on any of them. Whatever was serving keeps serving.

The 503 is the only one worth retrying blindly, and the only one that is about the cluster rather than the document. The rest are answers about what you sent, and sending it again gets the same answer.

One caveat applies to 422. Two of the validation steps are per-node by construction: ${VAR} references resolve against this node’s environment, and a model reference against this node’s cache. A cluster whose nodes disagree about their environment can still be handed a document that builds on the node that took the write and not on its peers. Keep the variables a document depends on wherever the document is.

Every write takes ?dry_run=true. The request is decided and validated the whole way, the same 403, 409, 412 and 422 come back where they would have, and nothing is stored on a 200:

Terminal window
curl -sS -X PUT "https://gw.example/admin/v1/config/sources/team-a?dry_run=true" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H "If-Match: $etag" --data-binary @team-a.yaml

The 200 carries dry_run: true and the revision the source is at now. A dry run leaves no audit record, since nothing happened. It is a read that asks the write’s questions, and the answer holds until somebody writes: a document that would land now can meet a 412 a moment later, which is what the precondition on the real write is for.

A dry run is decided as the write, so only a caller who may write can make one. A caller who may only read the source asks the check instead. It runs the same parse, composition and build and stores nothing:

Terminal window
curl -sS -X POST "https://gw.example/admin/v1/config/sources/team-a/check" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
--data-binary @team-a.yaml

The check is the grant for an agent or a CI job that proposes a document for a person to apply. It answers about the document rather than the caller. The per-object write decisions are left to whoever applies it, so a document that checks can still meet a 403 on the write. The secrets, variables and credentials the document reaches are decided as they are on a write, because the build resolves them. The 200 names the revision the document was checked against. Pass that revision as If-Match on the write, and a source somebody wrote in the meantime refuses the write instead of landing a document nobody checked. kirto-gate config check -source team-a team-a.yaml is the same request from the command line.

A write that lands is agreed by every node, and each of them then builds it against its own environment and artifacts. The written response says the revision landed and whether the node that took the write could build it. Whether every other node did is on the members listing, where each node reports the store sequence its served configuration was built from:

Terminal window
curl -sS https://gw.example/admin/v1/cluster -H "Authorization: Bearer $TOKEN" \
| jq '.members[] | {id, served, rebuild_error}'

A write at revision N is rolled out when every member’s served is at least N. A member below it with a rebuild_error is one that agreed on the document and could not build it, and is serving the previous configuration until its environment is fixed or the document is written again. A member below it with no error has not reported yet.

A delete is validated the same way, because a source can be the one holding what the others depend on:

Terminal window
curl -sS -X DELETE https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" -H "If-Match: $etag"

Removing the source that held the credentials every provider names is a 422, not a broken cluster. The consequence is that tearing a deployment down means deleting in dependency order.

Deleting the last source does something specific. An empty store says nothing about what the deployment is, so the node’s deployment document takes over again. The node’s own source is the one source the API does not delete; see below.

A document PUT replaces everything the source says, which is right for a tool that renders its whole document and wrong for changing one provider by hand. Every object is also addressable on its own, by kind and name, without saying which source holds it:

Terminal window
# Replace one provider, at the revision of the source that declares it.
# The body may omit the name; the path supplies it.
curl -sS -X PUT https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H "If-Match: $etag" \
--data-binary $'base_url: https://azure.example/openai\ncredential: azure-key\n'
# Read it back, comments included. Kirto-Source names the source that
# declares it, and the ETag is that source's revision.
curl -sSi https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN"
# List every provider across sources, each with its source and revision.
curl -sS https://gw.example/admin/v1/config/providers \
-H "Authorization: Bearer $TOKEN"
# Remove it. Removing the last element removes the key with it.
curl -sS -X DELETE https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN" -H "If-Match: $etag"

An object that no source declares yet is the one write that has to say where it lands, since nothing stored can answer that:

Terminal window
# Create, naming the source. A source that does not exist is created.
curl -sS -X PUT 'https://gw.example/admin/v1/config/providers/vllm?source=team-a' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H 'If-None-Match: *' \
--data-binary $'dialect: openai\nbase_url: http://vllm:8000\n'

?source= may be left out when exactly one stored source is open to you for that object; with several the answer is a 409 naming them. With none, the object lands in api, created on that write, where the policy lets you create there, and is refused with a 403 where it does not. The console writes the same way, so a fresh cluster’s first provider is one form, and api is the source it made.

The addressable paths are the document’s own keys. The name-keyed collections take a /{name} segment: providers, credentials, budgets, profiles, mcp, a2a, access_rules. The guardrails block’s two lists are collections here too, guardrail_detectors and guardrail_rules, so one rule is written without the block around it. The first one written creates the block and the last one removed takes it away, and a detector left unnamed is addressed by its type. The blocks and scalars are addressed bare: guardrails, reconcile, model_artifacts, auth, max_body_bytes, default_provider, on_fidelity_loss, public_url.

What makes this a projection rather than a second write path:

  • It is the same write. The server finds the source that declares the object, splices your one object into that document and runs the whole thing through the pipeline a document PUT runs. The spliced document is composed against the other sources, built to a snapshot, written only if all of it worked, and audited as a write to the source. Deleting the provider a route still names is a 422 here too.
  • The precondition is the owning source’s revision. Two operators editing different providers in the same source are still two writers of one document. The second composes against the first’s change or is told to re-read. Every read here returns that revision in the ETag, and the source in Kirto-Source.
  • The rest of the document is left alone. Other keys, order and comments survive a splice, best-effort. A source kept as commented YAML by a person and also edited through object paths by a tool will lose formatting eventually. Give the tool its own source.

A scalar’s zero value (auth: "", max_body_bytes: 0) is refused on write. Composition treats it as “not set” without claiming ownership, so storing it would make a key that looks set and is not. Unset a field by deleting it. That is the one spelling.

The body of a source PUT is opaque to the transport, stored as sent, byte for byte, but not undocumented. The gateway publishes a JSON Schema for what a document may contain, generated from the same structs that parse one:

Terminal window
curl -sS https://gw.example/admin/v1/config/schema \
-H "Authorization: Bearer $TOKEN" -o kirto-gate-config.schema.json

Point an editor at it and a source kept as a file in a repository gets completion, hover documentation and shape errors while it is being written:

# yaml-language-server: $schema=./kirto-gate-config.schema.json
providers:
- name: openai
base_url: https://api.openai.com
credential: openai-key

The same schema is committed as api/config.schema.json, printed by kirto-gate -config-schema, and referenced from the OpenAPI spec’s request bodies, so generated clients see it too. The configuration file a node runs from has its own, api/node.schema.json from kirto-gate -node-schema, which carries this one as the shape of an inline deployment.

It is shape only, deliberately. It knows every key, every type, and that an unknown key is refused. Those are the strict parse’s rules. Nothing in it is required and the empty document is valid, because a deployment document’s keys are optional and the zero value means unset. It cannot know whether a document composes with the other sources and builds into a deployment. Those answers depend on state no schema can carry, and they arrive as the write’s own refusal, per the table above.

Every write is retained for a while: the last ten revisions of each source, inside a deployment-wide budget. Undoing a bad change does not depend on whoever made it still having the old document:

Terminal window
curl -sS https://gw.example/admin/v1/config/sources/team-a/revisions \
-H "Authorization: Bearer $TOKEN"

lists what is held, oldest first, deletes marked as such. The last entry is the current document. Read the one from before the mistake:

Terminal window
curl -sS "https://gw.example/admin/v1/config/sources/team-a?revision=41" \
-H "Authorization: Bearer $TOKEN" -o before.yaml

and put it back the way any document is put:

Terminal window
etag=$(curl -sSI https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
curl -sS -X PUT https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H "If-Match: $etag" --data-binary @before.yaml

There is no rollback endpoint. kirto-gate config rollback -source team-a -to 41 does the read and the write above, and the restore is an ordinary write. It needs the current revision in If-Match, because the historical response’s ETag is the revision the document came from and sending it is a 412. It passes the same validation, so a document that named a provider another source has since defined collides now even though it did not then. It lands as a new revision, audited like any other. The sequence records that the deployment went there and back. Nothing rewrites it.

This also works for a deleted source. Its history keeps the last documents behind the tombstone, so the undo is reading the pre-delete revision and re-creating the source with If-None-Match: *.

Retention is a working set, not an archive. A revision that has aged out answers 404. The audit trail records every write attempt, successful or refused, and holds the long-term record of who changed what.

A write is decided on the source it lands in, then once per object it adds, removes or changes. A source is one writer’s declared set, so writing into one is decided on the document itself, Resource::"config/team-a", whatever the objects are named. Each object is a Cedar resource of its kind under two containers: its kind, Resource::"profiles", and the source that declares it. A team therefore needs a grant on its document, and a kind-scoped grant narrows what may go in it:

@id("team-a-writes-its-own-document")
permit(principal in Role::"team-a", action,
resource in Resource::"config/team-a");
@id("team-a-declares-only-its-profiles")
forbid(principal in Role::"team-a", action in Action::"write", resource)
unless { resource in Resource::"profiles" && resource.id like "team-a-*" };
@id("platform-team-owns-platform")
permit(principal in Role::"platform", action,
resource in Resource::"config/platform");
@id("everyone-may-read")
permit(principal, action in Action::"read", resource in Resource::"config");

in on a source covers the document and everything in it. A document PUT that adds a profile and a budget is one decision on the source and one per object, and a refusal names what was refused:

403 not authorized to putConfigSource budgets/daily

The trail records one admin.authz denial per refused object, so “who tried to change the daily budget” is a question it answers. Every resource carries resource.source, the source it sits in, so one policy covers a family of documents: the operator writes every k8s-* source under when { resource.source like "k8s-*" }. See Restrict what an admin caller can do for roles and issuers.

A document points at things it does not carry. api_key: ${secret:NAME} and ${VAR} are resolved by the node, and credential: platform-entra names a credential another source declared. Whoever writes the document chooses the base_url those values are sent to, so what a writer may declare and what it may reach are two questions.

Each distinct thing a document reaches is decided once, whichever field names it:

Written Action Resource
${secret:NAME}, ${file:NAME} useSecret secrets/NAME
${VAR} useEnv env/VAR
${secret:NAME} the node answers from its environment useSecret and useEnv secrets/NAME, env/NAME
credential: NAME, declared elsewhere useCredential credentials/NAME

A team that keeps its own secrets under a prefix is one line, and the platform’s keys stay the platform’s:

@id("team-a-uses-its-own-secrets")
permit(principal in Role::"team-a", action == Action::"useSecret",
resource in Resource::"secrets")
when { resource.id like "team-a.*" };
@id("the-node-environment-is-the-platform's")
permit(principal in Role::"platform", action == Action::"useEnv", resource);

The refusal names what the document asked for, and nothing is written:

403 not authorized to use secrets/platform.openai, env/PLATFORM_KEY

A credential the same document declares is not decided here: declaring it was a write to an object, decided as one, and the secret it carries is decided like any other reference. Naming a credential is a question only when another document declares it.

A write is decided on the references inside the objects it adds or changes, and every reference in such an object counts, not only the ones the write adds. A grant governs where a value is sent, and the object holding a reference is where it goes. A provider whose base_url moves sends its unchanged ${secret:KEY} somewhere new, so that write asks for the key. A provider that signs with a credential the same document declares asks for the credential’s references too, even where the credential itself did not change. An object the write leaves alone is not decided. It sends what it sent before whether or not the write lands, so a writer who edits one profile in a document holding the platform’s key needs no grant for the key. Operator-delivered secrets are named <namespace>.<name>, which is what makes a per-namespace grant a pattern rather than a list.

A source is not only for people. Anything that maintains part of the deployment writes one the same way, such as the Terraform provider or the Kubernetes operator. It gets the same two guarantees out of it. The composition refuses by name if it strays into somebody else’s territory, and the compare-and-swap refuses a write composed against a document that has since moved.

kirto-gate config apply is the plain form: a file written as one source on every pass of whatever runs it. It reads the writer the cluster recorded on the source’s last revision, and where that is somebody else, it refuses and names the objects they changed since its own last retained write, the way kubectl apply conflicts on a field another manager owns. -force writes over them. kirto-gate config diff shows what an apply would change, and -dry-run sends the write with ?dry_run=true.

The Terraform provider in terraform-provider-kirto/ is this arrangement packaged: a source, an object or a field as a resource, with the validation refusals surfacing as apply errors. Its README has the resource reference.

The operator in operator/ is the worked example. Every object in a Kubernetes namespace (Provider, Credential, Budget, Profile and the rest) renders into one document, written as source k8s-<namespace> at the revision it was read at:

Terminal window
$ kirto-gate-operator \
-admin-url https://kirto-gate-admin.kirto-gate.svc:9900 \
-admin-token-file /var/run/secrets/tokens/admin \
-secrets-namespace kirto-gate-system \
-secrets-name kirto-gate-provider-keys

Three things follow from a source per namespace:

  • A namespace is a deployment half only. Node-level keys such as listen, cluster and admin cannot travel in a source, and no namespaced kind can express them. ClusterConfig carries the deployment-wide blocks and is cluster-scoped for that reason.
  • Its sources all carry the k8s- prefix. One per namespace, so an operator writes beside a person’s or a Terraform run’s source and never over it.
  • Policy can name it. resource.source like "k8s-*" grants the operator its sources and everything in them and nothing else.

The token file is re-read on every request, so a projected ServiceAccount token that rotates keeps working. operator/README.md has the status conditions and the RBAC the binary’s calls need.

The file configures the process, and nothing else

Section titled “The file configures the process, and nothing else”

A clustered node’s file holds what configures this process: listeners, TLS, the raft identity, the trusted issuers, the admin policy. It holds no deployment and refuses a deployment key, because the cluster is where that node’s deployment lives and a document in the file would be a second statement of it. An edit to the file is a restart; an edit to the deployment is a source write, and it lands on every node without one.

A cluster starts with nothing stored. GET /admin/v1/config lists the sources the served configuration was composed from, empty until the first write, and the data plane answers 503 with Retry-After until then. The first write is the same PUT as every later one, from whoever owns that part of the deployment: the Helm release, a config-management run, the operator, Terraform, or you with curl. kirto-gate config apply -source NAME FILE is that PUT with the read-compare-write done for you: it creates the source, replaces it at the revision it read, and writes nothing when the stored document is already the file’s bytes.

A node with no cluster, the inspector beside somebody else’s gateway, keeps deployment in its file, since it has no store and nowhere else to read a policy from. That is the one shape where the file says what the deployment is.

For Terraform specifically, the resources are already written. See Manage the deployment with Terraform, which expresses this page’s ownership model as HCL. For a Kubernetes operator reconciling custom resources, see the operator/ module.