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
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 <role> (see the CLI section).
# One PHC hash per role. Guard from the top down. Generate and store a
# hash with: crabidy-server guard <role> (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.

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
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 <role>
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$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 {
/// `(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<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 {
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<Role> {
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<Role, Status> {
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<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
@ -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);

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::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]

View File

@ -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]

View File

@ -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

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
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

View File

@ -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<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
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