8.2 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, noW. - 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);
ownerguarded → anonymous is queue-owner;owner+queue_ownerguarded → 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 setsqueue_ownerwithoutowner(orqueue_appenderwithoutqueue_owner) is broken and aborts startup, fail-closed;crabidy-server guardlikewise 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
- Per-handler checks inside
RpcService(tonic interceptor authenticates, each of the ~25 handlers callsrequire(role)?). Idiomatic tonic, but fail-open: a future RPC that forgets the line is unprotected. - 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), andCreateLibraryNode— 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.
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
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.