# 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): ```toml [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 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): ```toml 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. The web client stores its credentials in `localStorage` and attaches them the same way. Because an anonymous browser silently connects as the fallback role, it would otherwise never learn a login is possible, so the `Init` response (reachable anonymously) carries an `auth_enabled` flag: on first connect with no stored credentials against an auth-enabled server, the client raises a login dialog. That dialog is **dismissible** — "continue as guest" keeps the fallback role — so the zero-typing browse path is preserved. When the server denies anonymous access outright (all roles guarded → `UNAUTHENTICATED`), the same dialog appears without the guest option, because credentials are then the only way in. ## Structure ```d2 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.