Authentication + authorization

Sparkwing uses a shared-secret bearer token model with typed principals and per-endpoint scope annotations.

Token formatSection anchor link

Raw tokens are <prefix>_<entropy>:

  • swu_... -- user. Created for humans (sparkwing cluster tokens create --type user, or sparkwing cloud connect --admin-token-stdin, which mints one and writes the profile that holds it).
  • swr_... -- runner. Created for remote machine agents or pool replicas.
  • sws_... -- service. Created for in-cluster back-channel callers.

The prefix segment is the first 12 characters of a raw token. It's a non-secret identifier used in sparkwing cluster tokens list, revoke, and audit logs. The remaining ~35 characters carry the secret entropy.

Metered runnersSection anchor link

A token carries a metered marker the operator sets, either at mint (sparkwing cluster tokens create --metered) or afterwards (sparkwing cluster tokens set-metered --prefix P --metered true). That marker is the only thing that decides whether the work a runner does costs credits. A claim-mode runner chooses its own labels, so a label saying "cloud" proves nothing and metering never reads one.

A claim by a metered token reserves its first minute inside the claim's own transaction. The reservation is what makes the check safe when several runners poll at once: each one's spend is visible to the next before either claim commits, so a balance that covers one minute hands out one node, not one per runner. When the balance cannot cover the reservation, POST /api/v1/nodes/claim answers 402 with "code": "insufficient_credits", the node stays ready, the run records a credits_blocked event, and the runner keeps polling.

The live claim's fenced execution acknowledgement starts billing immediately before the node body runs. Claiming, queueing, provisioning, image pulls and runner startup do not consume the reservation. A heartbeat after execution starts charges the seconds since the previous charge, and the finish charges the tail the last heartbeat missed and refunds whatever is left. A finish or expired claim before execution refunds the complete reservation, so a node that runs for four seconds pays for four seconds. Two bounds apply. No single charge bills more than the charge cap (30 seconds by default), so a controller outage or a stalled heartbeat loop does not bill the gap it left behind. A node that is requeued -- its lease reaped, its runner lost, or its attempt reset for a retry -- releases its charge window, so the next attempt starts a fresh reservation and the idle time between attempts is never billed.

Once the balance reaches zero the node keeps running for the grace period. The first heartbeat after that window fails the node with the failure reason credits_exhausted, releases its claim, and answers 409, which is how the runner learns to stop. The run records a credits_exhausted event naming the balance and how long it had been spent.

A token with no marker is neither checked nor charged, so a deployment that marks none bills nothing.

CreditsSection anchor link

Cloud runner time is prepaid. Amounts are stored in micro-credits: a million micro-credits is one credit, and a hundred credits is one dollar, so a ten dollar top-up is a thousand credits. At the default rate a cloud runner second costs 0.02 credits, which is 1.2 credits a minute and 72 credits ($0.72) an hour, so ten dollars buys just under fourteen hours.

A second is priced by the node's cpu class. The rate table prices one class per whole-core size, and a node is billed at the class it pinned, which is the class the pod is given. Nothing a claimant says about itself reaches the price, because a runner that priced its own work would bill a 64-core node at the smallest class. A request above the largest class the table prices fails the node with unpriced_cpu_class and a credits_unpriced_class event naming both sizes, rather than leaving a node no claim can pay for.

An installation that never set a table bills the default ladder, which carries GitHub Actions' Linux x64 rates to the second: 2-core 10,000 micro-credits, 4-core 20,000, 8-core 36,667, 16-core 70,000, 32-core 136,667, 64-core 270,000. credit_rate_micro_per_second is the four-core entry of that ladder under another name. Once a table exists that setting is derived: a PUT that names it, alone or beside rate_table, answers 400 and says to write the table. sparkwing cluster credits settings --rate-table 2=10000,4=20000,8=36667 sets the ladder and needs admin. A stored table this build cannot read is an error on every credit read rather than a silent return to the flat rate.

The balance is the sum of grants less the sum of charges, computed in SQL over the credit_grants and credit_charges tables. A grant is free or paid and records who added it and the payment it came from. A charge is a reservation a claim took, the usage an interval billed, or the refund of a reservation a node did not use; each names the run, node, token prefix, and seconds it covered, and the class and rate it was billed at, so a later change to the table never reprices a charge already written.

sparkwing cluster credits show prints the balance, the rate table, the charge cap and the last day's burn. sparkwing cluster credits grant --kind free|paid --amount N adds credits and needs admin. sparkwing cluster credits history lists every movement newest first.

Retained storageSection anchor link

Runner time stops costing when a node ends; retained bytes keep costing while they are kept, so they are billed from the same ledger. An installation bills storage only once an operator prices it: storage_rate_micro_per_gb_day is what one gibibyte kept for one day costs and storage_free_allowance_bytes is what every team keeps unbilled, and both are zero until written, so an installation that sets neither writes no storage charge.

What the storage charge bills is run-event payload bytes, which is the one thing the controller durably stores and already measures per team. Artifact content, cache entries and hosted logs are not metered on this release, because the controller never sees their sizes: a runner writes artifact blobs straight to the object store and the controller holds only the manifest digest. A published manifest counts one object against the team's quota and carries no bytes, so it costs nothing here.

Each storage pass bills every team holding bytes for the interval since it was last billed. The pass runs on the controller's hourly storage timer, so the meter's error is one pass interval of bytes held and released between two passes, not a whole day of them. A team the ledger has never billed is stamped with the current instant and billed from the next pass, so pricing storage never bills for the past, and so a team's first bytes cost one pass before the meter reaches them. A team that drops to nothing keeps no watermark, and an interval is never billed for longer than the bytes in it have been held, so an idle stretch is not charged against whatever a team stores next. A team whose retained runs all carry no creation date bills nothing for that interval. Each team's watermark moves by compare-and-set, so two controllers on one database bill an interval once whatever either clock says, and a clock that steps backwards bills nothing rather than billing twice.

The amount is bytes x rate x seconds divided by a gibibyte-day, truncated toward zero, so a fraction of a micro-credit is never billed and truncation forgives at most one micro-credit per team per pass. Three gibibytes retained against a one-gibibyte free allowance for one day is two gibibyte-days, which at 833,333 micro-credits a gibibyte-day is 1,666,666 micro-credits, or 25 credits a gibibyte-month, GitHub's $0.25.

The charge is a storage row naming the team, the bytes it billed and the interval it covered, so sparkwing cluster credits show and credits history separate retained bytes from runner time. It carries no cpu class and no per-second rate, because neither priced it.

Each team's allowance is how many retained bytes it asked to keep. The pass expires its oldest finished runs above the allowance before it bills, and it never bills for more than the allowance, so the allowance is both what a team keeps and the most it pays for; an allowance of zero keeps everything and caps nothing. Read and write one with sparkwing cluster credits allowance --principal NAME --gb N, which needs admin. Only that verb writes it: rewriting a team's quota leaves the allowance where it stands.

An empty balance is a hard cut, the same as it is for runner time: a write that would grow a team's retained bytes is refused with 402 and a reason naming the team and the balance, and the pass drains retained bytes down to the free allowance. That drain takes only runs whose retention window has already elapsed, so non-payment never removes anything inside the window, and an installation with no retention window drains nothing.

The cut is late by up to one storage pass, at most an hour: a balance that reaches zero between passes keeps accepting writes until the next one, and the team's storage quota is what bounds how much can land in the meantime. For a team that held nothing before, the stamp pass comes first, so the cut can take two passes to engage.

Runner classesSection anchor link

A class is a whole number of cores with the memory that comes with it, and it is the unit a pipeline buys. The ladder is 2, 4, 8, 16, 32, and 64 cores, and each class carries 4 GiB of memory for each of its cores: 8 GiB at two cores, 32 GiB at eight, 256 GiB at sixty-four. A node takes the smallest class that covers both halves of what it pinned, so a pin of three cores and 20 GB takes the 8-core class because the 4-core class carries only 16 GiB. The pod is created with cpu and memory requests and limits equal to its class, so a node never outgrows the class it is billed at.

The 2-core class runs on the warm pool and starts in seconds. A larger class starts a Kubernetes node of its own, which takes one to two minutes during the preview, and the controller refuses every warm claim and offer for it so it cannot land on a machine it shares. warm_cpu_class_cores is the largest class the warm pool serves, 2 by default; zero starts a node of its own for every class. Local claim-mode agents are unmetered and claim by their labels as they always have.

Each class above the warm one names the band of machines it runs on. The 4-core and 8-core classes select nodes labeled sparkwing.dev/cpu-band: small and tolerate the sparkwing.dev/cpu-band=small:NoSchedule taint. The 16-core class and every class above it select and tolerate large on the same key, and their pods carry a required anti-affinity on that label across kubernetes.io/hostname, so one of them holds a machine alone: the taint by itself does not give it the machine, because two 16-core pods fit one 48-vCPU node. The operator's own node selector and tolerations are merged in and win on this key, so a cluster that pins Jobs its own way keeps doing so. A cluster that serves classes above the warm one needs node pools carrying that label and that taint; without them the pod is unschedulable and the node fails as below, with the scheduler's own message.

The whole machine is bought with throughput. Concurrency in the large band is the number of machines the pool's own limit allows, one Job to each, and nothing queues behind it: a pool bounded at three 32-vCPU machines runs three 16-core Jobs at once and fails the fourth after the five-minute wait below, and a class that fills the pool's limit on its own runs one at a time.

A claim answers with the class it billed, as credit_cpu_class_cores and credit_cpu_class_memory_bytes, and the Job is created from those two figures, so the pod shape and the bill agree whichever ladder the operator priced. A claim that names a node carries sizes_to_class to say it creates the node's executor at that class; a metered claim without it is held to the warm class, and an unmetered one may not set it at all, so a customer's local agent can never route a class node to itself. The metered token is the operator's own pool, and class routing trusts it: the warm loop and the Job dispatcher share one process and one token, so the controller takes the flag at its word until the Job builder moves server-side and the pod shape is the controller's own. A runner cpu or memory ceiling below the billed class fails the node naming both, because a customer must never be billed for a class the pod cannot get. A pod no node accepts within five minutes fails the node with the scheduler's own message, which is what a class larger than the cluster provisions looks like. A node whose .Requires() labels no runner advertises, and which no fallback may take, fails after five minutes with a node_unmatchable event naming the labels, the labels the fallback does advertise, and the class, so work the fleet cannot serve ends where an operator can see it.

The class is stamped on the node when it becomes ready, so the queue read leaves the classes a warm runner may not take out of the scan entirely and a 2-core node behind thousands of larger ones is still claimed at once.

Compute guardsSection anchor link

The guards bound what the controller starts before the ledger bills it. Each is one non-negative integer, and zero is unlimited, so a controller that sets none behaves as it did before the guards existed.

GuardBoundsMeasured against
max_concurrent_runnerscloud runners held at onceone principal
max_global_runnerscloud runners the controller holdsevery principal
runner_alarmcloud runner count that logs a warning, set below the ceilingevery principal
max_run_secondswall-clock seconds a run may hold cloud runners forone run
max_nodes_per_runnodes a run may carry, which is what bounds a dynamic fan-outone metered principal's runs
max_runs_per_hourruns created in the last hourone metered principal
max_global_nodes_per_runnodes a run may carryevery run
max_global_runs_per_hourruns created in the last hourevery run
min_cron_interval_secondsshortest interval a controller schedule may declareevery controller schedule
runner_scale_baserunners one step of paid credit buys, at most a million; zero uses max_concurrent_runnersone principal
runner_scale_step_creditspaid credit that earns one more base, at most a billion; zero turns scaling offthe controller's ledger
runner_scale_ceilingmost a scaled cap may reach, at most a million; zero uses max_global_runnersone principal

A cloud runner is a claim a metered token holds, so the runner guards count exactly the work credits pay for. max_nodes_per_run and max_runs_per_hour are a team's own budget: they measure the principal whose token created the run and apply only while that principal holds a metered token, so local work and unmetered runners pass them untouched. The max_global_* pair is the operator's own ceiling and counts every run whichever principal created it. The hourly window counts runs by their creation stamp, so a run that has already finished still occupies the budget until it ages out of the hour.

Scaling the per-principal runner capSection anchor link

max_concurrent_runners scales with what the controller loaded recently, so a customer that has paid for capacity gets it and one that has paid nothing cannot spawn a thousand pods. The cap is the base plus one more base for every runner_scale_step_credits of paid credit granted in the last 30 days, held under runner_scale_ceiling. The base is runner_scale_base, or max_concurrent_runners when that is zero; the ceiling is runner_scale_ceiling, or max_global_runners when that is zero. With a base of 100, a step of 5000 credits and 15000 credits loaded, a principal is held to 400 runners.

Every scaling setting is zero by default, which holds each principal to the static max_concurrent_runners, and the rule applies only while that guard is set. Scaling only ever raises that guard: a ceiling below it is ignored. runner_scale_base and runner_scale_ceiling are capped at a million runners and runner_scale_step_credits at a billion credits, so a typo cannot mint a cap.

free credit earns nothing and a payment ages out after 30 days. A refund is a reversal grant naming the payment's reference, and it is matched to that payment rather than to its own date: a refund settled after the window still takes back the payment that bought the cap, and refunding a payment that has already aged out leaves this month's payments alone. The ledger belongs to the controller and a controller serves one team, so every metered principal on it derives the same cap.

The derivation is held for a minute so a claim costs no ledger query, and any grant or reversal retires it at once. A ledger the derivation cannot read holds the principal to the static max_concurrent_runners and names the failure in the controller log. max_global_runners is checked first, so the controller's own ceiling still refuses a claim a scaled cap would have allowed.

GET /api/v1/compute-limits reports the result as usage.derived_runner_cap with the usage.recent_paid_micro it was read from, which is the window's paid grants less the reversals of them, and sparkwing cluster limits show prints it as DERIVED RUNNER CAP.

Work a guard refuses answers 429 with "code": "compute_limit" naming the guard, its ceiling and what was measured, and a Retry-After saying how soon to ask again. The run records a compute_limit_blocked event that sparkwing runs status prints on its guard: line; a refusal is recorded against a run the refused principal owns, and a guard that names no principal records nothing and reaches the operator through the log. A claim refused this way leaves the node ready and the runner keeps polling. A run that passes max_run_seconds loses its node on the next heartbeat: the node fails with the reason compute_limit, its claim is released, and the heartbeat answers 409.

min_cron_interval_seconds is measured over a schedule's next fires rather than its text, so */5 * * * * and 0,5,10,... both measure five minutes. It is checked when a repository arms its schedules and again when the tick is about to fire one, so a guard set after arming still binds.

sparkwing cluster limits show prints every guard with the cloud runners in use, per principal and in total. sparkwing cluster limits set --name G --value N sets one guard and needs admin; a value of zero removes it.

ScopesSection anchor link

The scope constants live in pkg/controller/auth.go; the full route-to-scope mapping is in the generated api-reference.md:

ScopeUnlocks
runs.readGET /api/v1/runs, /runs/{id}, /runs/{id}/nodes, /runs/{id}/events, /trends, /agents, /queue/state, /credits, /credits/history, /compute-limits, per-node metrics GETs, and similar deployment-wide reads. /runs/{id} alone also admits a nodes.claim or triggers.claim token holding a live claim on that run
runs.writePOST /api/v1/triggers, /runs/{id}/cancel, /runs/{id}/retry, /runs/{id}/nodes/{id}/bounce, /runs/{id}/nodes/{id}/release, /gitcache/refresh
nodes.claimPOST /nodes/claim, heartbeat, the per-node write routes, GET claimed node data, GET the claimed run and trigger, and read-only Git proxy routes scoped to a live claimed run
logs.readGET on logs-service (/api/v1/logs/*, /api/v1/logs/search)
logs.writePOST + DELETE on logs-service (/api/v1/logs/{runID}/{nodeID}, /api/v1/logs/{runID})
triggers.readGET /api/v1/triggers, /triggers/{id}, /triggers/spawned-child. /triggers/{id} alone also admits a nodes.claim or triggers.claim token holding a live claim on that run
triggers.claimPOST /api/v1/triggers/claim, /triggers/{id}/heartbeat, /triggers/{id}/done, and GET the live claimed trigger and its run. The heartbeat and the done name a trigger, and each is bound to the claimant that trigger's row records
runs.statePOST /api/v1/runs, /runs/{id}/finish, /runs/{id}/plan, /runs/{id}/nodes, /runs/{id}/events, per-node start, finish, deps, status, the offer-round routes mark-ready, revoke-ready, finalize-ready, auto-retry/reset, the slot routes /concurrency/{key}/acquire, heartbeat, release, holder, resolve, and PUT /pipelines/{name}/profile/pin. Every write naming a run is bound to a run the caller owns; the pin names a pipeline and is bound to a live claim on a run of it
secrets.readGET /api/v1/secrets/{name}, resolved against the repository of the run the caller holds a claim in
approvals.writePOST /api/v1/runs/{id}/approvals/{nodeID} (approve / deny a gate)
admintokens / users / secrets CRUD, the token metering marker, credit grants, the compute guards, run delete, gitcache seed, warm-pool checkout / return / heartbeat, and the two cross-run concurrency routes force-release and cancel-waiter -- see api-reference.md for the per-route mapping

Scope checks are set membership. admin is a superset -- any handler's scope check passes if the principal carries admin.

A runner needs nodes.claim, triggers.claim, runs.state, secrets.read, and logs.write. That set claims work, drives the run it claimed from plan to finish, reads the secrets its repository owns, and ships logs. It mints no token, reads no user, lists no secret, and cannot read cached source for an unclaimed run. A pool replica that executes already-created nodes still needs runs.state, because start, finish, and event append are its own writes; it can drop triggers.claim when a separate dispatcher claims triggers.

The set carries neither runs.read nor triggers.read, and a node process opens with exactly those two reads: GET /api/v1/runs/{id} and GET /api/v1/triggers/{id}. Both admit a caller holding a live claim on that run in place of the scope, so the claim the runner already took is what opens them. Add runs.read only to give a token the deployment-wide view.

A warm-pool dispatcher hands nodes to a pool on that same set. It opens and closes each offer round while holding the run's trigger claim, so the readiness routes admit it without an admin token.

A route can narrow a field below its route scope. The node dispatch reads (GET /api/v1/runs/{id}/nodes/{nodeID}/dispatch and /dispatches) admit runs.read, but fill env_json only for an admin principal. Every reader still gets redacted_keys, the names the snapshot dropped as credentials.

Claim ownershipSection anchor link

Scope decides which routes a token may call; the claim decides which node it may write. POST /api/v1/nodes/claim binds the claim to the claiming token: the controller records that token's prefix segment alongside the principal name and the client-supplied holder_id. The prefix is what the gate matches on, because it is unique per token while a principal name is a free-form label two tokens may share; the name stays for display. Afterwards the per-node write routes require that token plus the exact holder, membership, reservation, and claim generation while the lease is unexpired. A missing fence gets 403 with "error": "claim_required"; an expired or stale fence gets 409 Conflict. Another runner token is refused, and POST /runs/{id}/nodes/{nodeID}/heartbeat answers 409 unless the token, the principal, and the holder id all match. admin bypasses the check, which is what lets a dispatcher mark a node ready, start it, and finish it.

The lease is an authorization window, so the claimant does not choose how long it lasts. lease_secs above the server cap of 10 minutes is clamped, on the claim and on every heartbeat; a runner renews well inside that.

POST /api/v1/triggers/claim binds the same way and increments a claim generation. A trigger's id is the id of the run it creates, so trigger-driven node mutations carry that exact generation and are accepted only while the same token holds the live claim for that run. A stale generation gets 409.

POST /api/v1/triggers requires more than runs.write when it names parent_run_id. A node-spawned child carries the exact live claim for parent_run_id and parent_node_id; a trigger-spawned child carries the exact live trigger generation for parent_run_id. Another principal, another token with the same principal name, a stale generation, a different parent node, or no claim cannot attach lineage or inherit repository provenance. admin retains its operator override.

GET /api/v1/runs/{id} accepts runs.read or a live nodes.claim or triggers.claim owner of that run. GET /api/v1/triggers/{id} likewise accepts triggers.read or either live claim. Claim-scoped access expires with the lease and never widens list routes.

Run-definition writes -- run create and finish, plan snapshot, node create, and run-level events -- require the source trigger's exact live generation. Per-node writes accept either the node's exact live claim or that source trigger generation. A run heartbeat likewise accepts one exact live node claim from the run or the source trigger generation. A runner with runs.state but without the applicable fence gets 403 claim_required; a stale generation gets 409 Conflict.

An assisted executor acknowledges its claim generation and next monotonic attempt ordinal immediately before each job-body invocation. Node log appends must carry that started ordinal in addition to the exact claim fence. The logs service validates it against the controller and stores it in an immutable attempt substream. Trigger-owned node logs carry the trigger generation and started attempt ordinal; trigger-generation-only logs are reserved for the coordinator's _compile output. Ordinary node reads return executor and attempt attribution but remove holder and reservation values; claim responses still return the fence the winner must present.

PUT /pipelines/{name}/profile/pin is the one runs.state write that names a pipeline instead of a run, and a pin becomes a hard Kubernetes limit for every later run of it. The caller must hold a live claim on some run of that pipeline, so a token executing one pipeline cannot pin another's. admin bypasses, which is what lets a dispatcher pin a pipeline it is not running.

The two reads a node process opens with, GET /api/v1/runs/{id} and GET /api/v1/triggers/{id}, run the ownership check the other way: a caller without runs.read or triggers.read is admitted when it holds a live claim on that run, and refused with 403 missing_scope otherwise.

mark-ready, revoke-ready, and finalize-ready take runs.state plus the live claim on the run's trigger, the same gate auto-retry/reset carries. A node claim never satisfies them: readiness is a dispatcher decision, and the dispatcher is whoever claimed the trigger. A caller that does not hold that claim gets 403 claim_required, and a run with no trigger row answers 404. admin bypasses.

The slot routes under /api/v1/concurrency/{key}/ -- acquire, heartbeat, release, holder and resolve -- take runs.state plus a live claim on the run the request names, so a pipeline that declares a concurrency group or a memoized node runs on the runner scope set. acquire and resolve name their run outright; heartbeat, release and holder name a holder, and the controller reads the run off that holder's row. A caller holding no live claim on that run gets 403 claim_required, and so does a holder whose lease has already lapsed, because a lapsed row proves nothing about who is calling. The two routes that act on rows another run owns, force-release and cancel-waiter, stay admin. admin bypasses all of it.

A nodes.claim token also reaches only the runs it is working on. The node read routes (GET nodes/{id}, nodes/{id}/output, nodes/{id}/bounce) and POST /runs/{id}/heartbeat answer 403 claim_required unless the caller holds an unexpired claim on some node of that run. admin bypasses; so does runs.read on the reads, which already grants the wider view through GET /runs/{id}/nodes.

Node mutations validate the exact fence in the same transaction as the write. If the lease expires or another generation wins while an old executor is paused, the old write cannot land. An append already accepted by the log service remains confined to the old attempt substream rather than entering the replacement's log.

The execution view (GET /api/v1/runs/{id}?include=secret_values) follows the same rule: it returns plaintext argument values to an admin principal, or to a nodes.claim principal holding an unexpired claim on one of the run's nodes. A trigger claimant may read the run record but receives redacted secret values until it holds a node claim. A controller serving unauthenticated returns plaintext, because the whole API is open in that mode and handing a runner *** as a real argument value would corrupt the run rather than protect it. Once authentication is on, a request that carries no principal is refused.

Secret ownershipSection anchor link

A secret carries an owning repository slug, or none. Store one with sparkwing secrets set --name DEPLOY_KEY --file ./key --repo acme/web --profile prod. A secret stored with neither --repo nor --shared answers admin callers only; --shared opens an unscoped secret to every run in the cluster, so reserve it for values that are genuinely shared, such as a registry pull token.

GET /api/v1/secrets/{name} resolves differently per principal:

  • An admin principal reads any row. ?repo=<slug> selects a repository's row, ?run=<id> selects the repository of that run, and without either the unscoped row answers.
  • A secrets.read principal without admin cannot name a repository. It names the run it is executing with ?run=<id>, and the controller answers only when the caller holds that run's claim; the name then resolves against that run's repository, falling back to an unscoped row only when that row is shared. A caller holding no claim reads nothing. A caller holding claims in one repository may omit ?run; holding claims in two, it must name the run.

So one runner token cannot lift another repository's deploy credential by asking for it by name, and a token working two runs cannot read the wrong one's credential by accident. GET /api/v1/secrets (the list) and the secret writes stay admin; the list carries each row's repository and shared flag.

Token creation validates scopes against that same set: a scope the controller does not honor is rejected with a 400 naming the offending scope and the valid set, so a typo fails at mint time instead of producing a token that authenticates and then fails every scope check. A token with no scopes is still legal; it just unlocks nothing.

Per-endpoint scope annotations live in pkg/controller/server.go. If you add a new route, annotate it with requireScope.

GET /api/v1/auth/whoami is authenticated by the middleware like any other route but carries no scope check, so any valid token can read back its own principal, kind, scopes, and prefix. The logs service uses it to resolve tokens against the controller. It shows as public in api-reference.md because that table is generated from requireScope wrappers -- there, public means no scope check, not no authentication.

Unauthenticated endpointsSection anchor link

Routes registered on the controller's outer router are matched before the auth middleware runs, so they are open regardless of auth config: the health and metrics probes (k8s httpGet probes and Prometheus scrapes can't carry Authorization), the service-discovery endpoint the runner uses to find the cache pod, the browser session endpoints the dashboard uses to establish, validate, and end a session (login, logout, session), the bootstrap probe, and the GitHub webhook, which is HMAC-verified instead of bearer-authenticated. The logs service opens its health and metrics probes the same way. Every registered route is listed in api-reference.md.

With controller-backed dashboard login enabled, the browser authenticates same-origin dashboard requests with its HttpOnly session cookie. The dashboard validates that session before its server-side proxy adds the service bearer to an upstream controller request; the service credential never enters browser HTML or JavaScript. CLI and automation clients should authenticate directly to the controller through a profile rather than send a bearer to the browser-facing dashboard proxy.

Dashboard authorizationSection anchor link

The dashboard proxies a fixed list of controller routes: the run, node, approval, agent, and trend reads the SPA renders, plus the trigger, cancel, retry, debug-release, approval-resolve, and run-delete writes its buttons issue. A second list covers the logs service and carries reads only, so the browser cannot delete a run's logs or append a forged line through the web pod. Every other path under /api/v1/ answers 404 at the web pod and never reaches the upstream, so a signed-in tab cannot mint a token, read a secret, or create a user through the proxy. Both lists live in internal/web/proxy_routes.go, and a test holds each entry to the scope pkg/controller/server.go and pkg/logs/server.go register for that route.

A browser session carries the scopes of the user who signed in. The proxy checks them against the target route before forwarding, so an account holding only runs.read reads runs and gets 403 on cancel. Create narrower accounts with sparkwing cluster users add --scope runs.read,logs.read; omitting --scope grants admin. The first-visit bootstrap account defaults to admin and may carry more scopes beside it, but a scope set that omits admin is rejected with 400, and sparkwing cluster users list prints the scope set of every account.

The web pod's own service token needs runs.read plus logs.read. Add runs.write where the UI cancels, retries, or releases a debug pause, and approvals.write where it resolves approval gates. That token bounds what the proxy can reach at all; the session's scopes bound what one signed-in user reaches through it.

Deleting a run from the dashboard needs admin on both sides, because the controller registers DELETE /api/v1/runs/{id} at admin: the web pod's token must carry admin and so must the signed-in account. Leave admin off that token where operators should delete runs with the CLI instead; the dashboard button then reports delete needs the admin scope and nothing is removed.

sparkwing-web --require-login needs a controller session backend. Pass --controller URL, or select a --profile whose controller.url is set. A state-only configuration such as --state-spec=postgres://... --require-login now fails at startup instead of silently serving an unauthenticated dashboard. The controller URL must be an absolute http or https URL without embedded credentials, a query, or a fragment.

Every dashboard response carries Content-Security-Policy (default-src 'self' plus a per-response nonce for the bundle's inline scripts), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy: same-origin, and adds Strict-Transport-Security when the request carries evidence of TLS: the listener terminates TLS itself, a peer inside --trusted-proxy-cidrs forwarded X-Forwarded-Proto: https, or the operator passed --hsts because TLS terminates somewhere that forwards no trusted header. That same evidence decides the scheme the CSRF origin check expects, so a dashboard behind an HTTPS proxy keeps Secure cookies without the insecure-cookie override. The page reads its configuration from /sparkwing-runtime.js, which carries the dashboard version and the login mode. The service bearer stays in the web process and rides only its server-side proxy, so the browser talks to one origin and connect-src 'self' holds.

A dashboard that carries --token, runs without --require-login, and binds a non-loopback address refuses to start, because every caller that reaches the listener would drive the controller with that token. Pass --require-login, bind a loopback address (chart: web.addr), or accept the exposure with --allow-unauthenticated-remote (chart: web.allowUnauthenticatedRemote). --token with no controller, logs, or profile backend is a startup error too: nothing would authenticate with it, so the dashboard would serve unauthenticated while the flag suggested otherwise.

Login throttling uses the TCP peer address and ignores forwarded headers by default. When a reverse proxy fronts sparkwing-web, pass its egress networks as --trusted-proxy-cidrs=<CIDR,...> or set the chart's web.trustedProxyCIDRs. Sparkwing accepts X-Forwarded-For only from a trusted peer and walks append-style chains from right to left until it reaches the nearest untrusted address. Values to its left are ignored. A malformed entry in the trusted suffix or an untrusted immediate peer falls back to the TCP peer. IPv4-mapped CIDRs with prefix lengths /96 through /128 normalize to IPv4; broader mapped prefixes fail startup. List proxy networks, not client networks.

The controller throttles POST /api/v1/auth/login the same way and takes the same --trusted-proxy-cidrs flag, because it is reachable without going through the dashboard. sparkwing-web forwards each browser's resolved address to the controller as X-Forwarded-For, so the controller's list must include the web pod's source; otherwise the controller ignores the header and keys every dashboard login on the web pod's own address. See security.md for its budgets, the per-prefix bearer budget, and the argon2 memory bound.

The login, first-admin, and logout forms carry a CSRF token in both a SameSite=Strict cookie and a hidden field. Sparkwing rejects a missing, cross-origin, or mismatched token with 403 before it calls the controller. Unsafe browser API requests (POST, PUT, PATCH, and DELETE under /api/v1/) also require a same-origin request whose X-CSRF-Token header matches both the browser's sw_csrf cookie and the live controller session. The dashboard proxy removes browser cookies and the CSRF header before adding its server-side bearer to controller or logs-service requests. Logout also verifies the token against the live controller session. It clears the browser session only after the controller confirms revocation; a controller failure returns 502 and leaves the cookies in place so the browser does not claim a session was revoked when it was not.

The dashboard resolves the controller session on every HTML, data, and API request. Hashed files under /_next/static/ contain no tenant data and do not touch the session backend. Deleting a session on another web replica or at the controller therefore takes effect on the next protected data request rather than after a local cache expires. A controller 401 authoritatively clears the browser session; a controller outage, 5xx, or malformed response returns 502 and preserves the cookies so a transient failure cannot log out every user. The controller answers 5xx when the state store or the session signing key is unreadable, so only an unknown or expired session reaches the browser as 401. Browser redirects preserve the original path and query as one encoded next value and accept only same-origin absolute paths.

Login cookies are Secure by default, so a login-required dashboard must be served over HTTPS. A plain http://localhost port-forward can reach health endpoints but cannot retain those cookies. For a loopback-only development process, SPARKWING_WEB_INSECURE_COOKIES=1 permits HTTP cookies. The dashboard reads that variable once at startup and refuses a non-loopback bind with it set. That check reads the bind address only: a proxy or sidecar in front of a loopback bind still carries the cookie unencrypted to everything it publishes. An operator who publishes the dashboard over plain HTTP through a proxy or ingress adds --allow-insecure-cookies-remote to accept cookies that travel without TLS; the chart renders that flag with the variable whenever ingress.allowInsecure opts a TLS-less ingress in.

First-visit signupSection anchor link

Controller authentication is enabled at startup when the tokens table contains an active token. --require-auth makes startup fail when it does not, and --bootstrap-admin-token-file (SPARKWING_BOOTSTRAP_ADMIN_TOKEN) puts the first admin token in that table before the listener binds, so a provisioned controller starts with both satisfied; see the security operator checklist.

A freshly-installed sparkwing cluster has no users, so there is nothing to log in as. While controller authentication is disabled, browsing to /login on an empty cluster renders a "Create first admin" form. Submitting it creates the first admin user via POST /api/v1/users, then signs the new admin in automatically.

The bootstrap path is one-shot and latched: once any user exists, the controller serves {"needed": false} to the probe, the login page reverts to the standard sign-in form, and POST /api/v1/users goes back to requiring an admin token. There is no way to reopen the bootstrap path short of restarting the controller against a freshly emptied database.

When controller authentication is enabled, the bootstrap probe reports {"needed": false} and POST /api/v1/users requires an admin token even if the users table is empty. An operator can use that token with sparkwing cluster users add to create the first dashboard user. That first account has to be an admin, so leave --scope off, or name a list that contains admin; a narrower list is refused with 400 while the users table is empty.

After the first admin is created, additional users are added via sparkwing cluster users add. Pass --scope to bound what that account's dashboard sessions reach; omitting it grants admin.

CLISection anchor link

Every sparkwing command that talks to a remote controller reads connection info from a profile. Register one first:

# Register a prod profile (controller URL + admin bearer).
# --token-stdin prompts without echo on a terminal and reads a pipe otherwise.
sparkwing configure profiles add --name prod \
    --controller https://sparkwing.example.com \
    --token-stdin

--token accepts the bearer on the command line instead, but every process on the machine can read it from the process list and the shell records it in history. Use it only where a prompt or a pipe is impossible.

Then the tokens commands are terse:

# Mint a user admin token. Emits the raw token ONCE. Stash it.
sparkwing cluster tokens create --type user --principal alice --scope admin --profile prod

# List all active tokens.
sparkwing cluster tokens list --profile prod

# List including revoked, for audit.
sparkwing cluster tokens list --include-revoked --profile prod

# Revoke a token by its non-secret prefix.
sparkwing cluster tokens revoke --prefix swu_6cF9r2Kp --profile prod

# Look up metadata for a prefix.
sparkwing cluster tokens lookup --prefix swu_6cF9r2Kp --profile prod

# Rotate: mint a replacement, with a grace window before the old one 401s.
sparkwing cluster tokens rotate --prefix swu_6cF9r2Kp --grace 48h --profile prod

--grace is capped at 7 days; a larger value is rejected with 400. Revoking the old prefix cuts an open grace window short, so a rotation you started before learning the old token leaked can still be stopped.

Deleting a user removes the user row, deletes every session that user holds, and revokes every token whose principal is that name, in one transaction. The token the delete request authenticates with is left alone, so an operator whose admin token shares a name with the account being deleted keeps working. Principals are free-form labels, so any other token minted under the same name is revoked too, including one minted for an unrelated caller; keep human account names and service principal names distinct.

Profiles are the only path for targeting a remote cluster, which keeps it hard to accidentally point at the wrong one. The SPARKWING_CONTROLLER_URL environment variable is a fallback only for the local dashboard dev flow, not for remote-cluster targeting.

Argon2 parametersSection anchor link

Hash parameters (pkg/store/tokens.go):

  • time = 1
  • memory = 64 MiB
  • threads = 4
  • key length = 32 bytes

Measured on an arm64 laptop: ~8-15ms per argon2.IDKey. Token lookup on the hot path is prefix-indexed + cached in-process for 60s, so argon2 only runs on cold lookups. Concurrent hashing is capped by a memory budget, and a hash that waits more than 250ms for a slot is shed with 503 and a Retry-After instead of queueing.

Requests that arrive while one token is being verified wait on that verification rather than starting one of their own, so a fleet polling the claim routes costs one hash per token per cache window however many runners poll and however often. sparkwing_auth_token_cache_total counts verifications by how the cache answered them (hit, miss, coalesced) and sparkwing_auth_hashing_rejected_total counts the hashes the memory budget shed; a climbing rejection count on ordinary polling means the window is too short or the budget too small.

A claim or heartbeat that is answered 503 with a Retry-After is a load signal, so the runner waits the header out, capped at 30 seconds, and logs it at debug with at most one warning a minute. It does not fail the poll or the node.

A claim that finds no work can also name the interval the runner should wait before polling again, in the X-Sparkwing-Poll-After header. The controller widens what it suggests with how long it has had no work, up to --idle-claim-poll (default 5s), and stops suggesting anything the moment work arrives or is handed out, so a queue that fills returns its fleet to full cadence on the next poll. A runner caps what it accepts at 8 seconds whatever the header says, and the controller refuses to start unless two of the longest wait its suggestion permits, spread included, still fit inside --placement-hold and --placement-liveness, because a runner silent past those windows stops counting as live for local-first placement. The header is advice a runner may only widen its own cadence to: it never polls faster than it was configured to, it spreads its return with jitter so a fleet advised together does not come back together, and a runner that ignores the header polls exactly as often as it always did. A controller running a limits profile enforces the suggestion instead, answering an early claim 429 with the rest of the wait; see security.md. A host's own admission daemon and the loopback controller suggest nothing: they serve one machine's runs, where a widened idle poll costs pickup latency and protects no fleet.

How long revocation takes to biteSection anchor link

The verified-token cache holds an answer for 60 seconds, keyed by the token's public prefix and a SHA-256 of the whole credential, so the raw token is never held in controller memory between requests. That window is the outer bound on how long a revocation the replica did not serve takes to bite.

Revoking a token, rotating one, and deleting a user all drop the affected prefixes from the controller replica that served the request, so the next request on that replica re-reads the row and gets 401. A cached entry also carries the row's expires_at and revoked_at, which are rechecked on every hit, so a token that expires or whose rotation grace closes mid-cache stops authenticating on time rather than at the end of the cache window. An authentication that was already reading the row when the revoke landed does not install its entry, so it cannot put the revoked row back into the cache.

Three windows remain:

  • Other controller replicas. Invalidation is in-process. A replica that did not serve the revoke keeps its cached entry for up to 60 seconds. Restart or scale the controller to zero to close it now.
  • The loopback controller each run starts. A local run serves the admin API from the orchestrator process over the same tokens table, behind its own 60-second cache that a controller restart does not reach. It is bound to loopback and exits with the run.
  • The logs service. sparkwing-logs resolves callers through the controller's whoami and caches the answer for its own TTL (60s by default), on top of whatever the controller replica held. Its worst case is the sum of the two.

Sessions carry no cache: the controller reads the sessions row on every request and the dashboard resolves the session on every protected request, so deleting a session or a user logs that browser out on its next request.

A session expires 12 hours after its last use and the controller renews it when under an hour remains, but never past seven days from the moment it was created. Reaching that age deletes the row and answers 401, so the browser signs in again. An embedder changes the cap with controller.Server.WithSessionMaxLifetime.

Extension pointsSection anchor link

  • OIDC / SSO: not implemented. The users + sessions tables are shape-compatible; an OIDC callback can populate sessions directly by writing sha256(session id) into sessions.hash and keeping the raw id only in the browser cookie. There is no csrf_token column: Sparkwing derives that token per request as an HMAC of the session id under a key in sparkwing_meta.
  • Audit trail: the principal name is stamped onto the OTel trace span. There is no dedicated audit database.
  • Per-user multi-tenancy: principals are a free-form label. Adding a roles model is orthogonal and doesn't require a wire-shape change.
  • Fine-grained admin split: triggers.claim, runs.state, and secrets.read carved the runner's work out of admin. What remains can be split further into cache.write, locks.admin, and similar when a real caller needs that narrower trust.
  • Execution capabilities beyond assisted nodes: workstation and gateway agents keep their enrollment bearer in the supervisor and give each job-body child a process-lifetime loopback capability for its exact run, node, and acknowledged attempt log/lifecycle. Schema 30 is the internal current-node dependency; schema 31 adds the current-attempt mutation fence and durable grants for Memoize, Concurrency, ToolSlot, RunAndAwait, cross-pipeline references, and dynamic SpawnNode before this path can ship. Other execution modes retain their documented credential boundary. A future capability service could make the same split portable across container and process boundaries that do not share one supervisor.