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:
parent
5bcddb9027
commit
a275d3bc77
18
README.md
18
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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$..."
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue