Gate the gRPC surface behind basic-auth roles
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 <noreply@anthropic.com>
This commit is contained in:
parent
1c83217745
commit
4e59e50943
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
47
README.md
47
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -4,6 +4,7 @@ version.workspace = true
|
|||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
crabidy-core.workspace = true
|
||||
crossterm.workspace = true
|
||||
dirs.workspace = true
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ async fn orchestrate(
|
|||
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
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()))?;
|
||||
|
|
|
|||
|
|
@ -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<MetadataValue<tonic::metadata::Ascii>>,
|
||||
}
|
||||
|
||||
impl AuthInterceptor {
|
||||
/// `user` empty means "no credentials".
|
||||
fn new(user: &str, password: &str) -> Result<Self, Box<dyn Error>> {
|
||||
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<Request<()>, 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<InterceptedService<Channel, AuthInterceptor>>;
|
||||
|
||||
pub struct RpcClient {
|
||||
library_node_cache: HashMap<String, LibraryNode>,
|
||||
client: CrabidyServiceClient<Channel>,
|
||||
client: Client,
|
||||
pub update_stream: Streaming<GetUpdateStreamResponse>,
|
||||
}
|
||||
|
||||
|
|
@ -54,9 +100,12 @@ fn is_cacheable(path: &str) -> bool {
|
|||
}
|
||||
|
||||
impl RpcClient {
|
||||
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
|
||||
let endpoint = Endpoint::from_static(addr).connect_lazy();
|
||||
let mut client = CrabidyServiceClient::new(endpoint);
|
||||
pub async fn connect(
|
||||
server: &'static crate::config::ServerConfig,
|
||||
) -> Result<RpcClient, Box<dyn Error>> {
|
||||
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<String, LibraryNode> = HashMap::new();
|
||||
|
|
@ -68,9 +117,7 @@ impl RpcClient {
|
|||
})
|
||||
}
|
||||
|
||||
async fn get_update_stream(
|
||||
client: &mut CrabidyServiceClient<Channel>,
|
||||
) -> Streaming<GetUpdateStreamResponse> {
|
||||
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Role> {
|
||||
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<String, String> {
|
||||
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<HashMap<String, Role>>,
|
||||
}
|
||||
|
||||
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<Role, Status> {
|
||||
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<Authenticator>,
|
||||
}
|
||||
|
||||
impl AuthLayer {
|
||||
pub fn new(auth: Arc<Authenticator>) -> Self {
|
||||
Self { auth }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for AuthLayer {
|
||||
type Service = AuthService<S>;
|
||||
|
||||
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<S> {
|
||||
inner: S,
|
||||
auth: Arc<Authenticator>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> tower::Service<http::Request<ReqBody>> for AuthService<S>
|
||||
where
|
||||
S: tower::Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
|
||||
ResBody: Default,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: http::Request<ReqBody>) -> 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<Option<String>> = 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<Authenticator>,
|
||||
path: &str,
|
||||
header: Option<&str>,
|
||||
) -> (http::Response<String>, 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<String>) -> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
// 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?;
|
||||
|
|
|
|||
|
|
@ -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<Command>,
|
||||
}
|
||||
|
||||
#[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<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub queue_owner: Option<String>,
|
||||
pub queue_appender: Option<String>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> }`;
|
||||
`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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue