From a275d3bc77a6cc8db38a769cab694b31dcbb37bb Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 16:06:01 +0200 Subject: [PATCH] Auth: anonymous callers inherit the highest unguarded role Replace the "any hash configured => every RPC needs credentials" switch with a top-down model: a request with no credentials is granted the most privileged role whose password is not set, and each password lowers that floor. Nothing guarded -> owner (the open default); guard owner -> anonymous is queue-owner; guard owner+queue_owner -> queue-appender; guard all three -> credentials required for everything. A credential still elevates a caller to its role; a present-but-wrong credential is denied, never silently downgraded to the anonymous role. Because the anonymous role is always the highest unguarded one, guarding a lower role while a higher one is open is meaningless. Valid guarded sets are prefixes of [owner, queue_owner, queue_appender]; AuthSettings::validate rejects any other order, load aborts startup on it (fail-closed), and `guard` refuses to write it. Docs (architecture, quality, mdbook, README) updated to the new model. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 ++-- architecture/roles-auth.md | 39 +++++++-- crabidy-server/src/auth.rs | 128 +++++++++++++++++++++++++---- crabidy-server/src/cli.rs | 25 ++++-- crabidy-server/src/settings.rs | 110 +++++++++++++++++++++++-- crabidy-server/tests/web_server.rs | 11 +-- docs/src/auth.md | 60 ++++++++++---- plan/roles-auth.md | 25 ++++++ quality/roles-auth.md | 19 ++++- 9 files changed, 372 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 69d52d1..b98b5ba 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,10 @@ provider's own config file (`tidaly.toml`, etc.) is simply left unread. #### 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`): +full control. Setting password hashes in `[auth]` locks the server +**from the top down** — each password you set lowers what a +no-credential caller may do, while a matching password elevates a +caller to that role (see `architecture/roles-auth.md`): - **owner** — everything (the normal user). - **queue-owner** — anything on the queue and playback, but no @@ -132,17 +134,23 @@ every RPC and hands out *roles* (see `architecture/roles-auth.md`): - **queue-appender** — may browse/search and append tracks to the queue; nothing else. +A caller with no credentials gets the highest role you left *unguarded*: +nothing guarded → owner (the open default); guard `owner` → anonymous is +queue-owner; guard `owner` + `queue_owner` → anonymous is queue-appender; +guard all three → credentials required for everything. + ```toml [auth] -# One PHC hash per role; omit a role to disable it. Generate and store -# a hash with: crabidy-server guard (see the CLI section). +# One PHC hash per role. Guard from the top down. Generate and store a +# hash with: crabidy-server guard (see the CLI section). 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 +`cbd-tui.toml` above). A malformed `crabidy-server.toml` — or one that +guards a lower role while a higher one is still open — 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. diff --git a/architecture/roles-auth.md b/architecture/roles-auth.md index 766a370..331b854 100644 --- a/architecture/roles-auth.md +++ b/architecture/roles-auth.md @@ -21,10 +21,26 @@ Three roles, credentialed by password hashes in the server config: 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. +- **Anonymous callers inherit the highest *unguarded* role.** A request + with no credentials is not rejected outright; it is granted the most + privileged role whose password is *not* set. So the config guards + from the top down and each password lowers what anonymous users can + do: + - nothing guarded → anonymous is **owner** (today's open server); + - `owner` guarded → anonymous is **queue-owner**; + - `owner` + `queue_owner` guarded → anonymous is **queue-appender**; + - all three guarded → anonymous can do **nothing** (`UNAUTHENTICATED`). + A credential still elevates a caller to its role; the anonymous role + is only the floor. A *present-but-wrong* credential is denied, never + silently downgraded to the anonymous role. +- **Guarding order is enforced.** Because anonymous callers get the + highest unguarded role, guarding a lower role while a higher one is + open is meaningless — the anonymous role would still outrank it. The + valid guarded sets are therefore prefixes of `[owner, queue_owner, + queue_appender]`. A config that sets `queue_owner` without `owner` + (or `queue_appender` without `queue_owner`) is **broken and aborts + startup**, fail-closed; `crabidy-server guard` likewise refuses to + write such a config. - One password per role, not per person. The Basic-auth *username* selects the role (`owner`, `queue-owner`, `queue-appender`), the password is verified against that role's hash. @@ -88,9 +104,10 @@ Minimum role per RPC; higher roles include lower ones 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). +A caller whose role (anonymous or credentialed) is below the method's +minimum gets `PERMISSION_DENIED`; a present-but-wrong credential, and +an anonymous request to a fully-locked server, get `UNAUTHENTICATED`. +Credentials are never logged (hard rule: secrets redacted). ## Configuration @@ -99,8 +116,12 @@ 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 +# One PHC hash per role. Guard from the top down: setting a role's hash +# lowers what an anonymous (no-credential) caller may do to the next +# role below. Omitting a role leaves it open, so anonymous callers get +# the highest omitted role. Setting a lower role without the higher one +# (e.g. queue_owner without owner) is rejected at startup. +# Generate with: crabidy-server guard owner = "$argon2id$v=19$m=19456,t=2,p=1$..." queue_owner = "$argon2id$v=19$..." queue_appender = "$argon2id$v=19$..." diff --git a/crabidy-server/src/auth.rs b/crabidy-server/src/auth.rs index fdb14ae..b26cb63 100644 --- a/crabidy-server/src/auth.rs +++ b/crabidy-server/src/auth.rs @@ -104,6 +104,13 @@ pub fn hash_password(password: &str) -> Result { pub struct Authenticator { /// `(role, PHC hash)` pairs from the config; empty = auth off. hashes: Vec<(Role, String)>, + /// The role granted to requests that carry no credentials: the most + /// privileged role left *unguarded* (no hash in the config), or + /// `None` when every role is guarded and anonymous access is denied. + /// Guarding runs from the top down (`AuthSettings::validate`), so + /// this is simply the first role, owner → queue-owner → + /// queue-appender, whose hash is absent. + unauthenticated: Option, verified: RwLock>, } @@ -128,8 +135,22 @@ impl Authenticator { } } } + // Anonymous callers inherit the highest role that is *not* + // guarded. A hash that is present but unusable still counts as + // guarded here (fail-closed): that level is neither reachable by + // login nor handed to anonymous callers. + let unauthenticated = if settings.owner.is_none() { + Some(Role::Owner) + } else if settings.queue_owner.is_none() { + Some(Role::QueueOwner) + } else if settings.queue_appender.is_none() { + Some(Role::QueueAppender) + } else { + None + }; Self { hashes, + unauthenticated, verified: RwLock::new(HashMap::new()), } } @@ -139,18 +160,28 @@ impl Authenticator { !self.hashes.is_empty() } + /// The role an anonymous (headerless) request receives — the most + /// privileged unguarded role, or `None` when every role is guarded. + pub fn unauthenticated_role(&self) -> Option { + self.unauthenticated + } + /// 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. + /// A request with **no** `authorization` header is anonymous and + /// receives [`Authenticator::unauthenticated_role`] — the most + /// privileged unguarded role — or is denied when every role is + /// guarded. A request that **does** carry a header is attempting to + /// authenticate; every way that can fail — wrong scheme, broken + /// base64, unknown user, wrong password — answers the same + /// `UNAUTHENTICATED` so callers cannot probe which part was wrong. + /// It never silently falls back to the anonymous role. Never panics + /// on input. pub fn authenticate(&self, header: Option<&str>) -> Result { - if !self.enabled() { - return Ok(Role::Owner); - } let denied = || Status::unauthenticated("credentials required"); - let header = header.ok_or_else(denied)?; + let Some(header) = header else { + return self.unauthenticated.ok_or_else(denied); + }; if let Some(role) = self .verified .read() @@ -371,9 +402,56 @@ mod tests { fn without_configured_hashes_everyone_is_owner() { let auth = Authenticator::new(&AuthSettings::default()); assert!(!auth.enabled()); + assert_eq!(auth.unauthenticated_role(), Some(Role::Owner)); assert_eq!(auth.authenticate(None).expect("open"), Role::Owner); } + #[test] + fn the_unauthenticated_role_is_the_highest_unguarded_one() { + let owner = hash("o"); + let qo = hash("qo"); + let qa = hash("qa"); + // Nothing guarded → owner. Owner guarded → queue-owner. Owner + + // queue-owner guarded → queue-appender. All guarded → denied. + let cases = [ + (None, None, None, Some(Role::Owner)), + (Some(&owner), None, None, Some(Role::QueueOwner)), + (Some(&owner), Some(&qo), None, Some(Role::QueueAppender)), + (Some(&owner), Some(&qo), Some(&qa), None), + ]; + for (owner, queue_owner, queue_appender, expected) in cases { + let auth = Authenticator::new(&AuthSettings { + owner: owner.cloned(), + queue_owner: queue_owner.cloned(), + queue_appender: queue_appender.cloned(), + }); + assert_eq!(auth.unauthenticated_role(), expected); + match expected { + Some(role) => assert_eq!(auth.authenticate(None).expect("anon"), role), + None => { + let err = auth.authenticate(None).expect_err("locked"); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + } + } + } + + #[test] + fn a_header_carrying_bad_credentials_never_falls_back_to_anonymous() { + // owner guarded, so anonymous callers are queue-owner — but a + // present-yet-wrong owner login is denied, not downgraded. + let auth = Authenticator::new(&AuthSettings { + owner: Some(hash("os")), + queue_owner: None, + queue_appender: None, + }); + assert_eq!(auth.unauthenticated_role(), Some(Role::QueueOwner)); + let err = auth + .authenticate(Some(&basic("owner", "wrong"))) + .expect_err("wrong password denied"); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + #[test] fn valid_credentials_resolve_their_role() { let auth = authenticator(); @@ -392,8 +470,10 @@ mod tests { #[test] fn every_failure_is_the_same_unauthenticated() { let auth = authenticator(); + // A missing header is *not* a failure here — it yields the + // anonymous role. Only present-but-bad headers are failures, and + // they must be indistinguishable. let cases: Vec> = vec![ - None, // no header Some("Bearer token".to_string()), // wrong scheme Some("Basic !!!not-base64!!!".to_string()), // broken base64 Some("Basic bm9jb2xvbg==".to_string()), // no colon @@ -487,17 +567,26 @@ mod tests { #[test] fn the_layer_forwards_authorized_requests_only() { + // owner + queue-owner guarded, appender open → anonymous callers + // are queue-appenders. 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. + // No credentials: the anonymous role (queue-appender) is enough + // for Append, so the handler runs. let (response, reached) = call_layer(auth.clone(), append, None); - assert!(!reached); - assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED"); + assert!(reached); + assert_eq!(response.body(), "handled"); - // Sufficient role: forwarded. - let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os"))); + // No credentials against an owner-only method: the anonymous role + // is insufficient, denied before the handler runs. + let (response, reached) = call_layer(auth.clone(), capture, None); + assert!(!reached); + assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED"); + + // Sufficient role via credentials: forwarded. + let (response, reached) = call_layer(auth.clone(), capture, Some(&basic("owner", "os"))); assert!(reached); assert_eq!(response.body(), "handled"); @@ -509,6 +598,17 @@ mod tests { assert!(!reached); assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED"); + // Fully locked (every role guarded): an anonymous request is + // denied with UNAUTHENTICATED before any handler runs. + let locked = Arc::new(Authenticator::new(&AuthSettings { + owner: Some(hash("os")), + queue_owner: Some(hash("qos")), + queue_appender: Some(hash("aps")), + })); + let (response, reached) = call_layer(locked, append, None); + assert!(!reached); + assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED"); + // Auth disabled: everything forwards without a header. let open = Arc::new(Authenticator::new(&AuthSettings::default())); let (_, reached) = call_layer(open, capture, None); diff --git a/crabidy-server/src/cli.rs b/crabidy-server/src/cli.rs index 073750b..1881d4d 100644 --- a/crabidy-server/src/cli.rs +++ b/crabidy-server/src/cli.rs @@ -74,6 +74,10 @@ pub fn write_role_hash(config_dir: &Path, role: Role, hash: String) -> Result<() Role::QueueOwner => settings.auth.queue_owner = Some(hash), Role::Appender => settings.auth.queue_appender = Some(hash), } + // Refuse to persist a config the server would reject on load: roles + // must be guarded from the top down (architecture/roles-auth.md). This + // catches `guard queue-owner` before `guard owner`. + settings.auth.validate()?; settings.store(config_dir) } @@ -260,15 +264,24 @@ mod tests { #[test] fn guard_writes_the_role_hash_and_preserves_others() { let dir = TempDir::new().expect("tempdir"); + // Guarding must run top-down; owner first, then queue-owner. write_role_hash(dir.path(), Role::Owner, "$argon2id$owner".to_string()).expect("owner"); - write_role_hash(dir.path(), Role::Appender, "$argon2id$app".to_string()).expect("app"); + write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string()).expect("qo"); let settings = ServerSettings::load(dir.path()).expect("reload"); assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner")); - assert_eq!( - settings.auth.queue_appender.as_deref(), - Some("$argon2id$app") - ); - assert!(settings.auth.queue_owner.is_none()); + assert_eq!(settings.auth.queue_owner.as_deref(), Some("$argon2id$qo")); + assert!(settings.auth.queue_appender.is_none()); + } + + #[test] + fn guard_refuses_to_write_a_role_below_an_unguarded_one() { + let dir = TempDir::new().expect("tempdir"); + // queue-owner without owner would let anonymous callers outrank it. + let err = write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string()) + .expect_err("must refuse"); + assert!(err.contains("owner"), "{err}"); + // Nothing was written, so the config still loads clean. + assert!(ServerSettings::load(dir.path()).is_ok()); } #[tokio::test] diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index e91c150..76eb974 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -86,6 +86,35 @@ impl AuthSettings { fn is_default(&self) -> bool { self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none() } + + /// Enforces the guarding order: roles must be locked from the most + /// privileged down. Anonymous callers inherit the highest *unguarded* + /// role (architecture/roles-auth.md), so guarding a lower role while a + /// higher one is open is meaningless — the anonymous role would still + /// outrank it. Such a config is a mistake, not a subtle preference, so + /// it aborts startup rather than running with a surprising posture. + /// + /// Valid guarded sets are prefixes of `[owner, queue_owner, + /// queue_appender]`: nothing, `owner`, `owner`+`queue_owner`, or all + /// three. `queue_owner` without `owner`, or `queue_appender` without + /// `queue_owner`, is rejected. + pub fn validate(&self) -> Result<(), String> { + if self.queue_owner.is_some() && self.owner.is_none() { + return Err( + "[auth] queue_owner is guarded but owner is not; guard roles from most to \ + least privileged (owner, then queue_owner, then queue_appender)" + .to_string(), + ); + } + if self.queue_appender.is_some() && self.queue_owner.is_none() { + return Err( + "[auth] queue_appender is guarded but queue_owner is not; guard roles from most \ + to least privileged (owner, then queue_owner, then queue_appender)" + .to_string(), + ); + } + Ok(()) + } } impl ServerSettings { @@ -103,7 +132,15 @@ impl ServerSettings { } Err(err) => return Err(format!("cannot read {}: {err}", file.display())), }; - toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) + let settings: ServerSettings = + toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))?; + // A broken guarding order is fail-closed like any other parse error: + // abort startup rather than run with a surprising auth posture. + settings + .auth + .validate() + .map_err(|err| format!("invalid {}: {err}", file.display()))?; + Ok(settings) } /// Whether the named provider is enabled: every provider when the @@ -179,16 +216,75 @@ mod tests { let dir = TempDir::new().expect("tempdir"); std::fs::write( dir.path().join(SETTINGS_FILE), - "[auth]\nqueue_appender = \"$argon2id$fake\"\n", + "[auth]\nowner = \"$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()); + assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake")); + assert!(settings.auth.queue_owner.is_none()); + assert!(settings.auth.queue_appender.is_none()); + } + + #[test] + fn validate_accepts_top_down_guarded_prefixes() { + // Every valid guarded set is a prefix of owner → queue_owner → + // queue_appender. + let owner = "$argon2id$o".to_string(); + let qo = "$argon2id$qo".to_string(); + let qa = "$argon2id$qa".to_string(); + let cases = [ + AuthSettings::default(), + AuthSettings { + owner: Some(owner.clone()), + queue_owner: None, + queue_appender: None, + }, + AuthSettings { + owner: Some(owner.clone()), + queue_owner: Some(qo.clone()), + queue_appender: None, + }, + AuthSettings { + owner: Some(owner), + queue_owner: Some(qo), + queue_appender: Some(qa), + }, + ]; + for case in cases { + assert!(case.validate().is_ok(), "{case:?}"); + } + } + + #[test] + fn validate_rejects_a_role_guarded_below_an_open_one() { + // queue_owner without owner. + let broken = AuthSettings { + owner: None, + queue_owner: Some("$argon2id$qo".to_string()), + queue_appender: None, + }; + assert!(broken.validate().is_err()); + // queue_appender without queue_owner. + let broken = AuthSettings { + owner: Some("$argon2id$o".to_string()), + queue_owner: None, + queue_appender: Some("$argon2id$qa".to_string()), + }; + assert!(broken.validate().is_err()); + } + + #[test] + fn a_broken_guarding_order_aborts_load() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(SETTINGS_FILE), + "[auth]\nqueue_appender = \"$argon2id$fake\"\n", + ) + .expect("write"); + let err = ServerSettings::load(dir.path()).expect_err("must reject"); + assert!(err.contains("crabidy-server.toml"), "{err}"); + assert!(err.contains("queue_appender"), "{err}"); } #[test] diff --git a/crabidy-server/tests/web_server.rs b/crabidy-server/tests/web_server.rs index f5a9537..51830af 100644 --- a/crabidy-server/tests/web_server.rs +++ b/crabidy-server/tests/web_server.rs @@ -88,13 +88,14 @@ async fn unknown_get_paths_fall_back_to_the_shell() { #[tokio::test] async fn grpc_web_calls_route_through_the_auth_layer() { - // A credentialed server: an unauthenticated gRPC-web POST must be - // rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before* - // reaching a handler — so the dead channels never matter. + // A fully-locked server (every role guarded, so anonymous callers get + // nothing): an unauthenticated gRPC-web POST must be rejected by the + // layer (gRPC status UNAUTHENTICATED = 16) *before* reaching a handler + // — so the dead channels never matter. let auth = Authenticator::new(&AuthSettings { owner: Some(hash("pw")), - queue_owner: None, - queue_appender: None, + queue_owner: Some(hash("qo")), + queue_appender: Some(hash("qa")), }); let router = crabidy_server::build_router(service(), Arc::new(auth)); let response = router diff --git a/docs/src/auth.md b/docs/src/auth.md index f068633..581f995 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -7,9 +7,12 @@ who can reach the port has full control — not only playback, but library writes such as renaming or deleting your saves. This is the right default on a trusted home network with no config to write. -Adding an `[auth]` section to `crabidy-server.toml` turns authorization -on. From then on the server requires HTTP basic auth on **every** RPC -and grants each caller one of three roles. +Adding password hashes to the `[auth]` section of `crabidy-server.toml` +locks the server down **from the top**. Each password you set lowers +what a caller with **no credentials** may do; a caller *with* a matching +password is elevated to that role. You lock the powerful roles first and +leave the weaker ones open for anyone on your network — see +[What anonymous callers get](#what-anonymous-callers-get) below. ## The three roles @@ -42,15 +45,39 @@ like this: ```toml [auth] -# One PHC hash per role; omit a role to leave it disabled. +# One PHC hash per role. Guard from the top down. owner = "$argon2id$v=19$m=19456,t=2,p=1$..." queue_owner = "$argon2id$v=19$..." queue_appender = "$argon2id$v=19$..." ``` -Authorization switches on as soon as **any** role hash is present. Omit -a role's key and that role cannot authenticate. A role's PHC hash — not -its password — is what lives in the file. +A role's PHC hash — not its password — is what lives in the file. Omit a +role's key to leave that role **open**: no password is needed to act as +it, and no one can log in as it. + +## What anonymous callers get + +A request with no credentials is not rejected — it is granted the +**most powerful role you did *not* guard**. Setting a password walks +that floor down one step: + +| Guarded roles | A no-credential caller is… | +| --- | --- | +| *(none — no `[auth]`)* | **owner** — the open default | +| `owner` | **queue-owner** | +| `owner` + `queue_owner` | **queue-appender** | +| all three | *nothing* — credentials required | + +So to run a server where guests may append but a password is needed to +run the queue, guard `owner` **and** `queue_owner` and leave +`queue_appender` open. + +Because the anonymous caller always gets the highest open role, guarding +a weaker role while a stronger one is still open would be pointless — the +anonymous role would simply outrank it. Such a config is treated as a +mistake: setting `queue_owner` without `owner`, or `queue_appender` +without `queue_owner`, **aborts startup**, and `crabidy-server guard` +refuses to write it. Guard the roles from the top down. ## How enforcement behaves @@ -59,18 +86,23 @@ its password — is what lives in the file. requires the owner role until it is explicitly mapped to a lower one. No handler ever sees an unauthorized request. - **Startup is strict.** A `crabidy-server.toml` that exists but does - not parse aborts server startup rather than silently running open — - a broken auth config never downgrades to no auth. + not parse — or that guards the roles out of order — aborts server + startup rather than silently running open: a broken auth config never + downgrades to a weaker posture than intended. - **Clients send the role as the username.** A client authenticates by sending the role name (`owner`, `queue-owner`, `queue-appender`) as the basic-auth user and the role's password as the basic-auth password. Set these with the client `auth` subcommand or in the client config's `[server]` table (see [Configuration](./config.md)). - With no credentials configured the client sends no header, which keeps - the zero-config local setup working against an open server. -- Denied requests get gRPC `PERMISSION_DENIED`; missing or wrong - credentials get `UNAUTHENTICATED`, with every authentication failure - answering identically so a caller cannot probe which part was wrong. + With no credentials configured the client sends no header and is + treated as the anonymous role — which keeps the zero-config local + setup working against an open server. +- A caller below a method's required role — whether anonymous or logged + in — gets gRPC `PERMISSION_DENIED`. A wrong password (and an anonymous + request to a fully-locked server) gets `UNAUTHENTICATED`, with every + authentication failure answering identically so a caller cannot probe + which part was wrong. A wrong password is never quietly downgraded to + the anonymous role. ```admonish warning The transport is plain HTTP/2 — basic auth travels in the clear. This is diff --git a/plan/roles-auth.md b/plan/roles-auth.md index 603f164..6023f58 100644 --- a/plan/roles-auth.md +++ b/plan/roles-auth.md @@ -37,3 +37,28 @@ From `architecture/roles-auth.md` and `quality/roles-auth.md`. cross-links. Verify: markdownlint. - [x] **Gates**: run the full suite + clippy + fmt; check off `quality/roles-auth.md`; write `plan/summary.md` section. + +## Refinement — anonymous callers inherit the highest unguarded role + +Superseding the original "any hash ⇒ every RPC requires credentials" +switch. Anonymous (no-header) callers now get the most privileged +*unguarded* role; a password lowers that floor. Guarding order is +enforced top-down. + +- [x] **Unauthenticated role** (`auth.rs`): `Authenticator` computes and + exposes `unauthenticated_role() -> Option` (highest role with + no hash; `None` when all guarded, counting a present-but-unusable + hash as guarded, fail-closed). `authenticate(None)` returns it or + denies; a present-but-wrong header still denies, never downgrades. + Verify: `the_unauthenticated_role_is_the_highest_unguarded_one`, + `a_header_carrying_bad_credentials_never_falls_back_to_anonymous`, + updated layer + failures tests. +- [x] **Guarding-order validation** (`settings.rs`): + `AuthSettings::validate()` rejects a lower role guarded without the + higher one (prefix of `[owner, queue_owner, queue_appender]`); + `load` aborts on a bad order, and `cli::write_role_hash` refuses to + write one. Verify: `validate_*` unit tests, `a_broken_guarding_ + order_aborts_load`, `guard_refuses_to_write_a_role_below_an_ + unguarded_one`. +- [x] **Docs**: `architecture/roles-auth.md`, `quality/roles-auth.md`, + `docs/src/auth.md`, root README updated to the top-down model. diff --git a/quality/roles-auth.md b/quality/roles-auth.md index 7b0a1e5..5034163 100644 --- a/quality/roles-auth.md +++ b/quality/roles-auth.md @@ -10,6 +10,13 @@ live in `crabidy-server/src/auth.rs`, `crabidy-server/src/main.rs` 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] Guarding order enforced: valid guarded sets are prefixes of + `[owner, queue_owner, queue_appender]`. A config guarding a lower + role while a higher one is open (`queue_owner` without `owner`, or + `queue_appender` without `queue_owner`) aborts startup, and + `crabidy-server guard` refuses to write it. +- [x] A present-but-wrong credential is denied (`UNAUTHENTICATED`), + never silently downgraded to the anonymous role. - [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`, @@ -30,11 +37,17 @@ live in `crabidy-server/src/auth.rs`, `crabidy-server/src/main.rs` `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] A caller (anonymous or credentialed) below a method's minimum + role gets `PERMISSION_DENIED` (not `UNAUTHENTICATED`). +- [x] Anonymous callers inherit the highest *unguarded* role: nothing + guarded ⇒ owner (today's open server); `owner` guarded ⇒ + queue-owner; `owner`+`queue_owner` guarded ⇒ queue-appender; all + guarded ⇒ nothing (`UNAUTHENTICATED`). A credential elevates above + that floor. - [x] No `[auth]` hashes ⇒ exactly today's behavior: no header required, all methods allowed. -- [x] A role without a configured hash cannot authenticate. +- [x] A role without a configured hash cannot authenticate (a login + attempt as it is denied; anonymous callers may still inherit it). ## Performance