crabidy/architecture/roles-auth.md

7.5 KiB

Roles and rights (basic-auth authorization)

crabidy listens on 0.0.0.0:50051: anyone on the network can control playback and — worse — rename or delete library stores. The owner wants to hand out limited remotes: a co-host who may run the queue but not touch the library, and guests who may only add tracks.

Problem statement

Three roles, credentialed by password hashes in the server config:

  • owner — the normal user; everything.
  • queue-owner — anything on the queue (and playback), but no library writes: no w, no W.
  • queue-appender — may only add tracks to the end of the queue; no removal, no reordering, no queue settings.

Assumptions (confirmed by the request, or decided here)

  • Transport stays plain HTTP/2 gRPC. Basic auth over cleartext is acceptable on a trusted home network; anything else (Internet exposure) needs TLS termination in front (reverse proxy, VPN) and is out of scope. The README says so.
  • Anonymous callers inherit the highest unguarded role. A request with no credentials is not rejected outright; it is granted the most privileged role whose password is not set. So the config guards from the top down and each password lowers what anonymous users can do:
    • nothing guarded → anonymous is owner (today's open server);
    • owner guarded → anonymous is queue-owner;
    • owner + queue_owner guarded → anonymous is queue-appender;
    • all three guarded → anonymous can do nothing (UNAUTHENTICATED). A credential still elevates a caller to its role; the anonymous role is only the floor. A present-but-wrong credential is denied, never silently downgraded to the anonymous role.
  • Guarding order is enforced. Because anonymous callers get the highest unguarded role, guarding a lower role while a higher one is open is meaningless — the anonymous role would still outrank it. The valid guarded sets are therefore prefixes of [owner, queue_owner, queue_appender]. A config that sets queue_owner without owner (or queue_appender without queue_owner) is broken and aborts startup, fail-closed; crabidy-server guard likewise refuses to write such a config.
  • One password per role, not per person. The Basic-auth username selects the role (owner, queue-owner, queue-appender), the password is verified against that role's hash.

Options considered

Where to enforce

  1. Per-handler checks inside RpcService (tonic interceptor authenticates, each of the ~25 handlers calls require(role)?). Idiomatic tonic, but fail-open: a future RPC that forgets the line is unprotected.
  2. One tower layer in front of the tonic service, mapping the gRPC method path to a minimum role, default-deny (unknown method ⇒ owner only). Fail-closed, single enforcement point, zero handler churn; costs a small amount of manual HTTP plumbing for the deny response (gRPC trailers-only response).

Decision: option 2. Authorization is a security boundary; new RPCs must start locked. A unit test pins the method table against the proto service definition so an unmapped addition fails loudly.

Hash scheme

PHC-format strings verified with the pure-Rust argon2 crate (RustCrypto). Argon2id is the default the crate generates; any PHC variant the crate parses is accepted. bcrypt/scrypt support is not worth a second dependency. To keep users out of hash-tooling misery, crabidy-server hash-password reads a password on stdin and prints the PHC string to paste into the config.

Verification cost

Argon2 verification is deliberately slow (tens of ms); per-keypress RPCs cannot re-verify. Successful credentials are cached in memory (authorization header value → role). Only successful verifications are cached, so the cache is bounded by the number of valid credentials (≤ 3); failures pay the full argon2 cost every time, which doubles as throttling.

The rights matrix

Minimum role per RPC; higher roles include lower ones (owner ⊃ queue-owner ⊃ queue-appender):

  • queue-appender (and up): Init, GetLibraryNode, GetUpdateStream (reads), Append (the one queue write), and CreateLibraryNode — creatable nodes are exactly the search terms, and guests must be able to search for what they append. (Search terms do persist in the owner's provider config; accepted — they are the mechanism of finding tracks, not library data.)
  • queue-owner (and up): every other queue and playback verb — Queue, Replace, Remove, Insert, ClearQueue, SetCurrent, ToggleShuffle, ToggleRepeat, TogglePlay, Stop, Next, Prev, RestartTrack, ChangeVolume, ToggleMute.
  • owner only: the library writes — CaptureLibraryNode (w/W), SaveQueue (writes /queues), RenameLibraryNode, DeleteLibraryNode (these two also cover search terms; a queue-owner can create terms but not rename/delete them — the server cannot cheaply tell a term from a bookmark store at this layer, so renames/deletes stay owner-only, fail-closed).
  • unknown / future methods: owner only.

A caller whose role (anonymous or credentialed) is below the method's minimum gets PERMISSION_DENIED; a present-but-wrong credential, and an anonymous request to a fully-locked server, get UNAUTHENTICATED. Credentials are never logged (hard rule: secrets redacted).

Configuration

~/.config/crabidy/crabidy-server.toml (new, read by the server — both standalone and inside cbd; absent file = auth off):

[auth]
# One PHC hash per role. Guard from the top down: setting a role's hash
# lowers what an anonymous (no-credential) caller may do to the next
# role below. Omitting a role leaves it open, so anonymous callers get
# the highest omitted role. Setting a lower role without the higher one
# (e.g. queue_owner without owner) is rejected at startup.
# Generate with: crabidy-server guard <role>
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..."

~/.config/crabidy/cbd-tui.toml (client side, also as CLI flags):

address = "http://127.0.0.1:50051"
# Sent as HTTP basic auth when set. `user` is the role name.
user = "queue-owner"
password = "plaintext"

The client config holds a plaintext password (client credentials always are); the README tells users to keep the file private. The TUI attaches authorization: Basic … to every request through a tonic interceptor; without configured credentials it sends no header, which keeps today's zero-config local setup working against an open server.

Structure

direction: right
tui: cbd-tui {
  cfg: "cbd-tui.toml user/password"
  interceptor: "auth interceptor\n(adds Basic header)"
  cfg -> interceptor
}
server: crabidy-server {
  layer: "AuthLayer (tower)\nheader → role → method check"
  rpc: RpcService
  cache: "verified-creds cache"
  cfg: "crabidy-server.toml [auth] hashes"
  cfg -> layer
  layer -> cache: hit = skip argon2
  layer -> rpc: authorized
}
tui.interceptor -> server.layer: every RPC
server.layer -> tui: "UNAUTHENTICATED /\nPERMISSION_DENIED"

Risks and open questions

  • Denied-action UX: the TUI currently logs RPC errors; an appender pressing a forbidden key sees a silent no-op (the log has the denial). A status-line hint is deferred until the flow has been felt in practice.
  • Cleartext transport: documented; TLS stays out of scope.
  • The update stream is authorized once at subscription; the stream itself only carries state every role may read.