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 requireauthorization: Basic base64(user:pass)on every RPC — missing, malformed, unknown-user, or wrong-password →Unauthenticatedbefore any handler runs. A stored value with bcrypt's$2prefix is verified as a hash (minted withpulse 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 withpulse.WithTLSin 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, written0600, password redacted inpulse config view;PULSE_USER/PULSE_PASSWORDoverride.
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.