pulse
Design decisions (ADRs)

ADR-0012: Username/password authentication

Context

Until now every RPC was open: anyone who could reach the port could submit, cancel, pause dispatch, or stream jobs. Acceptable on localhost; not for a server reachable beyond it — and the operator CLI needed a credential story before pointing at anything remote. The requirement: keep strangers out with the least machinery that is actually enforced, support more than one caller identity (an operator and a worker fleet are different things), and change nothing for local development.

Decision

Multi-user username/password, NATS-style: users configured on the server, Basic credentials on every RPC, enforced by interceptors, off by default.

  • Server (internal/transport/grpc/auth.go): PULSE_AUTH_USERS="alice:s3cret,worker:hunter2" configures the accepted users. When set, unary and stream interceptors require authorization: Basic base64(user:pass) on every RPC — missing, malformed, unknown-user, or wrong-password → Unauthenticated before any handler runs. A stored value with bcrypt's $2 prefix is verified as a hash (minted with pulse passwd), so server config need never hold plaintext; anything else compares as plaintext, constant-time, with a dummy compare for unknown usernames so their absence is not distinguishable by timing. bcrypt successes are cached per user as a SHA-256 digest of the presented password — steady-state RPCs pay a hash and a constant-time compare, not bcrypt's deliberate tens of milliseconds. Unset → the interceptors are not installed and the server behaves exactly as before.
  • SDK: pulse.WithUserPass(user, pass) attaches per-RPC credentials. The credential does not itself demand transport security — local dev stays insecure by default — so pairing with pulse.WithTLS in production is the caller's explicit, documented responsibility.
  • CLI: credentials are saved once via pulse save creds --url … --username … --password … to ~/.config/pulse/config.json, written 0600, password redacted in pulse config view; PULSE_USER/PULSE_PASSWORD override.

Multiple users means operators and worker fleets hold separate credentials that can be rotated independently — authentication distinguishes identities even though v1 authorizes them all equally.

Alternatives considered

Single static bearer token

Fewer moving parts, but one shared secret for operators and every worker: rotating it is a fleet-wide event, and revoking one caller means revoking all. The user list costs a map and a parse.

No auth in v1

Ships faster, but the CLI's remote-context feature would invite pointing an open admin surface at real infrastructure. Rejected: the credential path is small.

TLS client certificates (mTLS)

Stronger (mutual identity, no shared secrets), but pushes certificate issuance and rotation onto every SDK consumer and demo. Deferred — the interceptor seam is where a cert- or OIDC-based check would slot in without touching handlers.

Per-user authorization (worker cannot pause dispatch)

The natural next step now that identities exist — but it is an authorization model, not an authentication mechanism. Deferred until there is a consumer; the username extracted here is what a role check would key on.

Consequences

  • A reachable server requires credentials on every RPC with one environment variable; local development is untouched.
  • Passwords ride the wire base64-encoded, not encrypted — over plaintext connections they are visible. TLS is the documented pairing, and the server terminates it natively (PULSE_TLS_CERT/PULSE_TLS_KEY; both-or-neither, validated at startup).
  • All identities are equally privileged in v1 (authentication without authorization) — the username is available at the interceptor for the future role check.
  • All enforcement lives in one interceptor file; handlers remain credential-unaware, and the bufconn suite pins the contract (missing/malformed/unknown/wrong/right, unary and stream — TestWire_Auth).
  • The CLI config carries secrets and is written 0600, redacted on display.

On this page