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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 16:06:01 +02:00
parent 5bcddb9027
commit a275d3bc77
9 changed files with 372 additions and 63 deletions

View File

@ -122,8 +122,10 @@ provider's own config file (`tidaly.toml`, etc.) is simply left unread.
#### Roles and rights #### Roles and rights
By default the server is open: everyone who can reach the port has 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 full control. Setting password hashes in `[auth]` locks the server
every RPC and hands out *roles* (see `architecture/roles-auth.md`): **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). - **owner** — everything (the normal user).
- **queue-owner** — anything on the queue and playback, but no - **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-appender** — may browse/search and append tracks to the
queue; nothing else. 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 ```toml
[auth] [auth]
# One PHC hash per role; omit a role to disable it. Generate and store # One PHC hash per role. Guard from the top down. Generate and store a
# a hash with: crabidy-server guard <role> (see the CLI section). # hash with: crabidy-server guard <role> (see the CLI section).
owner = "$argon2id$v=19$m=19456,t=2,p=1$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$..." queue_owner = "$argon2id$..."
queue_appender = "$argon2id$..." queue_appender = "$argon2id$..."
``` ```
Clients authenticate with the role name as the basic-auth user (see 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 startup rather than silently running open. Note that the transport is
plain HTTP/2: fine on a trusted home network, but anything exposed plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front. further needs TLS termination (reverse proxy, VPN) in front.

View File

@ -21,10 +21,26 @@ Three roles, credentialed by password hashes in the server config:
acceptable on a trusted home network; anything else (Internet acceptable on a trusted home network; anything else (Internet
exposure) needs TLS termination in front (reverse proxy, VPN) and is exposure) needs TLS termination in front (reverse proxy, VPN) and is
out of scope. The README says so. out of scope. The README says so.
- No auth configured (no `[auth]` section, or no hashes in it) means - **Anonymous callers inherit the highest *unguarded* role.** A request
the server behaves exactly as before: open, everyone is owner. Auth with no credentials is not rejected outright; it is granted the most
switches on as soon as **any** role hash is configured; from then on privileged role whose password is *not* set. So the config guards
every RPC requires credentials. 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* - One password per role, not per person. The Basic-auth *username*
selects the role (`owner`, `queue-owner`, `queue-appender`), the selects the role (`owner`, `queue-owner`, `queue-appender`), the
password is verified against that role's hash. 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). layer, so renames/deletes stay owner-only, fail-closed).
- **unknown / future methods**: owner only. - **unknown / future methods**: owner only.
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials A caller whose role (anonymous or credentialed) is below the method's
get `UNAUTHENTICATED`. Credentials are never logged (hard rule: minimum gets `PERMISSION_DENIED`; a present-but-wrong credential, and
secrets redacted). an anonymous request to a fully-locked server, get `UNAUTHENTICATED`.
Credentials are never logged (hard rule: secrets redacted).
## Configuration ## Configuration
@ -99,8 +116,12 @@ both standalone and inside `cbd`; absent file = auth off):
```toml ```toml
[auth] [auth]
# One PHC hash per role; omit a role to disable it. # One PHC hash per role. Guard from the top down: setting a role's hash
# Generate with: crabidy-server hash-password # lowers what an anonymous (no-credential) caller may do to the next
# role below. Omitting a role leaves it open, so anonymous callers get
# the highest omitted role. Setting a lower role without the higher one
# (e.g. queue_owner without owner) is rejected at startup.
# Generate with: crabidy-server guard <role>
owner = "$argon2id$v=19$m=19456,t=2,p=1$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..." queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..." queue_appender = "$argon2id$v=19$..."

View File

@ -104,6 +104,13 @@ pub fn hash_password(password: &str) -> Result<String, String> {
pub struct Authenticator { pub struct Authenticator {
/// `(role, PHC hash)` pairs from the config; empty = auth off. /// `(role, PHC hash)` pairs from the config; empty = auth off.
hashes: Vec<(Role, String)>, 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<Role>,
verified: RwLock<HashMap<String, Role>>, verified: RwLock<HashMap<String, Role>>,
} }
@ -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 { Self {
hashes, hashes,
unauthenticated,
verified: RwLock::new(HashMap::new()), verified: RwLock::new(HashMap::new()),
} }
} }
@ -139,18 +160,28 @@ impl Authenticator {
!self.hashes.is_empty() !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<Role> {
self.unauthenticated
}
/// Resolves the request's `authorization` header value to a role. /// Resolves the request's `authorization` header value to a role.
/// ///
/// With auth disabled everyone is [`Role::Owner`]. Every failure — /// A request with **no** `authorization` header is anonymous and
/// missing header, wrong scheme, broken base64, unknown user, /// receives [`Authenticator::unauthenticated_role`] — the most
/// wrong password — answers the same `UNAUTHENTICATED` so callers /// privileged unguarded role — or is denied when every role is
/// cannot probe which part was wrong. Never panics on input. /// 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<Role, Status> { pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
if !self.enabled() {
return Ok(Role::Owner);
}
let denied = || Status::unauthenticated("credentials required"); 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 if let Some(role) = self
.verified .verified
.read() .read()
@ -371,9 +402,56 @@ mod tests {
fn without_configured_hashes_everyone_is_owner() { fn without_configured_hashes_everyone_is_owner() {
let auth = Authenticator::new(&AuthSettings::default()); let auth = Authenticator::new(&AuthSettings::default());
assert!(!auth.enabled()); assert!(!auth.enabled());
assert_eq!(auth.unauthenticated_role(), Some(Role::Owner));
assert_eq!(auth.authenticate(None).expect("open"), 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] #[test]
fn valid_credentials_resolve_their_role() { fn valid_credentials_resolve_their_role() {
let auth = authenticator(); let auth = authenticator();
@ -392,8 +470,10 @@ mod tests {
#[test] #[test]
fn every_failure_is_the_same_unauthenticated() { fn every_failure_is_the_same_unauthenticated() {
let auth = authenticator(); 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<Option<String>> = vec![ let cases: Vec<Option<String>> = vec![
None, // no header
Some("Bearer token".to_string()), // wrong scheme Some("Bearer token".to_string()), // wrong scheme
Some("Basic !!!not-base64!!!".to_string()), // broken base64 Some("Basic !!!not-base64!!!".to_string()), // broken base64
Some("Basic bm9jb2xvbg==".to_string()), // no colon Some("Basic bm9jb2xvbg==".to_string()), // no colon
@ -487,17 +567,26 @@ mod tests {
#[test] #[test]
fn the_layer_forwards_authorized_requests_only() { fn the_layer_forwards_authorized_requests_only() {
// owner + queue-owner guarded, appender open → anonymous callers
// are queue-appenders.
let auth = Arc::new(authenticator()); let auth = Arc::new(authenticator());
let append = "http://s/crabidy.v1.CrabidyService/Append"; let append = "http://s/crabidy.v1.CrabidyService/Append";
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode"; 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); let (response, reached) = call_layer(auth.clone(), append, None);
assert!(!reached); assert!(reached);
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED"); assert_eq!(response.body(), "handled");
// Sufficient role: forwarded. // No credentials against an owner-only method: the anonymous role
let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os"))); // 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!(reached);
assert_eq!(response.body(), "handled"); assert_eq!(response.body(), "handled");
@ -509,6 +598,17 @@ mod tests {
assert!(!reached); assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED"); 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. // Auth disabled: everything forwards without a header.
let open = Arc::new(Authenticator::new(&AuthSettings::default())); let open = Arc::new(Authenticator::new(&AuthSettings::default()));
let (_, reached) = call_layer(open, capture, None); let (_, reached) = call_layer(open, capture, None);

View File

@ -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::QueueOwner => settings.auth.queue_owner = Some(hash),
Role::Appender => settings.auth.queue_appender = 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) settings.store(config_dir)
} }
@ -260,15 +264,24 @@ mod tests {
#[test] #[test]
fn guard_writes_the_role_hash_and_preserves_others() { fn guard_writes_the_role_hash_and_preserves_others() {
let dir = TempDir::new().expect("tempdir"); 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::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"); let settings = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner")); assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner"));
assert_eq!( assert_eq!(settings.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
settings.auth.queue_appender.as_deref(), assert!(settings.auth.queue_appender.is_none());
Some("$argon2id$app") }
);
assert!(settings.auth.queue_owner.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] #[tokio::test]

View File

@ -86,6 +86,35 @@ impl AuthSettings {
fn is_default(&self) -> bool { fn is_default(&self) -> bool {
self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none() 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 { impl ServerSettings {
@ -103,7 +132,15 @@ impl ServerSettings {
} }
Err(err) => return Err(format!("cannot read {}: {err}", file.display())), 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 /// Whether the named provider is enabled: every provider when the
@ -179,16 +216,75 @@ mod tests {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
std::fs::write( std::fs::write(
dir.path().join(SETTINGS_FILE), dir.path().join(SETTINGS_FILE),
"[auth]\nqueue_appender = \"$argon2id$fake\"\n", "[auth]\nowner = \"$argon2id$fake\"\n",
) )
.expect("write"); .expect("write");
let settings = ServerSettings::load(dir.path()).expect("parse"); let settings = ServerSettings::load(dir.path()).expect("parse");
assert!(settings.auth.enabled()); assert!(settings.auth.enabled());
assert_eq!( assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake"));
settings.auth.queue_appender.as_deref(), assert!(settings.auth.queue_owner.is_none());
Some("$argon2id$fake") assert!(settings.auth.queue_appender.is_none());
); }
assert!(settings.auth.owner.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] #[test]

View File

@ -88,13 +88,14 @@ async fn unknown_get_paths_fall_back_to_the_shell() {
#[tokio::test] #[tokio::test]
async fn grpc_web_calls_route_through_the_auth_layer() { async fn grpc_web_calls_route_through_the_auth_layer() {
// A credentialed server: an unauthenticated gRPC-web POST must be // A fully-locked server (every role guarded, so anonymous callers get
// rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before* // nothing): an unauthenticated gRPC-web POST must be rejected by the
// reaching a handler — so the dead channels never matter. // layer (gRPC status UNAUTHENTICATED = 16) *before* reaching a handler
// — so the dead channels never matter.
let auth = Authenticator::new(&AuthSettings { let auth = Authenticator::new(&AuthSettings {
owner: Some(hash("pw")), owner: Some(hash("pw")),
queue_owner: None, queue_owner: Some(hash("qo")),
queue_appender: None, queue_appender: Some(hash("qa")),
}); });
let router = crabidy_server::build_router(service(), Arc::new(auth)); let router = crabidy_server::build_router(service(), Arc::new(auth));
let response = router let response = router

View File

@ -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 writes such as renaming or deleting your saves. This is the right
default on a trusted home network with no config to write. default on a trusted home network with no config to write.
Adding an `[auth]` section to `crabidy-server.toml` turns authorization Adding password hashes to the `[auth]` section of `crabidy-server.toml`
on. From then on the server requires HTTP basic auth on **every** RPC locks the server down **from the top**. Each password you set lowers
and grants each caller one of three roles. 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 ## The three roles
@ -42,15 +45,39 @@ like this:
```toml ```toml
[auth] [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$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..." queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..." queue_appender = "$argon2id$v=19$..."
``` ```
Authorization switches on as soon as **any** role hash is present. Omit A role's PHC hash — not its password — is what lives in the file. Omit a
a role's key and that role cannot authenticate. A role's PHC hash — not role's key to leave that role **open**: no password is needed to act as
its password — is what lives in the file. 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 ## 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. requires the owner role until it is explicitly mapped to a lower one.
No handler ever sees an unauthorized request. No handler ever sees an unauthorized request.
- **Startup is strict.** A `crabidy-server.toml` that exists but does - **Startup is strict.** A `crabidy-server.toml` that exists but does
not parse aborts server startup rather than silently running open — not parse — or that guards the roles out of order — aborts server
a broken auth config never downgrades to no auth. 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 - **Clients send the role as the username.** A client authenticates by
sending the role name (`owner`, `queue-owner`, `queue-appender`) as sending the role name (`owner`, `queue-owner`, `queue-appender`) as
the basic-auth user and the role's password as the basic-auth the basic-auth user and the role's password as the basic-auth
password. Set these with the client `auth` subcommand or in the password. Set these with the client `auth` subcommand or in the
client config's `[server]` table (see [Configuration](./config.md)). client config's `[server]` table (see [Configuration](./config.md)).
With no credentials configured the client sends no header, which keeps With no credentials configured the client sends no header and is
the zero-config local setup working against an open server. treated as the anonymous role — which keeps the zero-config local
- Denied requests get gRPC `PERMISSION_DENIED`; missing or wrong setup working against an open server.
credentials get `UNAUTHENTICATED`, with every authentication failure - A caller below a method's required role — whether anonymous or logged
answering identically so a caller cannot probe which part was wrong. 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 ```admonish warning
The transport is plain HTTP/2 — basic auth travels in the clear. This is The transport is plain HTTP/2 — basic auth travels in the clear. This is

View File

@ -37,3 +37,28 @@ From `architecture/roles-auth.md` and `quality/roles-auth.md`.
cross-links. Verify: markdownlint. cross-links. Verify: markdownlint.
- [x] **Gates**: run the full suite + clippy + fmt; check off - [x] **Gates**: run the full suite + clippy + fmt; check off
`quality/roles-auth.md`; write `plan/summary.md` section. `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<Role>` (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.

View File

@ -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 owner; a present-but-malformed `crabidy-server.toml` aborts server
startup instead of silently running open; a missing file (or empty startup instead of silently running open; a missing file (or empty
`[auth]`) is the documented open mode. `[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 - [x] No panics on request input: malformed `authorization` headers
(bad base64, missing colon, non-UTF-8, wrong scheme), unknown (bad base64, missing colon, non-UTF-8, wrong scheme), unknown
role names, and oversized values all produce `UNAUTHENTICATED`, 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 `RenameLibraryNode`, `DeleteLibraryNode`. Higher roles include
lower ones. A test pins the full method list of the proto service lower ones. A test pins the full method list of the proto service
so an unmapped new RPC fails the suite. so an unmapped new RPC fails the suite.
- [x] Valid credentials with an insufficient role get - [x] A caller (anonymous or credentialed) below a method's minimum
`PERMISSION_DENIED` (not `UNAUTHENTICATED`). 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 - [x] No `[auth]` hashes ⇒ exactly today's behavior: no header
required, all methods allowed. 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 ## Performance