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",
|
"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]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
|
@ -393,6 +405,12 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -435,6 +453,15 @@ version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -547,6 +574,7 @@ dependencies = [
|
||||||
name = "cbd-tui"
|
name = "cbd-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -854,13 +882,17 @@ name = "crabidy-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"audio-player",
|
"audio-player",
|
||||||
|
"base64",
|
||||||
|
"clap",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flume",
|
"flume",
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"http",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -871,6 +903,7 @@ dependencies = [
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
"tonic",
|
"tonic",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1054,6 +1087,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2641,6 +2675,17 @@ dependencies = [
|
||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -3063,6 +3108,9 @@ name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ edition = "2021"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
|
@ -27,6 +28,7 @@ crossterm = "0.29"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
flume = "0.12"
|
flume = "0.12"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
http = "1"
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
|
|
@ -65,6 +67,7 @@ toml = "1"
|
||||||
tonic = "0.14"
|
tonic = "0.14"
|
||||||
tonic-prost = "0.14"
|
tonic-prost = "0.14"
|
||||||
tonic-prost-build = "0.14"
|
tonic-prost-build = "0.14"
|
||||||
|
tower = "0.5"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
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
|
mount. Files are created/rewritten on first start with their defaults
|
||||||
filled in.
|
filled in.
|
||||||
|
|
||||||
| File | Component | Documentation |
|
| File | Component | Documentation |
|
||||||
| -------------- | --------- | -------------------------------------- |
|
| -------------------- | --------- | -------------------------------------- |
|
||||||
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
||||||
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
||||||
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
||||||
| `cbd-tui.toml` | TUI / cbd | below |
|
| `cbd-tui.toml` | TUI / cbd | below |
|
||||||
|
| `crabidy-server.toml`| server | below (never auto-created) |
|
||||||
|
|
||||||
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
||||||
live in `~/.config/crabidy/`; they need no configuration and hold plain
|
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
|
```toml
|
||||||
# Where to find the server. Default shown.
|
# Where to find the server. Default shown.
|
||||||
address = "http://127.0.0.1:50051"
|
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
|
Every option is also available as a command-line flag
|
||||||
(`cbd-tui --address ...`).
|
(`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
|
## Using the library
|
||||||
|
|
||||||
Navigation is vim-style: `j`/`k` select, `l` enters the selected
|
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
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,17 @@ pub struct ServerConfig {
|
||||||
#[default("http://127.0.0.1:50051".to_string())]
|
#[default("http://127.0.0.1:50051".to_string())]
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub address: String,
|
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>),
|
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
info!(address = config.server.address, "connecting to server");
|
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? {
|
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
||||||
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
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 std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
use tonic::{
|
use tonic::{
|
||||||
|
metadata::MetadataValue,
|
||||||
|
service::{interceptor::InterceptedService, Interceptor},
|
||||||
transport::{Channel, Endpoint},
|
transport::{Channel, Endpoint},
|
||||||
Request, Streaming,
|
Request, Status, Streaming,
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXME: use anyhow + thiserror
|
// FIXME: use anyhow + thiserror
|
||||||
|
|
@ -31,9 +34,52 @@ impl fmt::Display for RpcClientError {
|
||||||
|
|
||||||
impl Error 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 {
|
pub struct RpcClient {
|
||||||
library_node_cache: HashMap<String, LibraryNode>,
|
library_node_cache: HashMap<String, LibraryNode>,
|
||||||
client: CrabidyServiceClient<Channel>,
|
client: Client,
|
||||||
pub update_stream: Streaming<GetUpdateStreamResponse>,
|
pub update_stream: Streaming<GetUpdateStreamResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,9 +100,12 @@ fn is_cacheable(path: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcClient {
|
impl RpcClient {
|
||||||
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
|
pub async fn connect(
|
||||||
let endpoint = Endpoint::from_static(addr).connect_lazy();
|
server: &'static crate::config::ServerConfig,
|
||||||
let mut client = CrabidyServiceClient::new(endpoint);
|
) -> 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 update_stream = Self::get_update_stream(&mut client).await;
|
||||||
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
||||||
|
|
@ -68,9 +117,7 @@ impl RpcClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_update_stream(
|
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
|
||||||
client: &mut CrabidyServiceClient<Channel>,
|
|
||||||
) -> Streaming<GetUpdateStreamResponse> {
|
|
||||||
loop {
|
loop {
|
||||||
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
||||||
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
||||||
|
|
@ -321,6 +368,27 @@ impl RpcClient {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn mutable_provider_listings_are_never_cached() {
|
fn mutable_provider_listings_are_never_cached() {
|
||||||
// Freshly captured/saved content must show up on the next visit
|
// Freshly captured/saved content must show up on the next visit
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
http.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
audio-player.workspace = true
|
audio-player.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.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 bookmark_store;
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod capture_store;
|
pub mod capture_store;
|
||||||
|
|
@ -5,6 +6,7 @@ pub mod playback;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod queue_store;
|
pub mod queue_store;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
use audio_player::PlayerMessage;
|
use audio_player::PlayerMessage;
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
|
|
@ -33,6 +35,18 @@ pub const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> 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 (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||||
let orchestrator = provider::ProviderOrchestrator::init("")
|
let orchestrator = provider::ProviderOrchestrator::init("")
|
||||||
.await
|
.await
|
||||||
|
|
@ -86,6 +100,7 @@ pub async fn serve(
|
||||||
|
|
||||||
info!(%addr, "grpc server listening");
|
info!(%addr, "grpc server listening");
|
||||||
tonic::transport::Server::builder()
|
tonic::transport::Server::builder()
|
||||||
|
.layer(auth::AuthLayer::new(authenticator))
|
||||||
.add_service(CrabidyServiceServer::new(crabidy_service))
|
.add_service(CrabidyServiceServer::new(crabidy_service))
|
||||||
.serve(addr)
|
.serve(addr)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,48 @@
|
||||||
//! lives in the library so the bundled `cbd` binary can host it too
|
//! lives in the library so the bundled `cbd` binary can host it too
|
||||||
//! (architecture/cbd-bundle.md D1).
|
//! (architecture/cbd-bundle.md D1).
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
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();
|
let _log_guard = init_tracing();
|
||||||
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
||||||
Ok(())
|
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.
|
/// Installs the global tracing subscriber.
|
||||||
///
|
///
|
||||||
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and
|
/// 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
|
cancel-on-anything-else, prompt render) plus a guard in the existing
|
||||||
queues delete test that track deletion stays `NotSupported` there.
|
queues delete test that track deletion stays `NotSupported` there.
|
||||||
188 workspace tests green; clippy `-D warnings` and fmt clean.
|
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