SWAMP SERVE
swamp serve turns a Swamp repository into a remote API. This page explains the
architectural decisions behind the server, the security constraints it enforces,
and how admin access is managed.
Architecture
A swamp serve instance is a single process that holds a Swamp repository and
exposes it over a WebSocket API. Clients connect to run model methods, execute
workflows, query data, and manage access. Workers connect to receive dispatched
steps for remote execution.
The server is the single source of truth. It owns the datastore, resolves vault secrets, stores model definitions, and manages the policy snapshot. Clients and workers are stateless — they issue requests and receive responses but hold no durable state.
This is a deliberate centralization. A Swamp repository is a coherent unit: definitions reference each other, workflows chain model outputs, and vault secrets are scoped to the repo. Distributing this state would introduce consistency problems that the current model avoids by not having them.
Hard refusals
swamp serve refuses to start in certain configurations. These are not warnings
— they are hard failures that prevent the server from binding.
Off-loopback without TLS and authentication. If --host is set to anything
other than 127.0.0.1 or ::1, the server requires both --cert-file /
--key-file and --auth-mode other than none. This is not configurable.
The reasoning is straightforward: swamp serve is a control plane. It accepts
arbitrary model method execution, workflow runs, and vault reads. An
unauthenticated, unencrypted control plane on the network is arbitrary remote
execution — anyone who can reach the port can run any command. The hard refusal
prevents this misconfiguration from reaching production.
On loopback, the risk model is different. The server is only reachable from the local machine, where the user already has access to the repository. TLS and authentication add friction without meaningful security benefit in this context.
That said, --auth-mode none is deprecated. The loopback exception made sense
as a convenience for early adoption, but in practice it creates a gap between
local and production setups. Workflows, grants, and token-scoped permissions
behave differently under none than under token or oauth — issues that
surface only at deployment time. Requiring authentication everywhere, including
loopback, closes that gap and makes local development a reliable predictor of
production behavior.
Admin materialization
The --admins flag lists principal IDs that receive full admin access. On every
server start, the server materializes a grant of admin on access:* for each
listed principal. This grant implies all actions on all resources.
Materialization is reconciled on every boot. If a principal is removed from
--admins, its materialized grant is removed on the next start. If a new
principal is added, it receives the grant on the next start. The config is the
source of truth — not the grant store.
This design serves two purposes.
Bootstrap. When a server starts for the first time with --auth-mode token,
someone needs to be able to mint the first token and create the first grants.
The --admins flag provides that bootstrap identity without requiring a
separate setup step.
Recovery. If an admin accidentally revokes their own access or corrupts the
grant store, restarting the server with the correct --admins flag restores
admin access. The config always wins.
Policy snapshot and reload
Grants and group memberships are compiled into a policy snapshot — a precomputed
structure that the server evaluates on every access check. The snapshot is
rebuilt on swamp access reload or automatically on every change when
--grant-reload auto is active.
The default manual reload mode is a safety mechanism. Grant changes are staged
until an admin deliberately applies them. A bad grant — an overly broad allow, a
deny that locks out the admin — does not take effect until reload. This gives
the admin a chance to review and revoke before applying.
The auto mode removes this safety net in exchange for convenience. Teams that
trust their grant-creation workflow and want immediate effect use auto. Teams
that prefer a review step use manual.
Deny-wins evaluation
When both allow and deny grants match a request, deny wins. This is not configurable.
The alternative — most-specific-wins or last-writer-wins — creates reasoning problems at scale. With deny-wins, a compliance team can create a deny grant that blocks access to sensitive resources, and no amount of allow grants from other teams can override it. The deny is a hard boundary.
This makes the system predictable: if you see a deny grant, you know it applies regardless of what else exists. See the authorization reference for the full evaluation model.
OAuth and collective-based admission
Token authentication requires admins to mint and distribute credentials for every user. OAuth replaces this with self-service login — users authenticate themselves through swamp-club, and the server decides admission based on collective memberships. No tokens to distribute, no credentials to rotate per user.
The device grant flow
swamp serve uses the OAuth 2.0 Device Authorization Grant (RFC 8628) for
authentication. The device grant exists for exactly this scenario: a CLI tool
running on one machine, with the user approving access in a browser that may be
on a different machine.
The flow works like this:
- The CLI requests a device code from swamp-club
- swamp-club returns a verification URL and a user code
- The CLI displays both and polls for approval
- The user opens the URL in a browser, signs in, and enters the code
- On approval, swamp-club issues an access token
- The CLI receives the token and stores it locally
This is the same pattern used by GitHub CLI and other tools that need browser authentication from a terminal. The security boundary is in the browser approval step — the user sees exactly what they are authorizing and can refuse.
The bootstrap client
On first start with --auth-mode oauth, the server registers itself as an OAuth
client with swamp-club using the same device grant flow. A well-known public
client ID baked into the binary initiates this registration — the same pattern
GitHub CLI uses. Client ID secrecy is not the security mechanism; the browser
approval step is. After registration, the server stores its client credentials
in the vault and subsequent starts skip the flow entirely.
Collective-based admission
Collectives in swamp-club serve as the admission boundary. The admin declares
which collectives grant access, and swamp-club membership controls who gets in.
There is no user management on the swamp serve side — the admin manages team
membership in swamp-club, and the server enforces it.
This inverts the traditional approach where the server maintains its own user
list. Instead, identity and group membership live in swamp-club, and
swamp serve is a policy consumer. Adding or removing a team member is a
swamp-club operation, not a server operation.
Collectives on the token
A user's collective memberships are snapshotted on the server token at login
time. The server uses these memberships for idp-group: grant matching —
connecting swamp-club collectives to swamp serve authorization grants without
requiring the server to call swamp-club on every request.
The tradeoff is staleness. If a user is removed from a collective in swamp-club,
their server token still carries the old membership until the next group refresh
cycle updates it. The --group-refresh-interval (default: 4h) controls how
often the server re-fetches memberships from the OAuth provider, bounding the
staleness window. Token expiry provides a secondary refresh — when the token
expires, re-authentication captures the latest memberships from scratch.
Collectives vs groups
Collectives and IdP groups serve different purposes in the swamp serve
authorization model, even though both originate from swamp-club.
Collectives are the admission gate. The --allowed-collectives flag
controls who can connect to the server at all. A user must be a member of at
least one allowed collective to authenticate. This is a coarse, binary decision:
you are in or you are out.
Groups are for grant matching. Once admitted, a user's fine-grained
permissions come from grants. Grants can target idp-group:<collective-slug>
subjects, which match users whose OAuth token includes that group. This connects
swamp-club collective membership to swamp serve authorization without the
server maintaining its own group directory.
The separation is deliberate. Admission is a policy the server admin controls (which teams can access this server). Authorization is a policy the access control system controls (what each team can do). Conflating the two — using collective membership as both the admission check and the permission boundary — would mean every collective member gets the same access, which breaks down as soon as teams need different permission levels on the same server.
IdP group refresh and deprovisioning
When a user authenticates via OAuth, the server snapshots their group
memberships from the provider's userinfo response. These memberships inform
idp-group: grant matching. The question is: what happens when memberships
change after login?
The server runs a background refresh loop controlled by
--group-refresh-interval (default: 4h). On each cycle, it re-fetches
userinfo for every active session and updates the policy snapshot with current
memberships. This bounds the staleness window — a user removed from a collective
in swamp-club loses matching grants within one refresh interval, not at their
next login.
The refresh loop distinguishes two failure modes:
Transient errors are fail-open. If the provider is temporarily unreachable (network timeout, 5xx, DNS failure), the server retains the last known group memberships and retries on the next cycle. The reasoning: a brief provider outage should not revoke access for every authenticated user. The staleness window extends by one cycle, which is acceptable because the memberships were valid at the last successful refresh.
401 Unauthorized is a hard revocation. If the provider responds with 401,
the user's session is no longer valid at the identity layer — their token has
been revoked, their account disabled, or their consent withdrawn. The server
invalidates the session immediately and all idp-group: grants for that user
stop matching. This is not a transient condition, and retaining stale
memberships would mean the server continues authorizing a user that the identity
provider has explicitly rejected.
This asymmetry — fail-open on transient errors, hard-close on 401 — matches the
trust relationship between swamp serve and the OAuth provider. The provider is
authoritative on identity. When it says "this user is no longer valid," the
server honours that immediately. When the provider is just unreachable, the
server falls back to its last known state rather than making an irreversible
decision based on a network hiccup.
See Serve Flags for the
--group-refresh-interval flag reference, and
Authorization — IdP group refresh
for the behavioral specification.
Two authentication targets
swamp auth login and swamp auth server-login --server authenticate to
different systems. swamp auth login authenticates to swamp-club — the
platform. swamp auth server-login --server authenticates to a specific
swamp serve instance — a team's server. They are different trust relationships
stored in different credential files.
A user who is logged in to swamp-club is not automatically authenticated to any
swamp serve instance. Each server requires its own login, which issues its own
token with its own expiry and collective snapshot.
Token transports on WebSocket
swamp serve accepts tokens on the WebSocket upgrade request via three
transports: the Authorization: Bearer header, the Sec-WebSocket-Protocol
subprotocol, and the ?token= query parameter. The reason for three is that
different client environments have different constraints on what they can attach
to a WebSocket upgrade.
The Authorization: Bearer header is the standard HTTP mechanism. CLI clients,
server-side code, and tools like curl can set it directly. It keeps the token
out of URLs entirely — no query string, no path fragment — so it does not appear
in proxy access logs, browser history, or Referer headers that the server or
intermediaries might emit.
The browser WebSocket API cannot set custom headers. new WebSocket(url) does
not accept an Authorization header — the browser attaches only the URL and an
optional list of subprotocols. The Sec-WebSocket-Protocol transport exists for
this case: the client passes bearer.<name>.<secret> as a subprotocol value,
and the server extracts the token from it. This is a well-known workaround used
by other WebSocket-authenticated systems.
The query parameter (?token=<name>.<secret>) is the simplest transport — it
works everywhere — but it embeds the token in the URL. URLs are logged by
proxies, cached in browser history, and leaked via Referer headers on
subsequent navigations. For environments where neither headers nor subprotocols
are available, the query parameter still works, but the other two transports
avoid this exposure.
When multiple transports are present on the same upgrade request, the server picks the first match in priority order: Authorization header, then Sec-WebSocket-Protocol, then query parameter. The priority favors the transport that leaks the least.
See Serve Flags — WebSocket token transports for the protocol-level reference, and Connect with a token for connection examples.
Webhooks and event-driven workflows
swamp serve --webhook registers an HTTP endpoint that receives external
events, verifies their signatures, and triggers workflow runs. This turns Swamp
from a pull-based system — where cron schedules and manual runs drive automation
— into a reactive one that responds to external events as they happen.
The workflow triggered by a webhook receives the incoming payload through
webhook.* CEL variables: webhook.body for the parsed payload,
webhook.headers for request headers, and webhook.route for the matched
endpoint path. These variables are available in every step of the workflow,
allowing steps to branch on event type, extract resource identifiers, or pass
payload fields to model methods. See the
workflow reference for trigger syntax.
Provider schemes and signature verification
Each SaaS platform signs webhooks differently. GitHub uses HMAC-SHA256 over the
raw body. Stripe prepends a timestamp to prevent replay attacks. Linear, Slack,
and others each have their own algorithm. Rather than forcing a single
verification model, Swamp supports named provider schemes — github, stripe,
linear, slack, and generic — each implementing the correct algorithm for
its platform.
The generic scheme exists for platforms not explicitly supported. It accepts a
configurable header name and optional prefix, covering most HMAC-based
verification patterns without requiring a new scheme per provider. The
webhooks reference documents the full
list of schemes and their configuration.
Secret source indirection
Webhook secrets can be provided as literal strings, @env=VAR (read from an
environment variable), or @file=/path (read from a file). The env and file
forms exist because process arguments are visible in ps output — passing a
secret as a literal --webhook argument leaks it to anyone who can list
processes on the machine. Environment variables and file paths are opaque in
process listings.
Webhooks vs cron triggers
Cron triggers (trigger.schedule in workflow YAML) run workflows on a time
schedule. Webhooks run workflows in response to external events. The distinction
is pull vs push: cron is for periodic sweeps (check every hour for drift),
webhooks are for immediate reaction (deploy when code is pushed).
In practice, many workflows combine both. A webhook provides the fast path — respond to events within seconds — while a cron schedule acts as a fallback sweep to catch events that were missed due to delivery failures or downtime. Neither replaces the other; they complement.
Related
- Set Up OAuth Authentication — practical setup for OAuth mode
- Set Up Token Authentication — practical setup for token mode
- Webhooks — webhook endpoint configuration and provider schemes
- swamp serve how-to guides — practical setup and operations
- Authorization — the grant model reference
- Serve Flags — full flag and environment variable reference
- Remote Execution — how workers connect and execute steps