Skip to main content

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:

  1. The CLI requests a device code from swamp-club
  2. swamp-club returns a verification URL and a user code
  3. The CLI displays both and polls for approval
  4. The user opens the URL in a browser, signs in, and enters the code
  5. On approval, swamp-club issues an access token
  6. 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.