Authentication + authorization
A connection chooses the controller. A credential identifies the caller and its exact authority. A claim confines execution to the work the controller awarded.
The controller enforces the shared capability policy through store-backed authentication and transactional authorization. The production constructor wires root provisioning, credential inventory and rotation, and OIDC identity reads to concrete store implementations. See Credential management for the CLI surface.
Cloud operations and test environments
An operator can create a team and its first administrator without OAuth:
sparkwing operator teams create --team support-test --display-name Support --profile deployment -o json
The profile must present a deployment credential with operator.tokens.
The matching HTTP operation is POST /api/v1/operator/teams, with
{"slug":"support-test","display_name":"Support test"}. A configured
operator's direct browser session may also call it with its CSRF proof.
One transaction creates the team, a dedicated account without a provider
identity, its owner membership, the first team-admin capability and a
team.created audit record naming the operator. The credential has the owner's
team permissions and no deployment permissions. Its membership and revocation
remain live authorization checks. Like every token holding team.admin, it
expires after 30 days, and rotation keeps that expiry. Use it to invite a real
owner, who signs in with their own identity, before the 30 days end. Revoke it
through the team token commands.
JSON returns team, account_id, capability, prefix and grants.
Plain and pretty output print the capability once. Store that credential in
the recipient's private connection profile. The server persists its digest,
not the bearer. The operation refuses an existing or retired team slug with
conflict and makes no changes. It never retries automatically because a lost
response cannot recover the one-time credential.
Use sparkwing operator teams show --team support-test --job-namespace sparkwing-cloud-jobs --profile deployment -o json
to read the team identity and its protected node label key and value. The value
hashes the launcher's Cloud Job namespace with the team slug. The launcher sets
that exact team value when it reserves a never-used node, together with permanent
used-team and used-run fences. It never moves a used node to another team or run.
Jobs of the same run may share the node.
Cloud capacity uses one shared NodePool per hardware class and deployment. Its
template identifies the class and Cloud Job namespace and carries the generic
sparkwing.dev/dedicated=true:NoSchedule taint. The launcher verifies the node,
NodeClaim and shared NodePool provenance before binding the team. It has read-only
NodePool get and list access and creates no pools. Team creation requires no
infrastructure change. Without a shared pool for the requested class, a held
award ends as cloud_capacity_unconfigured, releases its unstarted credit hold,
and gives the customer support@sparkwing.dev and the run ID as a support reference.
A closed Cloud budget breaker ends such an award the same way as
cloud_capacity_unavailable (see deployment).
Scaling or exhausted shared pools keep the award pending, with “Waiting for
dedicated Cloud capacity” and elapsed time in run status.
The underlying GET /api/v1/operator/teams/{team} returns team ID and display
name to deployment credentials. Financial and console details remain restricted
to console sessions.
This is the Cloud operations, support and test-environment path. Local mode uses its default team and OS-verified socket. Self-hosted controller onboarding is outside the launch offering.
Controller authorization
Embedded controllers supply WithMetricWriter for job metric and usage writes,
and WithOperatorSettingsWriter for deployment setting changes. Each writer
receives the verified authority and resource target and rechecks them in its
committing transaction. Signup mode and limit changes commit together. Missing
writers return policy_missing.
Job metrics use GET and POST /api/v1/runs/{run}/jobs/{job}/metrics. Process
usage uses POST /api/v1/runs/{run}/jobs/{job}/usage. The former /nodes/ paths
are removed. Request budgets run after policy authorization and use the
credential's team and authenticated holder. A successful empty claim response
spends the polling budget. An award does not. Log concurrency refusals use the
canonical error envelope with rate_limited and a reason in error.details.
The controller's shared policy routes use the store's authentication snapshot
and binding checker by default. Each request reads current capability ancestry
and account membership. Browser writes use the session-bound CSRF verifier by default. The header
and cookie must match the token derived from the persisted encrypted key, and
the request must come from the same origin. A forged or cross-session token
returns forbidden. Session key failures return unavailable. Configured operator
accounts use the default console-session adapter. Their direct account sessions
receive operator_console: true from /api/v1/me, which shows the Operator
navigation link even while waitlisted. Operator console routes bypass the
customer waitlist screen. Customer pages still require admission, and console
API requests still check the configured operator account and direct session.
Verified local peers use
WithPeerIdentity, whose callback returns only the transport-verified uid or SID
as a local subject. Request headers and loopback addresses supply neither
identity. The local store must implement peer authentication snapshots and
transactional authority checks before those presentations can execute work.
Embedded local controllers use Server.HTTP() for the API and dashboard
handlers in wingd.LocalHTTP. The API reads the verified socket identity from
Wingd's connection context. Explicit credentials keep precedence. Browser
requests supply their own authentication. These handlers expose the same capability policy routes as the network controller.
Schedule, invitation, build-trust, billing and storage reads use member
capability policy. The same holds for GitHub App installation, repository,
trigger and extra-repository reads. Schedule and review mutations pass the verified
Authorized value to tenant transactions through WithScheduleWriter and
WithRunControlWriter. Each committing transaction rechecks ancestry,
membership and the selected route alternative. Team administration, run deletion and GitHub App configuration writes use the same boundary. Embedded controllers supply WithTeamWriter, WithGitHubAppWriter and WithBillingWriter for committing transactions. The token-table verifier, its caches and its route fences are removed. Missing adapters fail
with policy_missing. Metrics on the API listener uses deployment or console
policy.
Embedded store users authenticate through capability snapshots and use the tenant or operator capability inventory and mutation methods. The unused token-table authentication, listing, lookup and revocation APIs are removed. Here runs use sealed admission and capability issuance.
GitHub App connection pages verify their signed browser flow through the shared
policy router. Connecting, listing administered installations, selecting and
completing a connection require a live account session with team.admin in its
active team. Form posts require the session's CSRF token. Provider returns
verify signed state and the browser verifier. Embedded controllers supply
WithGitHubConnectWriter for the linked-identity read, single-use state
consumption and installation binding. Each write rechecks the session and
current team ownership. The final bind also rechecks the linked GitHub identity.
Console mutations pass their verified authority to WithConsoleWriter.
The transaction derives the audit actor and rechecks the direct operator session.
This covers credit grants, billing trust, standing-request declines, holds,
account/team deletion, waitlist approval, credit settings and installation
unbinding. Waitlist approval requires
an Idempotency-Key header. A missing transaction adapter returns policy_missing.
Reporting reads use the shared capability policy and the authorized resource's team. This includes run lists, receipts, attempts, steps, events, approvals, debug pauses, pipeline lists and latest runs, trends, runner views, sizing and logs. Unbound machine roots and deployment credentials cannot substitute for team membership. Work and trigger claims receive only the read alternatives declared for their bindings.
Job logs use /api/v1/runs/{run}/jobs/{job}/logs, with /stream and /completeness beneath it. The old /runs/{run}/logs/{job} paths are removed. Use /api/v1/runners instead of /api/v1/agents, /api/v1/approvals instead of /api/v1/approvals/pending, and /api/v1/sizing or /api/v1/sizing/explain instead of /api/v1/capacity/profiles. Read debug pauses at /runs/{run}/debug-pauses. The paused alias is removed.
The default tenant adapters recheck secret and git-credential writes in their committing transactions. They use current team-admin membership and the store clock. Secret reads select the pipeline from the claim's bound run, check its sealed declarations and reserved pools, and commit a release audit before returning the stored envelope. Omitting the run selector does not remove the claim's binding. Members may read only unmasked values in their own team.
GET /api/v1/queue uses the shared capability policy. A member or verified
local peer needs runs.read for its active team. Queue groups and advertised
runner headroom belong to that team. Machine roots and work claims cannot read
the team queue. The former /api/v1/queue/state path is removed. The local
controller that sparkwing dashboard start runs also reports its machine's
admission daemon: daemon_version, daemon_uptime_ms, the cores and
memory rows and the daemon's holders and waiters. Without a running daemon
those fields are absent, and a daemon that does not answer returns
unavailable. A team or Cloud controller has no daemon to report.
Run intake, plan acceptance, job status reads, job transitions, finish,
cancellation, retry and run/job renewal use the shared capability policy. The
controller resolves the resource's team before authorization and derives
actors and fences from the credential. Unknown or ended credentials get
unauthenticated. A live
credential without the grant or binding gets forbidden.
Create a run with POST /api/v1/runs, submit its plan to
POST /api/v1/runs/{run}/plan, and report job state through
POST /api/v1/runs/{run}/jobs/{job}/transition. Renew the awarded claim through
POST /api/v1/claims/{claim}/heartbeat. Outputs use the authorized
/api/v1/data upload, commit and record operations. The controller derives
execution fences from the authenticated claim.
Job-output reads authenticate the consuming job's live award, then authorize the producer against the consumer's declared dependencies in the same run and team. An in-run output read cannot select an unrelated job or another run.
An absent route returns the shared not_found error before credential
lookup. A method with no handler on a registered path returns
method_not_allowed and Allow, also before credential lookup. Neither
response renews browser sessions.
GET /api/v1/runs/{run}/jobs returns a status array from one committed
snapshot. It accepts member runs.read or the live run owner's trigger.host.
GET /api/v1/runs/{run}/jobs/{job} also accepts that job's live data.read
authority. An executor cannot list siblings or read another job through this
route. Both responses use the shared safe job status fields. They omit job
declarations, environment, claim identities and execution settings. The old
GET /nodes and GET /nodes/{nodeID} paths are removed.
POST /api/v1/runs/{run}/jobs/{job}/cancel accepts runs.control from a
member or trigger.host from the live owner of that run. The state transaction
requests cancellation of held jobs and preserves the holder's result rights.
The first cancellation reason is recorded separately from the holder's
terminal result and reaches the holder on renewal.
GET /api/v1/runs/{run}/jobs/{job}/failure?generation=N requires data.read
and a declared recovery read edge to that source generation. It returns the
persisted masked job failure, including its stage, or null when none was
recorded. Status text is never converted into a synthetic failure. Missing,
duplicate or nonpositive generation selectors return bad_request after
credential authentication.
GET /api/v1/runs/{run}/snapshot requires a live trigger owner with
trigger.host bound to that run. It returns one committed scheduling view,
including generated jobs and empty generator groups. Ordinary members and
work or plan claims cannot read this private owner view. Cancellation and
owner lease loss refuse new reads. A cancellation committed after
authentication appears in the returned snapshot. Accepted expansion bodies,
hashes and source generations travel in the same committed read. Replayed
expansion responses retain each generated job's current state and attempt.
Pipeline sizing uses /api/v1/pipelines/{name}/sizing. Members need
runs.read. Executable claims need source.read and the bound run's exact
sealed repository and pipeline. Bare runner and Actions roots cannot read
sizing. Bootstrap credentials can acquire source but cannot read sizing.
The job query selects one job. Omitting it selects the pipeline rollup.
An authorized missing measurement returns not_found.
Pins, observations, contention and waits use the pin, observations,
contention and waits suffixes on that path. They require node.report
and the same pipeline binding. Cancellation permits reporting while the
claim remains live. Member and admin grants cannot bypass this binding.
Writes require an authority-bearing sizing transaction adapter. Without one,
the controller returns policy_missing before storing a measurement.
The old /profile paths are removed.
Token revocation and member updates use the same policy router. A team token is
revoked at DELETE /api/v1/tokens/{token}. Deployment service/operator roots use
DELETE /api/v1/operator/tokens/{token} with operator.tokens or an operator
console session. Readers and editors may revoke tokens they own or created.
Console credential operations recheck the direct session, lifetime and current
operator configuration in their committing transaction. Team routes derive their active team from the committed
credential. Header and query hints cannot select another team.
GET /api/v1/team/members returns the active team's member directory and
requires runs.read. An empty team returns [].
GET /api/v1/credits and GET /api/v1/credits/history require a live member
or verified local peer with runs.read. The committed credential selects the
team. Machine roots, executable claims and bootstrap credentials cannot read
the team ledger. Both routes use the common error envelope and authenticate
before checking whether credit billing is enabled. An authorized caller gets
not_found when billing is unavailable. History retains the limit query
and returns empty grants and charges as arrays.
PATCH /api/v1/team/members/{account} accepts a role of owner, editor or
reader. DELETE on that path removes the membership; it needs team.admin,
except that a member may remove themselves from a signed-in session (not with a
token). The store rechecks the
authenticated authority and commits the role change or removal in the same
transaction. Removal ends the member's existing authority for that team,
including delegated data credentials. Rejoining does not reactivate it; sign in
again to use the new membership. A missing member returns not_found. A last-owner change returns
conflict. Browser mutations require CSRF proof. Revocation stops descendants'
new controller operations, including those parented to a session. Previously
issued external credentials and signed URLs keep their documented lifetimes.
POST /api/v1/runs accepts a request and idempotency_key. The request names
its team. A bound job creating a child must name its own run and job as the
parent. Intake returns the committed run number and first seal. A here bearer
is returned once. Replaying intake returns the committed response without
another bearer.
POST /api/v1/runs/{run}/plan accepts declarations, resolved and plan.
The store validates the second seal against the persisted first seal, including
a retry's preserved source seal. Raw plan snapshots are no longer accepted.
Plan, job-transition and finish requests from ended claims require the exact
recorded body, actor and fence to replay. Mutations recheck authority inside
the store transaction. A lost fence gets lock_held.
Job transitions use /api/v1/runs/{run}/jobs/{job}/transition. Finish ends the
run and its owner unit together. Published local runs use a separate transition
edge and receive no executable owner. Cancellation carries a reason. Retry
carries an idempotency key and leaves source seals unchanged. Run/job heartbeat
responses carry the committed lease expiry, cancellation and watched jobs.
Every request uses JSON. Bodies cannot replace authority, holders or fences.
Job and step annotations, summaries and step transitions use the same policy
under /api/v1/runs/{run}/jobs/{job}. A job claim reports only its own job.
A trigger owner may report its run's jobs, subject to the transaction's holder
check. A local publisher uses its separate run-bound publication authority.
Cancellation permits reporting while the claim remains live. An ended claim
cannot write these reports.
Step reports carry a positive source_attempt, step_id, state and an
optional masked inner error. The controller derives the actor and claim fence
from authorization. The store owns stale-attempt refusal and committed timestamps. Annotations and summaries accept at most
128 KiB of masked text. The old /nodes/ reporting paths are removed.
Data read authority
Download signing and batched presence use the shared capability policy.
A live claim needs its data grant and sealed run/repository/ref binding.
A repository reader needs existing repository authority. Cache grants,
ordinary member tokens and machine roots cannot substitute for either.
Dependency-cache lookup requires data.read on a job credential or a
repository cache reader. An editor or owner with repository authority can
mint the repository reader through POST /api/v1/tokens with a
remote_cache data scope. Refusals name this requirement without reporting
whether the requested cache exists.
Each operation resolves current authority again and checks the read set
before reading metadata. Presence queries omit unreadable digests. Download
signing applies the team's daily byte cap and returns a 60-second URL for
one immutable object. The returned storage headers contain no controller bearer.
Credential discovery
GET /api/v1/auth/whoami uses the shared policy verifier. It returns the
committed subject ID as principal, the subject kind as kind, and grants
as scopes. team is present when the credential has an active team.
token_prefix names a capability. Sessions and verified local peers omit it.
The response excludes session IDs, ancestry, claim fences and billing facts.
An empty grant set returns "scopes":[].
GET /api/v1/services and GET /api/v1/data/capabilities use the same verifier
and require an active team. They announce configured services and data transfer
support. Availability is checked after authorization. An authorized caller gets
not_found when nothing is configured. Missing or dead credentials receive
unauthenticated regardless of service configuration. Bootstrap and eval
credentials cannot use these discovery routes or whoami.
All three routes return Cache-Control: no-store. Unsupported methods return
method_not_allowed with Allow: GET. Identity lookup requires an explicit
credential or a verified local peer. An address, query parameter or identity
header cannot supply authority.
The OIDC discovery document at /.well-known/openid-configuration and public
key set at /.well-known/jwks.json also use the policy router. Providers can
read them without a Sparkwing credential. Both retain their one-hour public
cache header. A controller without an issuer returns not_found, and
unsupported methods return method_not_allowed with Allow: GET.
Generator expansion authority
POST /api/v1/runs/{run}/jobs/{job}/expansion uses the shared capability
policy. It requires expansion.submit, the bound source job and generation,
and a live owner or eval lease. Eval of a controller-dispatched run also
requires that the controller still owns the run. The ended source claim cannot submit an expansion.
The body supplies source_generation and exactly one of body or failure.
A successful empty expansion uses "body":{"jobs":[]}. A failure uses the
shared inner error object with code and message. The controller derives
the actor, holder and lease generation from authorization. Success and failure
reach the same store transaction, which rechecks authority before acceptance
and owns replay. Unsupported methods return a JSON method_not_allowed error
with Allow: POST.
Capability inventory and rotation
All runner enrollment requires an independent account session. Shared runners
require current team administration for enrollment, token
rotation and revocation. Members enroll their own personal machines through
sparkwing runners add --personal --session-stdin with an independent account
session. After fetching the session CSRF proof, the CLI attempts logout on
enrollment success or failure. Use a dedicated session because logout ends it
for every client. The team setting members_can_add_machines defaults to true. When it
is false, personal enrollment requires administration too. A personal runner
records the membership epoch, grant and setting that authorized enrollment.
Every use checks them again, and turning the setting off ends members' personal
runners for good: turning it back on does not revive them. Removing and re-adding the member cannot revive
an old credential. Shared runners belong to the team and survive the
registrar's demotion or removal. Revoke a departing registrar's shared runners if they still control the machines or bearer credentials.
Member tokens default to and expire within 90 days. They retain their reported
expiry when the minting session reaches its idle or absolute timeout. Logout,
admin revocation, membership removal or demotion ends them immediately.
Rejoining or promotion cannot revive them. team.admin requires
--allow-team-admin (allow_team_admin in the API) and expires within 30 days.
Omitted expiry uses the applicable ceiling. A requested expiry beyond that
ceiling is refused, including during rotation. The refusal names the applicable
90-day or 30-day maximum and asks for a future expiry within it.
Invitation acceptance checks that the inviter still holds team administration,
and that the account owns the invited address: the last sign-in to verify an
address, through any account, makes that account its one owner. Unlinking the
account's last sign-in that verifies an address, or that sign-in's provider
reporting another address, stops the account seeing or accepting invitations
to it.
Teams can have any number of members, regardless of their funding or payment holds. Invitation creation and acceptance do not depend on member count.
GET /api/v1/team/settings returns members_can_add_machines,
claim_ranking, max_children_per_root and high_trust under runs.read. PUT /api/v1/team/settings requires
team.admin and names at least one of them. claim_ranking takes distinct
tiers from here, personal, github-actions, self-hosted and cloud, and
an empty list means first come. A self-hosted controller has no Cloud launcher
and refuses cloud with 400. ["default"] restores the default ranking. Changes and their actor, store time and old/new
values commit with an audit event.
Administrators can use
the console's team settings or sparkwing team settings set --profile work --members-can-add-machines false. Members can read it with team settings show.
GET /api/v1/tokens lists credentials within the authenticated team. Members
need runs.read and see their own credentials. A team admin can see the team's
credentials. GET /api/v1/tokens/{token} checks ownership of that prefix.
Deployment service and operator credentials use the corresponding collection
and detail routes under /api/v1/operator/tokens, requiring operator.tokens
or an operator console session.
The collection returns {tokens:[...]}. Each row carries prefix, team,
subject, audience, grants, created_by, issued_at,
metered, and optional expires_at, last_used_at and revoked_at. Times are RFC3339
timestamps. The response omits bearer secrets, digests, bindings and ancestry.
kind filters by account, machine, service or operator.
include_revoked=1 includes revoked rows. Duplicate or invalid filters fail.
POST /api/v1/tokens/{token}/rotate replaces an owned credential. Only the
owner rotates an account token or a personal machine: a team admin who rotates
another member's token gets 403 and the token keeps working, and revokes it
instead. A team admin rotates shared runners. Deployment
credentials use /api/v1/operator/tokens/{token}/rotate. The body accepts
grace_secs and optional expires_at. Grace defaults to 24 hours and accepts
zero through seven days. An omitted expiry keeps the original expiry. An
explicit expiry must pass the original parent's ceilings or the current root
origin policy. The transaction preserves the original
authority and ends the old credential after the grace window. Explicit
revocation ends that window at once.
The rotation response returns capability once, new public metadata,
old_prefix and the RFC3339 old_revoked_at. Do not replay a rotation after
losing its response. Browser mutations require session-bound CSRF proof.
A replacement retains the credential's owner, registration, scope and original parent. A predecessor can be rotated once. Failed rotation leaves the credential unchanged.
All inventory and rotation responses use Cache-Control: no-store and the
common error envelope. The controller uses its store manager by default. Embedded
controllers can supply WithCapabilityManager for their own persistence.
Browser session lifecycle
GET /api/v1/auth/session accepts a live account session from one session
cookie or one Authorization: Session <id> header. An Authorization header
takes precedence over cookies. Duplicate headers, duplicate session cookies,
unknown or ended sessions and HEAD are refused. The response includes the
current role, narrowed scopes, session-bound CSRF token and renewed expiry.
Renewal cannot extend the absolute session lifetime. A disabled account-session
license or failed proof cannot renew the session.
POST /api/v1/auth/logout takes no body. It ends only the presented session,
requires the session's CSRF header and cookie plus same-origin proof, and
clears the browser cookies on success. A body or query parameter cannot select
another session. Both endpoints return Cache-Control: no-store and the
canonical error envelope. Session-key and deletion failures return unavailable.
Other policy routes accept the same Session header or one session cookie and check the configured absolute lifetime without renewing. A malformed header cannot fall back to a cookie. Dashboard requests reach that proof boundary before session renewal, so a failed CSRF check cannot extend a live session.
The browser form posts to POST /logout. It requires a live session and a
csrf_token field matching both the CSRF cookie and the stored session, plus
same-origin proof. It deletes only that session, clears its browser cookies and
redirects to /login. It never renews before checking proof. Refusals use the
canonical error envelope. An unavailable session returns 401; a key or
deletion failure returns 503 and leaves the cookies in place.
A session renewal extends expiry by seven days, but never past 30 days from
creation. Ordinary policy reads and writes check that lifetime without
renewing. The renewal endpoint refuses an over-age session and retains its row
while member tokens depend on its revocation state.
Future-created sessions are refused before renewal and remain unchanged.
An embedder changes the cap with
controller.Server.WithSessionMaxLifetime.
Token sign-in ends the browser's prior session unless that session is an ancestor of the submitted token. The minting session is retained because revoking it would also end the token and the new session. Explicit logout of the minting session ends its tokens and descendant sessions. Logging out a descendant ends only that descendant's authority.
Token format
Controller capabilities start with swk_. Node-host capabilities start with
swn_. Each contains 256 random bits. The prefix selects the audience. It does
not identify a human, runner or administrator. The issuer records the subject,
team, exact grants, binding and lifetime.
The lookup key is SHA-256 of the complete reference token. Metadata carries a
16-character public prefix for selection and audit. Creation and rotation return the raw
bearer once. Metadata lists contain no bearer.
Credentials travel in memory, a private file read by the process, or the private protocol pipe. Runner and controller processes take a credentials directory. A job receives a single-use bootstrap file. Credentials never travel in process environment variables. The node-host hello sends its process-lifetime credential over the protocol pipe.
A connection profile belongs in the user's private machine file. A project file cannot choose a controller connection or capture its credential.
Scopes
Grants are exact names. There is no wildcard grant and no admin implication.
Each route declares its accepted credential classes, grants, binding and
liveness checks. A deployment credential cannot stand in for a team member or
an operator-console session.
The shared credential contract defines these recognized grant names:
| Grant |
|---|
runs.read |
runs.write |
runs.control |
runs.state |
nodes.claim |
triggers.claim |
claims.launch |
logs.read |
secrets.read |
approvals.write |
team.admin |
secrets.release |
source.read |
data.read |
data.write |
oidc.mint |
node.report |
node.result |
log.tail |
refs.read |
children.start |
trigger.host |
claim.exchange |
expansion.submit |
operator.limits |
operator.object_store |
operator.tokens |
operator.console |
A runner enrollment requests nodes.claim, triggers.claim and runs.state.
The issuer validates provisioning authority. Granted repository access, reserved
pools and metering are server facts. A runner's free labels and a proposed
repository name grant no authority. triggers.claim remains a permission name.
Its public work queue is /api/v1/runners/claim.
Operational deployment credentials carry operator.limits,
operator.object_store or operator.tokens for their selected operations.
Trust changes, grants, freezes and account deletion require an
operator-console session. Those powers do not follow from a deployment token.
Claim ownership
The controller derives the run, job, team, holder, generation, attempt, repository, ref, reserved pools and billing facts from the awarded row. Caller headers, labels and request fields cannot replace those facts. A mutation rechecks the fence in the same transaction as its write. The store clock judges whether the lease is live.
A live credential with insufficient authority receives 403. A dead credential
receives 401. Clients branch on error.code and use details.reason for the
specific refusal. Every boundary uses the same envelope:
{"error":{"code":"forbidden","message":"operation requires team.admin; ask a team owner for a credential with team.admin","details":{"reason":"missing_grant"}}}
Grant refusals name the required grants. Secret writes require team.admin,
which the owner role holds. OIDC minting requires oidc.mint on an awarded
or delegated job credential. A team token cannot substitute for that job
credential. These messages describe the operation's policy and include no
resource identifiers. Foreign-team resources retain the same response as
absent resources.
A root that can claim work does not gain arbitrary execution writes. Awarded credentials carry the operation grants and exact binding needed by their unit. Run-host ownership and job ownership remain separate. A member's management rights do not bypass a competing execution holder.
Repository authority
A missing repository scope (null) is unrestricted. An explicit empty scope
([]) grants no repository access. Delegation cannot widen that distinction.
Claim admission checks every ancestor against the same repository evaluator.
Canonical repository keys and enrollment path patterns remain separate types.
Patterns are checked against the work's persisted repository location.
Claim tokens
The issuer has three operations:
- Delegate narrows its parent's grants, binding, repositories, data scope and lifetime. It cannot widen authority.
- Award creates the fixed credential for work selected under the claim transaction. Only launcher authority may select work across teams.
- Exchange consumes a bootstrap once and recovers its original unit's credential. A concurrent or repeated exchange cannot issue a second one.
Operational units are trigger, plan, eval and job. A here award retains trigger ownership and follows the requesting capability or session. An ordinary claim follows its claimant root. A delegated job-process credential follows its parent and dies when its process exits. Revocation checks capability and session ancestry, including the nearest here award.
Same-uid pipeline code may still reach a runner's credential. A private pipe or filtered environment does not isolate hostile processes sharing an OS account. Cloud jobs run rootful Docker inside a user-namespaced pod on nodes bound to one team and run, never privileged. Those are accepted deployment boundaries, separate from the claim's API authority.
A pod bootstrap has only source-read and exchange authority. A bootstrap-specific policy class admits it only to source acquisition and exchange. It cannot read run metadata, events or approvals. Source initialization can use it before exchange. Exchange revokes it and retains its unit, binding and billing provenance. The job receives no runner root credential.
Sensitive reads require a live claim. A terminal job has only its bounded window to finish its own already-started log upload. Expansion submission uses its live expansion authority. Neither exception reopens general source, secret or data access.
Secret ownership
Management access and execution release are separate decisions. An authorized management read can return a value. Lists contain metadata only. Execution release requires the bound claim and the accepted plan's secret declarations. Secret values are encrypted in persistence and masked before rendering.
A secret restricted with --only-on names a granted reserved pool. The
controller checks that restriction at plan acceptance and again at release.
Here execution cannot use a pool-restricted secret. A self-asserted label that
spells the pool name grants nothing.
Anyone who can change and run a pipeline can make that pipeline use the secrets it is authorized to read. Assign that access as carefully as access to the secrets themselves. Registered secret values are refused in job, step, group and approval IDs because those identifiers cannot be masked after storage.
Metered runners
Metering follows server-recorded provenance. Runner labels, request fields and audience prefixes cannot select billing. Here work inherits the requester and keeps trigger-to-host billing. A normal award copies the registered claimant's billing facts. Operational trigger work maps to host billing. Job work maps to job billing. Plan and eval are separate billable units.
Reservation, charging, claim renewal and settlement happen in store transactions. An unstarted claim refunds its reservation. A job that starts consumes the minimum. A host has a separate minimum. Plan and eval have zero minimum and charge their actual compute. Customer-owned runner wait intervals are excluded.
Billing reconciliation must distinguish cancellation, lease loss and execution start. Repeating a finish or payment event cannot create another charge. The CLI's transport tests do not establish these ledger properties.
Credits
Credit availability and billing policy are team-scoped server facts. Claiming work cannot bypass an exhausted balance by changing labels or supplying a billing flag. An insufficient-credit refusal keeps its canonical structured error. Pricing and compute measurements come from the controller's observations.
Buying credits
POST /api/v1/team/billing/checkout and POST /api/v1/team/billing/card
require one nonempty
Idempotency-Key header. A committing transaction derives the team, account,
reservation and current billing limits. The checkout service receives that
reservation as its request key. A completed replay returns the recorded URL
without opening another page. Provider failure leaves the reservation available
for reconciliation. API failures use the canonical error envelope. Billing
refusal details include a reason. Cloud admissions follow billing standing.
The controller verifies Stripe signatures at POST /webhooks/stripe. Checkout
payments, failures and expiry carry the committed provider session identity.
Card setup and card payment outcomes, individual refund updates, early fraud
warnings and opened or closed disputes carry their provider identities and
verified status. The verifier reads a paid charge's 3-D Secure result from Stripe
and checks its identity, payment and test/live mode. It keeps no card details. Metadata grants no team,
credit, attempt or billing authority.
Settlement derives the team and ledger effect from committed billing records and deduplicates provider events in its transaction. Checkout settlement is implemented. Settlement of card, refund, warning and dispute events still needs the store integration. A provider retry cannot safely be treated as a new credit grant.
Test and live Stripe keys select configuration for the same implementation. Checkout, payment history, card operations, payment holds and reversals retain their account authorization. A signed payment event authorizes only its declared payment operation. It grants no general team or operator power.
GitHub Actions enrollment and runner metering
GitHub Actions enrollment uses GET and POST /api/v1/runners/github and
DELETE /api/v1/runners/github/{repository}. The dashboard uses these paths.
Each repository, workflow run and run attempt can exchange its GitHub identity
once. Run the Sparkwing runner in one job per attempt. Actions credentials cannot
be rotated. A new workflow attempt receives its own credential. The issuer
bounds its expiry to one hour from the store transaction clock and at most
one hour after the verified GitHub token expires.
Runner metering uses the console-only
POST /api/v1/operator/runners/{runner}/metered, selecting an enrolled root's
capability prefix.
Automated payment outcomes enter through the signed provider callback. Service credentials cannot submit provider outcomes or grant team credit.
Deployment bootstrap
Mount bootstrap-admin-token in the controller's --credentials-dir. Its value
is swk_ followed by the unpadded base64url encoding of 32 random bytes. Startup
passes it to the once-only bootstrap transaction before opening the listener.
The store keeps its digest and grants operator.limits, operator.object_store
and operator.tokens. It grants no team membership, runs.read, runs.write,
runner enrollment or operator-console authority.
A live seed can be reused while setup is unfinished. Issuing the first real
deployment administrator closes the gate, revokes the seed and its descendants,
in one transaction. Remove the mounted seed
after that handoff. A seed left mounted after its handoff only logs a warning
at startup, because its digest matches the one that bootstrapped the store; a
different seed still stops startup. An empty input provisions nothing. Private
routes require live credentials even on an empty store. --require-auth, which
the chart always passes, also refuses startup without a live deployment
administrator capability.
Create a team and its first team-admin credential with
sparkwing operator teams create --team support-test --display-name Support --profile deployment -o json.
The route is POST /api/v1/operator/teams and requires operator.tokens, or an
operator's direct browser session with its CSRF proof. It creates the team,
owner account, membership and first credential in one audited transaction.
The credential is returned once. Existing team administrators issue member
credentials through the team namespace and enroll machines with
sparkwing runners add --session-stdin --profile work --runner build-box --repos 'github.com/acme/*'.
For headless enrollment, an existing team administrator uses the in-pod
sparkwing-controller admin session command. See
workstation capacity for the full pipe.
profiles add probes /api/v1/health and authenticates through
/api/v1/auth/whoami, which every live credential may call. Passing this probe
does not grant access to team runs. Set SPARKWING_CONFIG as well as
SPARKWING_HOME when saving profiles in an isolated CLI home. See
Self-hosting for commands.
Working-tree submission uses POST /api/v1/data/source and
POST /api/v1/data/source/{upload}/commit under the submitter's runs.write
authority. Each step rechecks current team and repository authority. Signed
transfers carry only object-specific headers. Intake consumes one committed
upload matching its submitter, repository, both source revisions and digest.
It creates a source-only read edge for the awarded run; a member credential
does not gain arbitrary data downloads. Uploaded source has local provenance
and cannot assert GitHub App trust.
A --deployment=team controller refuses deployment-admin bearers. local and
cloud controllers accept them. Console sessions retain their separate account
gate.
Operator console
Operator-console sessions own trust changes, credit grants, freezes and account deletion. Refunds are made in the Stripe dashboard. They also read the standing queue, where team owners' requests for High standing wait. A grant naming the reviewed request approves it, and a decline names the request and records the reason its owners see. Deployment tokens manage operational limits, object-store settings, deployment service credentials and new-team onboarding through their separate routes. They cannot exchange themselves for console powers.
Break-glass
When console access is unavailable, the deployment operator can use
sparkwing-controller admin inside the controller pod. This is an in-pod
administrative path with its own authority and audit. A remote deployment
credential does not open it. It freezes and releases teams and grants credits:
kubectl exec -n NAMESPACE deploy/RELEASE-sparkwing-full-controller -- \
sparkwing-controller admin grant --operator ACCOUNT_ID --team qa-team --cents 500 --reason "QA fixture" --key qa-1
It acts on the store the serving controller holds. Once startup succeeds, the
controller records its --deployment, --store, --credentials-dir, where its
database connection landed (server, port, database and schema, or the SQLite
file) and when the store was created in
$SPARKWING_HOME/deployment.json. admin opens the existing store that record
names and checks that its own connection landed on the same target and store
before it acts. It never creates a store, and it refuses when there is no
record or when the store it reaches differs, for example after pg-url was
pointed at a restored copy.
Every grant requires --key. Reusing it reports the first grant instead of
granting again. To correct a mistaken free grant, use --reverses with its full
reference, for example --key qa-correction --reverses admin:qa-1. Keep --cents
positive. The command records a negative reversal bounded by the grant's remaining
amount. Paid credits use the payment refund path. Every command requires --operator ACCOUNT_ID with
your existing account ID and records that identity in the business event and
operator log. Pod access remains the authority for the command. Audit rows
exclude free-form reasons and store grant keys as digests.
Retained storage
The controller owns /data authorization, records and signed transfers. Reads
check the caller's permitted records and digests. An uploaded digest is not a
team-wide permission to attach it to an unrelated manifest. Every reference
checks integrity, existence and authority.
Signed downloads expire after 60 seconds and verify the digest at EOF. Revocation does not recall a URL already issued. Retention uses record reachability and the declared collection window. Missing or corrupt bytes fail as an error or cache miss. A separate cache service or cache credential is not part of this model.
Runner classes
Runner identity, reserved pools, metering and capacity remain separate fields. The controller returns granted class and location alongside free labels, repository fences, active jobs and measured headroom. Missing headroom means there is no observation. It is not a zero-capacity sample.
Runner and queue views read one tenant-scoped store snapshot. Display names and
holder strings grant no identity. Distinct credentials with the same name remain
separate observations. Accepted renewals refresh liveness without offering a free
slot or refreshing headroom. The controller resolves the inventory reader from
the request's tenant handle. A missing adapter returns policy_missing.
A here runner is reserved for its run. Pool access comes from granted reservations and verified repository authority. The first eligible matching claim wins under the same matcher used for queued and named work.
Compute guards
Operator values are ceilings. A pipeline value above a ceiling is refused with both values and the setting's source. It is never silently clamped. Execution, queue wait and approval wait are separate bounds. Local runs gain no execution deadline. A queued job with a bounded owner also ends at the owner's deadline. Approval zero remains indefinite wherever the owner can wait.
Deployment-wide counters live in the store. One controller holds a store at a time, which still does not justify process-local financial or authorization state.
Teams and sign-in
A signed-in account's active team and membership determine its authority. Requests cannot select another team merely by naming it. A demotion or removal changes the membership generation checked by subsequent authorization. Team-admin authority remains confined to that team.
Team tokens
Member credentials act for an account in its team. Runner credentials act for a registered machine. Issuance checks the account or registration and cannot exceed its authority. Principal text is a display label. It cannot establish membership, machine identity, reserved pools or metering.
A browser session opened with an account capability retains that capability as its authority parent. Credential issuance through that session delegates from its existing ancestry and inherits the shortest remaining lifetime. Independent member roots require a direct account sign-in session. Changing a token into a browser cookie does not establish one. Account credentials carrying a data scope cannot sign in because browser sessions cannot preserve that scope.
Machine roots require a direct account sign-in session or deployment authority.
Their grants are limited to nodes.claim and triggers.claim. The store checks
these rules in the transaction that issues the root.
Linked sign-ins
Google and GitHub identities can be linked from an authenticated account. A matching email address alone must not merge two accounts. Linking and unlinking identities require the account's authorized session. A provider's verified identity and the application's team membership remain separate facts.
Linking starts at POST /auth/{provider}/link with one csrf_token form field,
the matching CSRF and session cookies, and same-origin proof. It requires the
account's direct provider session from the last ten minutes. An account-token
session cannot add sign-in methods. The flow binds its account, session,
provider and PKCE verifier, expires after ten minutes, and can complete once.
The existing limit is ten link attempts per account per minute.
GET /auth/{provider}/link/complete uses that same session and flow cookie.
It consumes the flow before provider access and attaches only the verified
provider identity. Neither endpoint renews the session or accepts an
Authorization header. A refused link returns to /account/sign-ins?refused=<reason>,
and the page says what happened and what to do.
Controller construction binds the store operator as the identity-link writer
for committing session and nonce checks. A missing writer returns policy_missing.
A nonlocal dashboard requires a live session for its private pages. Its login page remains available to
signed-out browsers. Dashboard.Local selects local pages without browser login.
Sign-up gate
The deployment's sign-up settings decide whether a new account proceeds or waits for admission. The operator controls those settings. A team role or a supplied email does not grant an exception. Authentication and tenant isolation still apply after admission.
Unauthenticated endpoints
Public discovery, sign-in, provider callbacks, webhooks and signed data URLs use their declared proof requirements. A route being reachable without a bearer is not an authorization bypass. A signed URL, verified webhook or provider proof is valid only for its own operation. Every registered route declares its proof.
Dashboard authorization
Browser sessions use account and team authority. State-changing browser requests also require the session's CSRF protection. A session cookie is not a deployment service credential. Operator-console powers require the console's session checks. Membership and session ancestry remain live authorization inputs.
Licensed team and Cloud controllers use __Host-sw_session and
__Host-sw_csrf, including over HTTP loopback port-forwards. Session enrollment
clients send __Host-sw_csrf with the proof returned by /api/v1/auth/session
for enrollment and logout. The URL scheme does not select the cookie name.
The Secure cookie localhost exception
allows loopback clients to keep the prefix. Licensed controllers refuse
--insecure-cookies.
Google and GitHub sign-in
Provider sign-in verifies the callback state, proof-key flow and provider token
or identity response. It checks the configured redirect origin and the provider's
identity and verification claims. A refused sign-in returns to
/login?refused=<reason> (keeping next), and the sign-in page says what
happened and what to do. The reason is one of a fixed set that names no
account or provider detail: expired, cancelled, email_unverified,
account_exists, no_team, reauth_required, direct_sign_in_required,
identity_linked_elsewhere, identity_already_linked,
provider_already_linked, rate_limited, unavailable or failed. A refused
invitation acceptance carries invitation_not_found or invitation_closed
in error.details.reason. The store finds an invitation only through
an address the account owns, so a missing invitation and one sent elsewhere get
the same invitation_not_found answer; expiry and use are reported only for the
caller's own invitation. The refusal asks the user to sign in with the account
whose verified email matches the invitation, or ask a team owner for a new
invitation. Linking a provider does not grant repository
trust. GitHub App intake separately verifies its webhook and clones its exact
SHA. A subsequent branch-head lookup cannot replace that proof.
Signing in
Sign in on the controller's configured dashboard origin, select the team you belong to, and create the credential appropriate for the CLI or machine. Controller enrollment checks account and machine authority. Possessing a repository URL or typing a team name supplies neither.
CLI
See Credential management for the implemented client surface.
Credential lookup
Capability lookup uses SHA-256 of the complete random token. Password hashing remains a separate account concern. Every authenticated request reads current authority, ancestry, membership and expiry in one store snapshot. The controller has no bearer verification cache.
How long revocation takes to bite
Revocation advances the affected authority generation in the same transaction. Every descendant depends on live ancestry. Each request reads those facts again.
Revocation cannot recall a signed URL or an external GitHub or OIDC credential already issued. Those retain their separate expirations. Stopping a runner is not proof that an external credential was recalled.
A normal database restore keeps sessions, API tokens, runner and machine
credentials, and API/local schedule consent valid. Recover revocations, member
removals and demotions since the backup from the controller's audit log stream,
kept outside the database. Its revocation lines identify token prefixes,
accounts, machines or GitHub repository IDs and the team. Session logout and
replacement identify the ended session by session_digest, revoked_account_id
and revoked_team, including when the browser signs in as another account.
Controller logs fingerprint credential shapes in messages, attributes and errors.
Public token prefixes remain readable. Follow the operator runbook kept outside this repository.
For a stolen database, leaked keys or another security incident,
sparkwing-controller restore prepare --sign-out-everyone rotates the external
credential epoch. All earlier credentials and API/local schedule consent then
need replacement. See Backup and restore.
When a runner's token dies
A dead root prevents new claims and invalidates descendant authority. Work that cannot renew its claim stops. Replace the machine's private credential and restart its supervised runner.
The final machine configuration uses runner, credentials_dir, repos and
labels. Native enrollment and service refresh must use that schema before
they can establish recovery behavior. Client fixtures do not establish config
hot reload, hourly parking or automatic resumption.
Sealed execution trust
Intake seals the proven repository and verified default-branch fact in the
request. Claim issuance seals the host kind and cache scope in award authority.
Secret release, OIDC, source credentials, cache writes, latest-output reads and
repository-scoped runners read those facts. A submitted repository name never
establishes proof. Personal machines and here hosts cannot claim proven runs.
Job credentials additionally require the GitHub compare result sealed at intake
for both the source and execution commits when they differ. Pull refs compare
against the same-repository PR head branch. Merged PR runs compare against their
base branch. An asserted run can still have repository proof: a branch-pinned
source: selected through the App may receive a repository credential when both
commits pass the intake compare check. The asserted trust class alone does not
refuse that credential. Exact-revision children inherit their parent's seal.
Changed-revision and cross-repository children inherit no repository proof.
A changed-revision child in the same repository may receive a fetch-only GitHub
App token after intake compares both its source and execution commits against
that repository's branch or tag. The compare must return identical or ahead.
Unreachable commits, unknown compare results and cross-repository children receive
no token. Push the commit to a branch or tag in the parent's repository and start
a new run, or upload the source.
Fetch-only permission is sealed separately from repository proof. The child stays
asserted and receives no stored Git credentials, extra repositories, team or
pipeline secrets, or OIDC tokens. The App token requests only contents: read
for that repository's numeric ID. Issuance and its committed audit require the
current source-acquisition attempt's live authority, lease and generation.
Fetch-only tokens keep GitHub's provider lifetime of at most one hour, including
after the attempt ends. Sparkwing does not recall issued tokens. Public
repositories can still be fetched anonymously. Shared-secret eligibility does
not grant source credentials.
Verified cache fallbacks use the sealed repository proof. Unproven
runs read and write their own data scope. Unknown claimant shapes use the
personal host boundary and cannot claim repository-proven work.
GitHub compare responses include file patches even when commit pagination is
limited. Large comparisons retain verification when their status proves
reachability. If the intake compare cannot reach GitHub, times out, receives a
5xx or supplies unreadable JSON or an unknown status, webhook, native trigger
and schedule intake admit the run as asserted without repository proof. It
receives no job credentials, proven-repository secrets or
proven OIDC claims. Run status explains why verification failed. Definite
negative compare answers, including diverged, behind and 404, retain their
existing behavior.
Latest pipeline references select verified default-branch work unless the
pipeline declares sw.FromAnyRun(). A reference declared with
sw.FromProvenRunAtCommit() reads only a run at the reader's own sealed commit
whose repository was proven (an App delivery, an Actions push, a schedule, or
code the controller fetched from GitHub) as the reader's proven repository, and
whose sealed options selected every job and step: no --sw-only,
--sw-start-at or --sw-stop-at, no dry run, no debug and no
--sw-pause-before, --sw-pause-after or --sw-pause-on-failure, because a
paused job can be attached to and edited. The run must also have
sealed no environment variables, because a submitted GOFLAGS or similar can
change what its jobs judged, and every job that did not skip must have run on a
trusted host (see security). The newest such run in which the referenced job
succeeded answers, so a newer run that skipped the job does not hide an older
one that ran it. An asserted run, such as a
plain local run, proves no repository, so it neither reads nor satisfies such
a reference.