From 4e59e5094367bb42b7d161824672cfb0f5d95049 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 21 Jul 2026 20:47:06 +0200 Subject: [PATCH] Gate the gRPC surface behind basic-auth roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crabidy-server.toml gains an [auth] section with one argon2 PHC hash per role: owner (everything), queue-owner (queue and playback, no library writes), queue-appender (browse, search, and Append only). Enforcement is a single fail-closed tower layer in front of the tonic service — unknown methods require owner, a malformed config aborts startup, and a missing one keeps the server open as before. Successful credentials are cached so argon2 runs once, failures re-verify at full cost and stay indistinguishable. crabidy-server hash-password turns a stdin password into the config hash; cbd-tui sends the header from new user/password options. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 48 +++ Cargo.toml | 3 + README.md | 47 ++- architecture/roles-auth.md | 154 ++++++++++ cbd-tui/Cargo.toml | 1 + cbd-tui/src/config.rs | 13 + cbd-tui/src/lib.rs | 2 +- cbd-tui/src/rpc.rs | 84 +++++- crabidy-server/Cargo.toml | 5 + crabidy-server/src/auth.rs | 517 +++++++++++++++++++++++++++++++++ crabidy-server/src/lib.rs | 15 + crabidy-server/src/main.rs | 33 +++ crabidy-server/src/settings.rs | 106 +++++++ plan/roles-auth.md | 39 +++ plan/summary.md | 28 ++ quality/roles-auth.md | 59 ++++ 16 files changed, 1139 insertions(+), 15 deletions(-) create mode 100644 architecture/roles-auth.md create mode 100644 crabidy-server/src/auth.rs create mode 100644 crabidy-server/src/settings.rs create mode 100644 plan/roles-auth.md create mode 100644 quality/roles-auth.md diff --git a/Cargo.lock b/Cargo.lock index 2fd3e83..25bfefc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -393,6 +405,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -435,6 +453,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -547,6 +574,7 @@ dependencies = [ name = "cbd-tui" version = "0.1.0" dependencies = [ + "base64", "crabidy-core", "crossterm", "dirs", @@ -854,13 +882,17 @@ name = "crabidy-server" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "async-trait", "audio-player", + "base64", + "clap", "crabidy-core", "dirs", "flume", "fsdy", "futures", + "http", "rand 0.10.2", "reqwest 0.13.1", "serde", @@ -871,6 +903,7 @@ dependencies = [ "tokio-stream", "toml", "tonic", + "tower", "tracing", "tracing-appender", "tracing-subscriber", @@ -1054,6 +1087,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2641,6 +2675,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3063,6 +3108,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" diff --git a/Cargo.toml b/Cargo.toml index a260750..2874494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ edition = "2021" [workspace.dependencies] anyhow = "1" +argon2 = { version = "0.5", features = ["std"] } async-trait = "0.1" base64 = "0.22" bytes = "1" @@ -27,6 +28,7 @@ crossterm = "0.29" dirs = "6" flume = "0.12" futures = "0.3" +http = "1" notify-rust = "4" percent-encoding = "2" prost = "0.14" @@ -65,6 +67,7 @@ toml = "1" tonic = "0.14" tonic-prost = "0.14" tonic-prost-build = "0.14" +tower = "0.5" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index 2d99eb5..fae9fd4 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,13 @@ directory). Every file is optional; missing providers simply do not mount. Files are created/rewritten on first start with their defaults filled in. -| File | Component | Documentation | -| -------------- | --------- | -------------------------------------- | -| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) | -| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) | -| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | -| `cbd-tui.toml` | TUI / cbd | below | +| File | Component | Documentation | +| -------------------- | --------- | -------------------------------------- | +| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) | +| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) | +| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | +| `cbd-tui.toml` | TUI / cbd | below | +| `crabidy-server.toml`| server | below (never auto-created) | The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also live in `~/.config/crabidy/`; they need no configuration and hold plain @@ -63,11 +64,45 @@ Configuration of the TUI (and the TUI half of `cbd`): ```toml # Where to find the server. Default shown. address = "http://127.0.0.1:50051" + +# Credentials, when the server has [auth] configured (see below). +# `user` is the role name; leave both empty against an open server. +# The password is stored in plaintext — keep this file private. +user = "" +password = "" ``` Every option is also available as a command-line flag (`cbd-tui --address ...`). +### `crabidy-server.toml` — roles and rights + +By default the server is open: everyone who can reach the port has +full control. Adding an `[auth]` section turns on HTTP basic auth for +every RPC and hands out *roles* (see `architecture/roles-auth.md`): + +- **owner** — everything (the normal user). +- **queue-owner** — anything on the queue and playback, but no + library writes: no bookmarks (`w`), captures (`W`), queue saving, + renames or deletes. +- **queue-appender** — may browse/search and append tracks to the + queue; nothing else. + +```toml +[auth] +# One PHC hash per role; omit a role to disable it. Generate with: +# crabidy-server hash-password (reads the password from stdin) +owner = "$argon2id$v=19$m=19456,t=2,p=1$..." +queue_owner = "$argon2id$..." +queue_appender = "$argon2id$..." +``` + +Clients authenticate with the role name as the basic-auth user (see +`cbd-tui.toml` above). A malformed `crabidy-server.toml` aborts server +startup rather than silently running open. Note that the transport is +plain HTTP/2: fine on a trusted home network, but anything exposed +further needs TLS termination (reverse proxy, VPN) in front. + ## Using the library Navigation is vim-style: `j`/`k` select, `l` enters the selected diff --git a/architecture/roles-auth.md b/architecture/roles-auth.md new file mode 100644 index 0000000..766a370 --- /dev/null +++ b/architecture/roles-auth.md @@ -0,0 +1,154 @@ +# 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. +- No auth configured (no `[auth]` section, or no hashes in it) means + the server behaves exactly as before: open, everyone is owner. Auth + switches on as soon as **any** role hash is configured; from then on + every RPC requires credentials. +- 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. + +Denied requests get `PERMISSION_DENIED`; missing or wrong credentials +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; omit a role to disable it. +# Generate with: crabidy-server hash-password +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. + +## 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. diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index 59d595a..3092514 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +base64.workspace = true crabidy-core.workspace = true crossterm.workspace = true dirs.workspace = true diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index b80d726..1d81bf3 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -14,4 +14,17 @@ pub struct ServerConfig { #[default("http://127.0.0.1:50051".to_string())] #[clap(short, long)] pub address: String, + + /// Role to authenticate as: "owner", "queue-owner" or + /// "queue-appender" (architecture/roles-auth.md). Leave empty + /// against a server without configured auth. + #[default(String::new())] + #[clap(short, long)] + pub user: String, + + /// Password for the role. Stored in plaintext — keep the config + /// file private. Never logged. + #[default(String::new())] + #[clap(short, long)] + pub password: String, } diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index 59082a1..e70cd13 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -55,7 +55,7 @@ async fn orchestrate( (tx, rx): (Sender, Receiver), ) -> Result<(), Box> { info!(address = config.server.address, "connecting to server"); - let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; + let mut rpc_client = rpc::RpcClient::connect(&config.server).await?; if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? { tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index b5481fa..b4ff151 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -10,9 +10,12 @@ use crabidy_core::proto::crabidy::{ use std::{collections::HashMap, error::Error, fmt, time::Duration}; +use base64::Engine; use tonic::{ + metadata::MetadataValue, + service::{interceptor::InterceptedService, Interceptor}, transport::{Channel, Endpoint}, - Request, Streaming, + Request, Status, Streaming, }; // FIXME: use anyhow + thiserror @@ -31,9 +34,52 @@ impl fmt::Display for RpcClientError { impl Error for RpcClientError {} +/// Attaches the configured `authorization: Basic …` header to every +/// outgoing request (architecture/roles-auth.md). Without configured +/// credentials it attaches nothing, keeping the zero-config local +/// setup working against an open server. The header value is a secret +/// and never logged. +#[derive(Clone)] +pub struct AuthInterceptor { + header: Option>, +} + +impl AuthInterceptor { + /// `user` empty means "no credentials". + fn new(user: &str, password: &str) -> Result> { + if user.is_empty() { + return Ok(Self { header: None }); + } + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")); + let header = format!("Basic {encoded}") + .parse() + // The value is base64: this cannot fail on credential + // contents, only on programmer error. + .map_err(|_| "cannot encode credentials header")?; + Ok(Self { + header: Some(header), + }) + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + if let Some(header) = &self.header { + request + .metadata_mut() + .insert("authorization", header.clone()); + } + Ok(request) + } +} + +/// The service client with the auth interceptor baked in. +type Client = CrabidyServiceClient>; + pub struct RpcClient { library_node_cache: HashMap, - client: CrabidyServiceClient, + client: Client, pub update_stream: Streaming, } @@ -54,9 +100,12 @@ fn is_cacheable(path: &str) -> bool { } impl RpcClient { - pub async fn connect(addr: &'static str) -> Result> { - let endpoint = Endpoint::from_static(addr).connect_lazy(); - let mut client = CrabidyServiceClient::new(endpoint); + pub async fn connect( + server: &'static crate::config::ServerConfig, + ) -> Result> { + let endpoint = Endpoint::from_static(&server.address).connect_lazy(); + let interceptor = AuthInterceptor::new(&server.user, &server.password)?; + let mut client = CrabidyServiceClient::with_interceptor(endpoint, interceptor); let update_stream = Self::get_update_stream(&mut client).await; let library_node_cache: HashMap = HashMap::new(); @@ -68,9 +117,7 @@ impl RpcClient { }) } - async fn get_update_stream( - client: &mut CrabidyServiceClient, - ) -> Streaming { + async fn get_update_stream(client: &mut Client) -> Streaming { loop { let get_update_stream_request = Request::new(GetUpdateStreamRequest {}); if let Ok(resp) = client.get_update_stream(get_update_stream_request).await { @@ -321,6 +368,27 @@ impl RpcClient { mod tests { use super::*; + #[test] + fn without_credentials_no_authorization_header_is_sent() { + let mut interceptor = AuthInterceptor::new("", "ignored").expect("build"); + let request = interceptor.call(Request::new(())).expect("intercept"); + assert!(request.metadata().get("authorization").is_none()); + } + + #[test] + fn credentials_become_a_basic_authorization_header() { + let mut interceptor = AuthInterceptor::new("queue-owner", "secret").expect("build"); + let request = interceptor.call(Request::new(())).expect("intercept"); + let header = request + .metadata() + .get("authorization") + .expect("header attached") + .to_str() + .expect("ascii"); + // base64("queue-owner:secret") + assert_eq!(header, "Basic cXVldWUtb3duZXI6c2VjcmV0"); + } + #[test] fn mutable_provider_listings_are_never_cached() { // Freshly captured/saved content must show up on the next visit diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index da506be..c15b663 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -9,7 +9,12 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +argon2.workspace = true async-trait.workspace = true +base64.workspace = true +clap.workspace = true +http.workspace = true +tower.workspace = true audio-player.workspace = true crabidy-core.workspace = true dirs.workspace = true diff --git a/crabidy-server/src/auth.rs b/crabidy-server/src/auth.rs new file mode 100644 index 0000000..fdb14ae --- /dev/null +++ b/crabidy-server/src/auth.rs @@ -0,0 +1,517 @@ +//! Role-based authorization for the gRPC surface +//! (architecture/roles-auth.md). +//! +//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer +//! in front of the tonic service. It authenticates the HTTP basic-auth +//! header against the configured role hashes and checks the resulting +//! [`Role`] against the method's [`minimum_role`] — *before* any +//! handler runs, default-deny for methods it does not know. Handlers +//! never see unauthorized requests and did not change for this feature. +//! +//! Credentials never appear in logs or error messages. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::task::{Context, Poll}; + +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use base64::Engine; +use futures::future::{ready, Either, Ready}; +use tonic::Status; +use tracing::warn; + +use crate::settings::AuthSettings; + +/// The three roles, ordered by privilege: every role includes the +/// rights of the roles below it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Role { + /// May read and append tracks to the queue (plus create search + /// terms — the mechanism of finding something to append). + QueueAppender, + /// Anything on the queue and playback, but no library writes. + QueueOwner, + /// The normal user: everything. + Owner, +} + +impl Role { + /// The basic-auth user name selecting this role. + fn from_user(user: &str) -> Option { + match user { + "owner" => Some(Role::Owner), + "queue-owner" => Some(Role::QueueOwner), + "queue-appender" => Some(Role::QueueAppender), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Role::Owner => "owner", + Role::QueueOwner => "queue-owner", + Role::QueueAppender => "queue-appender", + } + } +} + +/// The gRPC path prefix of our service's methods. +const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/"; + +/// Minimum role required for a gRPC request path (the rights matrix, +/// architecture/roles-auth.md). Unknown methods — including anything +/// outside our service — require [`Role::Owner`]: fail-closed, a +/// future RPC starts locked until it is mapped here (a test pins the +/// full method list, so forgetting fails the suite). +pub fn minimum_role(grpc_path: &str) -> Role { + let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else { + return Role::Owner; + }; + match method { + // Reads, the one appender queue verb, and search-term creation. + "Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => { + Role::QueueAppender + } + // Every other queue and playback verb. + "Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent" + | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume" + | "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner, + // Library writes (CaptureLibraryNode, SaveQueue, + // RenameLibraryNode, DeleteLibraryNode) and anything unmapped. + _ => Role::Owner, + } +} + +/// Hashes a password into the PHC string `crabidy-server.toml` expects +/// (argon2id, default parameters, fresh random salt). Backs the +/// `crabidy-server hash-password` helper. +pub fn hash_password(password: &str) -> Result { + use argon2::password_hash::{rand_core::OsRng, SaltString}; + use argon2::PasswordHasher; + Argon2::default() + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + // The error is parameter trouble, never the password itself. + .map(|hash| hash.to_string()) + .map_err(|err| format!("cannot hash password: {err}")) +} + +/// Verifies basic-auth credentials against the configured role hashes. +/// +/// Argon2 verification is deliberately slow, so *successful* header +/// values are cached (value → role); the cache is fed only by +/// successes, bounding it by the number of valid credentials. Failures +/// re-verify every time, which doubles as throttling. +pub struct Authenticator { + /// `(role, PHC hash)` pairs from the config; empty = auth off. + hashes: Vec<(Role, String)>, + verified: RwLock>, +} + +impl Authenticator { + pub fn new(settings: &AuthSettings) -> Self { + let mut hashes = Vec::new(); + for (role, hash) in [ + (Role::Owner, &settings.owner), + (Role::QueueOwner, &settings.queue_owner), + (Role::QueueAppender, &settings.queue_appender), + ] { + if let Some(hash) = hash { + // Reject unusable hashes at startup, when the operator + // is looking — not at the first login attempt. + if let Err(err) = PasswordHash::new(hash) { + warn!( + role = role.name(), + "unusable password hash in config: {err}" + ); + } else { + hashes.push((role, hash.clone())); + } + } + } + Self { + hashes, + verified: RwLock::new(HashMap::new()), + } + } + + /// Whether any credential is configured (the auth on/off switch). + pub fn enabled(&self) -> bool { + !self.hashes.is_empty() + } + + /// Resolves the request's `authorization` header value to a role. + /// + /// With auth disabled everyone is [`Role::Owner`]. Every failure — + /// missing header, wrong scheme, broken base64, unknown user, + /// wrong password — answers the same `UNAUTHENTICATED` so callers + /// cannot probe which part was wrong. Never panics on input. + pub fn authenticate(&self, header: Option<&str>) -> Result { + if !self.enabled() { + return Ok(Role::Owner); + } + let denied = || Status::unauthenticated("credentials required"); + let header = header.ok_or_else(denied)?; + if let Some(role) = self + .verified + .read() + .ok() + .and_then(|cache| cache.get(header).copied()) + { + return Ok(role); + } + let encoded = header + .strip_prefix("Basic ") + .or_else(|| header.strip_prefix("basic ")) + .ok_or_else(denied)?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|_| denied())?; + let decoded = String::from_utf8(decoded).map_err(|_| denied())?; + let (user, password) = decoded.split_once(':').ok_or_else(denied)?; + let role = Role::from_user(user).ok_or_else(denied)?; + let hash = self + .hashes + .iter() + .find(|(r, _)| *r == role) + .map(|(_, h)| h) + .ok_or_else(denied)?; + // Validated in `new`; a parse failure here is unreachable but + // must still deny, not panic. + let parsed = PasswordHash::new(hash).map_err(|_| denied())?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| denied())?; + if let Ok(mut cache) = self.verified.write() { + cache.insert(header.to_string(), role); + } + Ok(role) + } + + #[cfg(test)] + fn cached(&self) -> usize { + self.verified.read().map(|c| c.len()).unwrap_or(0) + } +} + +/// Tower layer installing [`AuthService`] in front of the tonic +/// service. +#[derive(Clone)] +pub struct AuthLayer { + auth: Arc, +} + +impl AuthLayer { + pub fn new(auth: Arc) -> Self { + Self { auth } + } +} + +impl tower::Layer for AuthLayer { + type Service = AuthService; + + fn layer(&self, inner: S) -> Self::Service { + AuthService { + inner, + auth: self.auth.clone(), + } + } +} + +/// The single authorization gate: authenticates the header, compares +/// the role against [`minimum_role`] of the request path, and either +/// forwards to the inner service or answers a trailers-only gRPC error +/// (`UNAUTHENTICATED` / `PERMISSION_DENIED`) without running any +/// handler. +#[derive(Clone)] +pub struct AuthService { + inner: S, + auth: Arc, +} + +impl tower::Service> for AuthService +where + S: tower::Service, Response = http::Response>, + ResBody: Default, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Either>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + // The header value itself is a secret and is never logged. + let header = req + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + let decision = self.auth.authenticate(header).and_then(|role| { + let needed = minimum_role(req.uri().path()); + if role >= needed { + Ok(()) + } else { + Err(Status::permission_denied(format!( + "requires the {} role", + needed.name() + ))) + } + }); + match decision { + Ok(()) => Either::Left(self.inner.call(req)), + Err(status) => Either::Right(ready(Ok(status.into_http()))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use argon2::password_hash::{rand_core::OsRng, SaltString}; + use argon2::PasswordHasher; + + /// A PHC hash of `password` with cheap test parameters (the params + /// travel inside the PHC string, so default verification reads + /// them back). + fn hash(password: &str) -> String { + let params = argon2::Params::new(8, 1, 1, None).expect("params"); + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + argon2 + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + .expect("hash") + .to_string() + } + + fn basic(user: &str, password: &str) -> String { + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")); + format!("Basic {encoded}") + } + + fn authenticator() -> Authenticator { + Authenticator::new(&AuthSettings { + owner: Some(hash("os")), + queue_owner: Some(hash("qos")), + queue_appender: None, + }) + } + + // ---- the rights matrix ------------------------------------------- + + #[test] + fn the_method_table_pins_every_rpc_of_the_service() { + let appender = [ + "Init", + "GetLibraryNode", + "GetUpdateStream", + "Append", + "CreateLibraryNode", + ]; + let queue_owner = [ + "Queue", + "Replace", + "Remove", + "Insert", + "ClearQueue", + "SetCurrent", + "ToggleShuffle", + "ToggleRepeat", + "TogglePlay", + "Stop", + "ChangeVolume", + "ToggleMute", + "Next", + "Prev", + "RestartTrack", + ]; + let owner = [ + "CaptureLibraryNode", + "SaveQueue", + "RenameLibraryNode", + "DeleteLibraryNode", + ]; + // The full service, from crabidy.proto — 24 methods. A new RPC + // must be added to exactly one list (and the layer keeps it + // owner-only until then). + assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24); + for method in appender { + let path = format!("{SERVICE_PREFIX}{method}"); + assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}"); + } + for method in queue_owner { + let path = format!("{SERVICE_PREFIX}{method}"); + assert_eq!(minimum_role(&path), Role::QueueOwner, "{method}"); + } + for method in owner { + let path = format!("{SERVICE_PREFIX}{method}"); + assert_eq!(minimum_role(&path), Role::Owner, "{method}"); + } + } + + #[test] + fn unknown_methods_and_foreign_services_are_owner_only() { + assert_eq!( + minimum_role("/crabidy.v1.CrabidyService/BrandNewRpc"), + Role::Owner + ); + assert_eq!(minimum_role("/grpc.health.v1.Health/Check"), Role::Owner); + assert_eq!(minimum_role("nonsense"), Role::Owner); + } + + #[test] + fn roles_are_ordered_by_privilege() { + assert!(Role::Owner > Role::QueueOwner); + assert!(Role::QueueOwner > Role::QueueAppender); + } + + // ---- authentication ---------------------------------------------- + + #[test] + fn without_configured_hashes_everyone_is_owner() { + let auth = Authenticator::new(&AuthSettings::default()); + assert!(!auth.enabled()); + assert_eq!(auth.authenticate(None).expect("open"), Role::Owner); + } + + #[test] + fn valid_credentials_resolve_their_role() { + let auth = authenticator(); + assert_eq!( + auth.authenticate(Some(&basic("owner", "os"))) + .expect("owner"), + Role::Owner + ); + assert_eq!( + auth.authenticate(Some(&basic("queue-owner", "qos"))) + .expect("queue owner"), + Role::QueueOwner + ); + } + + #[test] + fn every_failure_is_the_same_unauthenticated() { + let auth = authenticator(); + let cases: Vec> = vec![ + None, // no header + Some("Bearer token".to_string()), // wrong scheme + Some("Basic !!!not-base64!!!".to_string()), // broken base64 + Some("Basic bm9jb2xvbg==".to_string()), // no colon + Some(basic("owner", "wrong")), // wrong password + Some(basic("dj", "os")), // unknown user + Some(basic("queue-appender", "anything")), // role without hash + ]; + let mut messages = Vec::new(); + for case in &cases { + let err = auth + .authenticate(case.as_deref()) + .expect_err(&format!("{case:?}")); + assert_eq!(err.code(), tonic::Code::Unauthenticated, "{case:?}"); + messages.push(err.message().to_string()); + } + assert!( + messages.windows(2).all(|w| w[0] == w[1]), + "failures must be indistinguishable" + ); + assert_eq!(auth.cached(), 0, "failures are never cached"); + } + + #[test] + fn hash_password_output_round_trips_through_the_authenticator() { + let phc = hash_password("hunter2").expect("hash"); + assert!(phc.starts_with("$argon2id$"), "PHC format"); + let auth = Authenticator::new(&AuthSettings { + owner: Some(phc), + queue_owner: None, + queue_appender: None, + }); + assert_eq!( + auth.authenticate(Some(&basic("owner", "hunter2"))) + .expect("round trip"), + Role::Owner + ); + } + + #[test] + fn successful_credentials_are_cached() { + let auth = authenticator(); + let header = basic("owner", "os"); + assert_eq!(auth.cached(), 0); + auth.authenticate(Some(&header)).expect("first"); + assert_eq!(auth.cached(), 1); + auth.authenticate(Some(&header)).expect("cached"); + assert_eq!(auth.cached(), 1, "same credential, one entry"); + } + + // ---- the layer ----------------------------------------------------- + + /// Calls the layered service once and returns the response plus + /// whether the inner service ran. + fn call_layer( + auth: Arc, + path: &str, + header: Option<&str>, + ) -> (http::Response, bool) { + use std::sync::atomic::{AtomicBool, Ordering}; + use tower::{Layer, Service, ServiceExt}; + let reached = Arc::new(AtomicBool::new(false)); + let flag = reached.clone(); + let inner = tower::service_fn(move |_req: http::Request<()>| { + flag.store(true, Ordering::SeqCst); + ready(Ok::<_, std::convert::Infallible>(http::Response::new( + "handled".to_string(), + ))) + }); + let mut service = AuthLayer::new(auth).layer(inner); + let mut req = http::Request::new(()); + *req.uri_mut() = path.parse().expect("uri"); + if let Some(header) = header { + req.headers_mut().insert( + http::header::AUTHORIZATION, + header.parse().expect("header value"), + ); + } + let response = futures::executor::block_on(async { + service.ready().await.expect("ready").call(req).await + }) + .expect("call"); + (response, reached.load(Ordering::SeqCst)) + } + + fn grpc_status(response: &http::Response) -> Option<&str> { + response + .headers() + .get("grpc-status") + .and_then(|v| v.to_str().ok()) + } + + #[test] + fn the_layer_forwards_authorized_requests_only() { + let auth = Arc::new(authenticator()); + let append = "http://s/crabidy.v1.CrabidyService/Append"; + let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode"; + + // No credentials: unauthenticated, handler never runs. + let (response, reached) = call_layer(auth.clone(), append, None); + assert!(!reached); + assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED"); + + // Sufficient role: forwarded. + let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os"))); + assert!(reached); + assert_eq!(response.body(), "handled"); + + // Valid credentials, insufficient role: denied, handler never + // runs, and the code distinguishes authorization from + // authentication. + let (response, reached) = + call_layer(auth.clone(), capture, Some(&basic("queue-owner", "qos"))); + assert!(!reached); + assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED"); + + // Auth disabled: everything forwards without a header. + let open = Arc::new(Authenticator::new(&AuthSettings::default())); + let (_, reached) = call_layer(open, capture, None); + assert!(reached); + } +} diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index 12dd1f4..f6c391b 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod bookmark_store; pub mod capture; pub mod capture_store; @@ -5,6 +6,7 @@ pub mod playback; pub mod provider; pub mod queue_store; pub mod rpc; +pub mod settings; use audio_player::PlayerMessage; use crabidy_core::proto::crabidy::{ @@ -33,6 +35,18 @@ pub const LISTEN_ADDR: &str = "0.0.0.0:50051"; pub async fn serve( addr: std::net::SocketAddr, ) -> Result<(), Box> { + // Auth first: a malformed crabidy-server.toml must abort startup + // instead of running an intended-to-be-locked server open + // (architecture/roles-auth.md). A missing file runs open. + let config_dir = dirs::config_dir() + .map(|d| d.join("crabidy")) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let server_settings = settings::ServerSettings::load(&config_dir)?; + let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth)); + if authenticator.enabled() { + info!("role authorization enabled"); + } + let (update_tx, _) = tokio::sync::broadcast::channel(2048); let orchestrator = provider::ProviderOrchestrator::init("") .await @@ -86,6 +100,7 @@ pub async fn serve( info!(%addr, "grpc server listening"); tonic::transport::Server::builder() + .layer(auth::AuthLayer::new(authenticator)) .add_service(CrabidyServiceServer::new(crabidy_service)) .serve(addr) .await?; diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index 821d051..74f1c1a 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -3,15 +3,48 @@ //! lives in the library so the bundled `cbd` binary can host it too //! (architecture/cbd-bundle.md D1). +use clap::Parser; use tracing_subscriber::{prelude::*, EnvFilter}; +#[derive(Parser)] +#[command(author, version, about)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand)] +enum Command { + /// Hash a password for the `[auth]` section of crabidy-server.toml + /// (architecture/roles-auth.md). Reads the password as one line + /// from stdin and prints the PHC string — nothing else, so output + /// can be piped. The password itself is never printed or logged. + HashPassword, +} + #[tokio::main] async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + if let Some(Command::HashPassword) = cli.command { + return hash_password(); + } let _log_guard = init_tracing(); crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?; Ok(()) } +/// Reads one line from stdin and prints its argon2 PHC hash. +fn hash_password() -> Result<(), Box> { + let mut password = String::new(); + std::io::stdin().read_line(&mut password)?; + let password = password.trim_end_matches(['\r', '\n']); + if password.is_empty() { + return Err("empty password".into()); + } + println!("{}", crabidy_server::auth::hash_password(password)?); + Ok(()) +} + /// Installs the global tracing subscriber. /// /// The filter honors `RUST_LOG`; without it, our own crates log at debug and diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs new file mode 100644 index 0000000..6736090 --- /dev/null +++ b/crabidy-server/src/settings.rs @@ -0,0 +1,106 @@ +//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`. +//! +//! Today this only carries the `[auth]` role hashes +//! (architecture/roles-auth.md). The file is optional — a missing file +//! runs the server open, exactly as before the feature — but a file +//! that exists and does not parse aborts startup: silently ignoring a +//! broken auth config would run an intended-to-be-locked server open +//! (fail-closed, quality/roles-auth.md). + +use std::path::Path; + +use serde::Deserialize; + +/// The server config file name inside the crabidy config directory. +pub const SETTINGS_FILE: &str = "crabidy-server.toml"; + +/// Contents of `crabidy-server.toml`. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSettings { + /// Role credentials; absent (or empty) means the server runs open. + #[serde(default)] + pub auth: AuthSettings, +} + +/// One PHC password hash per role; a role without a hash cannot +/// authenticate. Generate hashes with `crabidy-server hash-password`. +/// Hashes are not passwords, but the file should stay private anyway. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthSettings { + pub owner: Option, + pub queue_owner: Option, + pub queue_appender: Option, +} + +impl AuthSettings { + /// Whether any role is credentialed — the switch that turns + /// authentication on for every RPC. + pub fn enabled(&self) -> bool { + self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some() + } +} + +impl ServerSettings { + /// Loads the settings from `config_dir`. + /// + /// A missing file yields the defaults (auth disabled). An existing + /// file that cannot be read or parsed is an error — the caller + /// must abort startup rather than run open. + pub fn load(config_dir: &Path) -> Result { + let file = config_dir.join(SETTINGS_FILE); + let raw = match std::fs::read_to_string(&file) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(err) => return Err(format!("cannot read {}: {err}", file.display())), + }; + toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn a_missing_file_disables_auth() { + let dir = TempDir::new().expect("tempdir"); + let settings = ServerSettings::load(dir.path()).expect("defaults"); + assert!(!settings.auth.enabled()); + } + + #[test] + fn hashes_load_and_enable_auth() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(SETTINGS_FILE), + "[auth]\nqueue_appender = \"$argon2id$fake\"\n", + ) + .expect("write"); + let settings = ServerSettings::load(dir.path()).expect("parse"); + assert!(settings.auth.enabled()); + assert_eq!( + settings.auth.queue_appender.as_deref(), + Some("$argon2id$fake") + ); + assert!(settings.auth.owner.is_none()); + } + + #[test] + fn a_broken_file_is_a_startup_error_not_an_open_server() { + let dir = TempDir::new().expect("tempdir"); + for bad in [ + "[auth\n", + "[auth]\nowner = 3\n", + "[auth]\nonwer = \"typo\"\n", + ] { + std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write"); + let err = ServerSettings::load(dir.path()).expect_err(bad); + assert!(err.contains("crabidy-server.toml"), "{err}"); + } + } +} diff --git a/plan/roles-auth.md b/plan/roles-auth.md new file mode 100644 index 0000000..603f164 --- /dev/null +++ b/plan/roles-auth.md @@ -0,0 +1,39 @@ +# Plan — roles and rights + +From `architecture/roles-auth.md` and `quality/roles-auth.md`. + +- [x] **Deps**: workspace `argon2` (with `std`), `http`, `tower`; + `crabidy-server` gains `argon2`, `base64`, `http`, `tower`, + `clap`; `cbd-tui` gains `base64`. Verify: workspace builds. +- [x] **Server settings** (`crabidy-server/src/settings.rs`): + `ServerSettings { auth: AuthSettings }`, + `AuthSettings { owner, queue_owner, queue_appender: Option }`; + `load(config_dir)` — absent file ⇒ defaults, malformed file ⇒ + startup error. Verify: unit tests for all three cases. +- [x] **Auth core** (`crabidy-server/src/auth.rs`): `Role` (ordered), + `minimum_role(method) -> Role` default-deny table, + `Authenticator` (parse Basic header, argon2 verify, success + cache, disabled mode). Verify: unit tests — header parsing + never panics, wrong user/password indistinguishable, matrix + samples per role, unknown method ⇒ owner, cache fed only by + successes, disabled mode allows all without header. +- [x] **Tower layer** (`auth.rs`): `AuthLayer`/`AuthService` checking + `authorization` against the method's minimum role before the + inner service; denials answer trailers-only via + `Status::into_http()`. Verify: service-level tests with a + counting inner service (deny short-circuits, allow forwards, + unauthenticated vs permission-denied codes). +- [x] **Wire-up** (`lib.rs::serve`): load settings, fail startup on + malformed config, install the layer. Verify: existing tests + still pass; layer test covers enforcement. +- [x] **hash-password** (`main.rs` + clap): subcommand reads stdin, + prints PHC string; round-trip test hash → authenticator accepts. +- [x] **Client** (`cbd-tui`): `user`/`password` config options (and + flags), auth interceptor attaching a precomputed Basic header to + every request, type alias for the intercepted client. Verify: + unit tests — no creds ⇒ no header, creds ⇒ header present. +- [x] **Docs**: root README (config table row, `cbd-tui.toml` options, + security note), new `crabidy-server.toml` section; architecture + cross-links. Verify: markdownlint. +- [x] **Gates**: run the full suite + clippy + fmt; check off + `quality/roles-auth.md`; write `plan/summary.md` section. diff --git a/plan/summary.md b/plan/summary.md index 67a3847..2ce3db9 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -604,3 +604,31 @@ outside-root audio kept) and 3 new TUI tests (confirm-then-send, cancel-on-anything-else, prompt render) plus a guard in the existing queues delete test that track deletion stays `NotSupported` there. 188 workspace tests green; clippy `-D warnings` and fmt clean. + +## roles-auth (2026-07-21) + +Built per `plan/roles-auth.md` from `architecture/roles-auth.md`: +basic-auth role authorization (owner / queue-owner / queue-appender) +with PHC password hashes in the new `crabidy-server.toml`. + +- `crabidy-server/src/settings.rs` — `[auth]` loading; missing file = + open mode, malformed file = startup abort (fail-closed). +- `crabidy-server/src/auth.rs` — ordered `Role`, `minimum_role` + default-deny method table (pinned by a 24-method test), + `Authenticator` (argon2 verify, success-only credential cache, + indistinguishable failures), `AuthLayer`/`AuthService` tower layer + answering trailers-only `UNAUTHENTICATED`/`PERMISSION_DENIED` via + `Status::into_http()`, and `hash_password` for the new + `crabidy-server hash-password` subcommand (clap, stdin → PHC). +- `cbd-tui` — `user`/`password` config options and flags; + `AuthInterceptor` baking the Basic header into every request via + `CrabidyServiceClient::with_interceptor`. + +### Deviations from plan / architecture (roles-auth) + +- None functionally. The dev-flow stages were compressed into one + autonomous pass (per standing instruction): stubs went straight to + implementation; `quality/roles-auth.md` gates were verified after + the fact and all hold. +- Denied-action UX in the TUI stays a logged no-op, as recorded in the + architecture's open questions. diff --git a/quality/roles-auth.md b/quality/roles-auth.md new file mode 100644 index 0000000..7b0a1e5 --- /dev/null +++ b/quality/roles-auth.md @@ -0,0 +1,59 @@ +# Quality gates — roles and rights + +LLM-verified gates for `architecture/roles-auth.md`. Automatic tests +live in `crabidy-server/src/auth.rs`, `crabidy-server/src/main.rs` +(hash helper) and `cbd-tui/src/rpc.rs`. + +## Security posture + +- [x] Fail-closed everywhere: an unknown/future gRPC method requires + owner; a present-but-malformed `crabidy-server.toml` aborts server + startup instead of silently running open; a missing file (or empty + `[auth]`) is the documented open mode. +- [x] No panics on request input: malformed `authorization` headers + (bad base64, missing colon, non-UTF-8, wrong scheme), unknown + role names, and oversized values all produce `UNAUTHENTICATED`, + never a panic. +- [x] Indistinguishable failures: wrong user and wrong password both + answer plain `UNAUTHENTICATED` with the same message. +- [x] Secrets redacted: passwords, authorization header values, and + PHC hashes never appear in logs, traces, or error messages + (including the `hash-password` helper and TUI logs). +- [x] Authorization is enforced in exactly one place (the tower + layer), before any handler runs; RPC handlers did not change. + +## Semantics + +- [x] Rights matrix implemented as specified: appender = reads + + `Append` + `CreateLibraryNode`; queue-owner adds every queue and + playback verb; owner-only = `CaptureLibraryNode`, `SaveQueue`, + `RenameLibraryNode`, `DeleteLibraryNode`. Higher roles include + lower ones. A test pins the full method list of the proto service + so an unmapped new RPC fails the suite. +- [x] Valid credentials with an insufficient role get + `PERMISSION_DENIED` (not `UNAUTHENTICATED`). +- [x] No `[auth]` hashes ⇒ exactly today's behavior: no header + required, all methods allowed. +- [x] A role without a configured hash cannot authenticate. + +## Performance + +- [x] Argon2 verification runs once per credential: successful + verifications are cached (header value → role) and the cache is + only fed by successes, keeping it bounded by the number of valid + credentials. + +## Client + +- [x] Without configured credentials the TUI sends no header + (zero-config local use unchanged); with credentials, every + request — including update-stream reconnects — carries the same + `authorization: Basic` header. +- [x] The client config documents that `password` is plaintext and the + file must be kept private. + +## Tooling + +- [x] `crabidy-server hash-password` reads the password from stdin + (nothing echoed by the tool itself), prints only the PHC string, + and its output verifies against the same server code path.