618 lines
23 KiB
Rust
618 lines
23 KiB
Rust
//! Role-based authorization for the gRPC surface
|
|
//! (architecture/roles-auth.md).
|
|
//!
|
|
//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer
|
|
//! in front of the tonic service. It authenticates the HTTP basic-auth
|
|
//! header against the configured role hashes and checks the resulting
|
|
//! [`Role`] against the method's [`minimum_role`] — *before* any
|
|
//! handler runs, default-deny for methods it does not know. Handlers
|
|
//! never see unauthorized requests and did not change for this feature.
|
|
//!
|
|
//! Credentials never appear in logs or error messages.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::task::{Context, Poll};
|
|
|
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
|
use base64::Engine;
|
|
use futures::future::{ready, Either, Ready};
|
|
use tonic::Status;
|
|
use tracing::warn;
|
|
|
|
use crate::settings::AuthSettings;
|
|
|
|
/// The three roles, ordered by privilege: every role includes the
|
|
/// rights of the roles below it.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum Role {
|
|
/// May read and append tracks to the queue (plus create search
|
|
/// terms — the mechanism of finding something to append).
|
|
QueueAppender,
|
|
/// Anything on the queue and playback, but no library writes.
|
|
QueueOwner,
|
|
/// The normal user: everything.
|
|
Owner,
|
|
}
|
|
|
|
impl Role {
|
|
/// The basic-auth user name selecting this role.
|
|
fn from_user(user: &str) -> Option<Role> {
|
|
match user {
|
|
"owner" => Some(Role::Owner),
|
|
"queue-owner" => Some(Role::QueueOwner),
|
|
"queue-appender" => Some(Role::QueueAppender),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Role::Owner => "owner",
|
|
Role::QueueOwner => "queue-owner",
|
|
Role::QueueAppender => "queue-appender",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The gRPC path prefix of our service's methods.
|
|
const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/";
|
|
|
|
/// Minimum role required for a gRPC request path (the rights matrix,
|
|
/// architecture/roles-auth.md). Unknown methods — including anything
|
|
/// outside our service — require [`Role::Owner`]: fail-closed, a
|
|
/// future RPC starts locked until it is mapped here (a test pins the
|
|
/// full method list, so forgetting fails the suite).
|
|
pub fn minimum_role(grpc_path: &str) -> Role {
|
|
let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else {
|
|
return Role::Owner;
|
|
};
|
|
match method {
|
|
// Reads, the one appender queue verb, and search-term creation.
|
|
"Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => {
|
|
Role::QueueAppender
|
|
}
|
|
// Every other queue and playback verb.
|
|
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
|
|
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
|
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner,
|
|
// Library writes (CaptureLibraryNode, SaveQueue,
|
|
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
|
_ => Role::Owner,
|
|
}
|
|
}
|
|
|
|
/// Hashes a password into the PHC string `crabidy-server.toml` expects
|
|
/// (argon2id, default parameters, fresh random salt). Backs the
|
|
/// `crabidy-server hash-password` helper.
|
|
pub fn hash_password(password: &str) -> Result<String, String> {
|
|
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
|
use argon2::PasswordHasher;
|
|
Argon2::default()
|
|
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
|
// The error is parameter trouble, never the password itself.
|
|
.map(|hash| hash.to_string())
|
|
.map_err(|err| format!("cannot hash password: {err}"))
|
|
}
|
|
|
|
/// Verifies basic-auth credentials against the configured role hashes.
|
|
///
|
|
/// Argon2 verification is deliberately slow, so *successful* header
|
|
/// values are cached (value → role); the cache is fed only by
|
|
/// successes, bounding it by the number of valid credentials. Failures
|
|
/// re-verify every time, which doubles as throttling.
|
|
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>>,
|
|
}
|
|
|
|
impl Authenticator {
|
|
pub fn new(settings: &AuthSettings) -> Self {
|
|
let mut hashes = Vec::new();
|
|
for (role, hash) in [
|
|
(Role::Owner, &settings.owner),
|
|
(Role::QueueOwner, &settings.queue_owner),
|
|
(Role::QueueAppender, &settings.queue_appender),
|
|
] {
|
|
if let Some(hash) = hash {
|
|
// Reject unusable hashes at startup, when the operator
|
|
// is looking — not at the first login attempt.
|
|
if let Err(err) = PasswordHash::new(hash) {
|
|
warn!(
|
|
role = role.name(),
|
|
"unusable password hash in config: {err}"
|
|
);
|
|
} else {
|
|
hashes.push((role, hash.clone()));
|
|
}
|
|
}
|
|
}
|
|
// 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()),
|
|
}
|
|
}
|
|
|
|
/// Whether any credential is configured (the auth on/off switch).
|
|
pub fn enabled(&self) -> bool {
|
|
!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.
|
|
///
|
|
/// 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> {
|
|
let denied = || Status::unauthenticated("credentials required");
|
|
let Some(header) = header else {
|
|
return self.unauthenticated.ok_or_else(denied);
|
|
};
|
|
if let Some(role) = self
|
|
.verified
|
|
.read()
|
|
.ok()
|
|
.and_then(|cache| cache.get(header).copied())
|
|
{
|
|
return Ok(role);
|
|
}
|
|
let encoded = header
|
|
.strip_prefix("Basic ")
|
|
.or_else(|| header.strip_prefix("basic "))
|
|
.ok_or_else(denied)?;
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded.trim())
|
|
.map_err(|_| denied())?;
|
|
let decoded = String::from_utf8(decoded).map_err(|_| denied())?;
|
|
let (user, password) = decoded.split_once(':').ok_or_else(denied)?;
|
|
let role = Role::from_user(user).ok_or_else(denied)?;
|
|
let hash = self
|
|
.hashes
|
|
.iter()
|
|
.find(|(r, _)| *r == role)
|
|
.map(|(_, h)| h)
|
|
.ok_or_else(denied)?;
|
|
// Validated in `new`; a parse failure here is unreachable but
|
|
// must still deny, not panic.
|
|
let parsed = PasswordHash::new(hash).map_err(|_| denied())?;
|
|
Argon2::default()
|
|
.verify_password(password.as_bytes(), &parsed)
|
|
.map_err(|_| denied())?;
|
|
if let Ok(mut cache) = self.verified.write() {
|
|
cache.insert(header.to_string(), role);
|
|
}
|
|
Ok(role)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn cached(&self) -> usize {
|
|
self.verified.read().map(|c| c.len()).unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
/// Tower layer installing [`AuthService`] in front of the tonic
|
|
/// service.
|
|
#[derive(Clone)]
|
|
pub struct AuthLayer {
|
|
auth: Arc<Authenticator>,
|
|
}
|
|
|
|
impl AuthLayer {
|
|
pub fn new(auth: Arc<Authenticator>) -> Self {
|
|
Self { auth }
|
|
}
|
|
}
|
|
|
|
impl<S> tower::Layer<S> for AuthLayer {
|
|
type Service = AuthService<S>;
|
|
|
|
fn layer(&self, inner: S) -> Self::Service {
|
|
AuthService {
|
|
inner,
|
|
auth: self.auth.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The single authorization gate: authenticates the header, compares
|
|
/// the role against [`minimum_role`] of the request path, and either
|
|
/// forwards to the inner service or answers a trailers-only gRPC error
|
|
/// (`UNAUTHENTICATED` / `PERMISSION_DENIED`) without running any
|
|
/// handler.
|
|
#[derive(Clone)]
|
|
pub struct AuthService<S> {
|
|
inner: S,
|
|
auth: Arc<Authenticator>,
|
|
}
|
|
|
|
impl<S, ReqBody, ResBody> tower::Service<http::Request<ReqBody>> for AuthService<S>
|
|
where
|
|
S: tower::Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
|
|
ResBody: Default,
|
|
{
|
|
type Response = S::Response;
|
|
type Error = S::Error;
|
|
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
|
|
|
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
self.inner.poll_ready(cx)
|
|
}
|
|
|
|
fn call(&mut self, req: http::Request<ReqBody>) -> Self::Future {
|
|
// The header value itself is a secret and is never logged.
|
|
let header = req
|
|
.headers()
|
|
.get(http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok());
|
|
let decision = self.auth.authenticate(header).and_then(|role| {
|
|
let needed = minimum_role(req.uri().path());
|
|
if role >= needed {
|
|
Ok(())
|
|
} else {
|
|
Err(Status::permission_denied(format!(
|
|
"requires the {} role",
|
|
needed.name()
|
|
)))
|
|
}
|
|
});
|
|
match decision {
|
|
Ok(()) => Either::Left(self.inner.call(req)),
|
|
Err(status) => Either::Right(ready(Ok(status.into_http()))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
|
use argon2::PasswordHasher;
|
|
|
|
/// A PHC hash of `password` with cheap test parameters (the params
|
|
/// travel inside the PHC string, so default verification reads
|
|
/// them back).
|
|
fn hash(password: &str) -> String {
|
|
let params = argon2::Params::new(8, 1, 1, None).expect("params");
|
|
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
|
argon2
|
|
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
|
.expect("hash")
|
|
.to_string()
|
|
}
|
|
|
|
fn basic(user: &str, password: &str) -> String {
|
|
let encoded =
|
|
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
|
|
format!("Basic {encoded}")
|
|
}
|
|
|
|
fn authenticator() -> Authenticator {
|
|
Authenticator::new(&AuthSettings {
|
|
owner: Some(hash("os")),
|
|
queue_owner: Some(hash("qos")),
|
|
queue_appender: None,
|
|
})
|
|
}
|
|
|
|
// ---- the rights matrix -------------------------------------------
|
|
|
|
#[test]
|
|
fn the_method_table_pins_every_rpc_of_the_service() {
|
|
let appender = [
|
|
"Init",
|
|
"GetLibraryNode",
|
|
"GetUpdateStream",
|
|
"Append",
|
|
"CreateLibraryNode",
|
|
];
|
|
let queue_owner = [
|
|
"Queue",
|
|
"Replace",
|
|
"Remove",
|
|
"Insert",
|
|
"ClearQueue",
|
|
"SetCurrent",
|
|
"ToggleShuffle",
|
|
"ToggleRepeat",
|
|
"TogglePlay",
|
|
"Stop",
|
|
"ChangeVolume",
|
|
"ToggleMute",
|
|
"Next",
|
|
"Prev",
|
|
"RestartTrack",
|
|
];
|
|
let owner = [
|
|
"CaptureLibraryNode",
|
|
"SaveQueue",
|
|
"RenameLibraryNode",
|
|
"DeleteLibraryNode",
|
|
];
|
|
// The full service, from crabidy.proto — 24 methods. A new RPC
|
|
// must be added to exactly one list (and the layer keeps it
|
|
// owner-only until then).
|
|
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24);
|
|
for method in appender {
|
|
let path = format!("{SERVICE_PREFIX}{method}");
|
|
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");
|
|
}
|
|
for method in queue_owner {
|
|
let path = format!("{SERVICE_PREFIX}{method}");
|
|
assert_eq!(minimum_role(&path), Role::QueueOwner, "{method}");
|
|
}
|
|
for method in owner {
|
|
let path = format!("{SERVICE_PREFIX}{method}");
|
|
assert_eq!(minimum_role(&path), Role::Owner, "{method}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_methods_and_foreign_services_are_owner_only() {
|
|
assert_eq!(
|
|
minimum_role("/crabidy.v1.CrabidyService/BrandNewRpc"),
|
|
Role::Owner
|
|
);
|
|
assert_eq!(minimum_role("/grpc.health.v1.Health/Check"), Role::Owner);
|
|
assert_eq!(minimum_role("nonsense"), Role::Owner);
|
|
}
|
|
|
|
#[test]
|
|
fn roles_are_ordered_by_privilege() {
|
|
assert!(Role::Owner > Role::QueueOwner);
|
|
assert!(Role::QueueOwner > Role::QueueAppender);
|
|
}
|
|
|
|
// ---- authentication ----------------------------------------------
|
|
|
|
#[test]
|
|
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();
|
|
assert_eq!(
|
|
auth.authenticate(Some(&basic("owner", "os")))
|
|
.expect("owner"),
|
|
Role::Owner
|
|
);
|
|
assert_eq!(
|
|
auth.authenticate(Some(&basic("queue-owner", "qos")))
|
|
.expect("queue owner"),
|
|
Role::QueueOwner
|
|
);
|
|
}
|
|
|
|
#[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![
|
|
Some("Bearer token".to_string()), // wrong scheme
|
|
Some("Basic !!!not-base64!!!".to_string()), // broken base64
|
|
Some("Basic bm9jb2xvbg==".to_string()), // no colon
|
|
Some(basic("owner", "wrong")), // wrong password
|
|
Some(basic("dj", "os")), // unknown user
|
|
Some(basic("queue-appender", "anything")), // role without hash
|
|
];
|
|
let mut messages = Vec::new();
|
|
for case in &cases {
|
|
let err = auth
|
|
.authenticate(case.as_deref())
|
|
.expect_err(&format!("{case:?}"));
|
|
assert_eq!(err.code(), tonic::Code::Unauthenticated, "{case:?}");
|
|
messages.push(err.message().to_string());
|
|
}
|
|
assert!(
|
|
messages.windows(2).all(|w| w[0] == w[1]),
|
|
"failures must be indistinguishable"
|
|
);
|
|
assert_eq!(auth.cached(), 0, "failures are never cached");
|
|
}
|
|
|
|
#[test]
|
|
fn hash_password_output_round_trips_through_the_authenticator() {
|
|
let phc = hash_password("hunter2").expect("hash");
|
|
assert!(phc.starts_with("$argon2id$"), "PHC format");
|
|
let auth = Authenticator::new(&AuthSettings {
|
|
owner: Some(phc),
|
|
queue_owner: None,
|
|
queue_appender: None,
|
|
});
|
|
assert_eq!(
|
|
auth.authenticate(Some(&basic("owner", "hunter2")))
|
|
.expect("round trip"),
|
|
Role::Owner
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn successful_credentials_are_cached() {
|
|
let auth = authenticator();
|
|
let header = basic("owner", "os");
|
|
assert_eq!(auth.cached(), 0);
|
|
auth.authenticate(Some(&header)).expect("first");
|
|
assert_eq!(auth.cached(), 1);
|
|
auth.authenticate(Some(&header)).expect("cached");
|
|
assert_eq!(auth.cached(), 1, "same credential, one entry");
|
|
}
|
|
|
|
// ---- the layer -----------------------------------------------------
|
|
|
|
/// Calls the layered service once and returns the response plus
|
|
/// whether the inner service ran.
|
|
fn call_layer(
|
|
auth: Arc<Authenticator>,
|
|
path: &str,
|
|
header: Option<&str>,
|
|
) -> (http::Response<String>, bool) {
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use tower::{Layer, Service, ServiceExt};
|
|
let reached = Arc::new(AtomicBool::new(false));
|
|
let flag = reached.clone();
|
|
let inner = tower::service_fn(move |_req: http::Request<()>| {
|
|
flag.store(true, Ordering::SeqCst);
|
|
ready(Ok::<_, std::convert::Infallible>(http::Response::new(
|
|
"handled".to_string(),
|
|
)))
|
|
});
|
|
let mut service = AuthLayer::new(auth).layer(inner);
|
|
let mut req = http::Request::new(());
|
|
*req.uri_mut() = path.parse().expect("uri");
|
|
if let Some(header) = header {
|
|
req.headers_mut().insert(
|
|
http::header::AUTHORIZATION,
|
|
header.parse().expect("header value"),
|
|
);
|
|
}
|
|
let response = futures::executor::block_on(async {
|
|
service.ready().await.expect("ready").call(req).await
|
|
})
|
|
.expect("call");
|
|
(response, reached.load(Ordering::SeqCst))
|
|
}
|
|
|
|
fn grpc_status(response: &http::Response<String>) -> Option<&str> {
|
|
response
|
|
.headers()
|
|
.get("grpc-status")
|
|
.and_then(|v| v.to_str().ok())
|
|
}
|
|
|
|
#[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: 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!(response.body(), "handled");
|
|
|
|
// 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");
|
|
|
|
// Valid credentials, insufficient role: denied, handler never
|
|
// runs, and the code distinguishes authorization from
|
|
// authentication.
|
|
let (response, reached) =
|
|
call_layer(auth.clone(), capture, Some(&basic("queue-owner", "qos")));
|
|
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);
|
|
assert!(reached);
|
|
}
|
|
}
|