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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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$..."
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue