harden(auth): distinguish session-store failure (500) from absent session (401); exhaustive marker + verify_dummy tests

This commit is contained in:
2026-06-02 14:48:40 +02:00
parent 992526ef77
commit 4e7288731a
+21 -7
View File
@@ -73,6 +73,10 @@ pub enum AuthError {
Unauthenticated, Unauthenticated,
#[error("insufficient permissions")] #[error("insufficient permissions")]
Forbidden, Forbidden,
/// The session store itself failed (e.g. the database is unreachable) — distinct
/// from "no session", so an outage surfaces as 500 rather than a misleading 401.
#[error("session store unavailable")]
Internal,
} }
impl IntoResponse for AuthError { impl IntoResponse for AuthError {
@@ -80,6 +84,7 @@ impl IntoResponse for AuthError {
match self { match self {
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED, AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
AuthError::Forbidden => StatusCode::FORBIDDEN, AuthError::Forbidden => StatusCode::FORBIDDEN,
AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
} }
.into_response() .into_response()
} }
@@ -101,29 +106,30 @@ where
type Rejection = AuthError; type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// A failed extraction here means the SessionManagerLayer is missing from the
// stack — a wiring bug, not an auth failure: surface it as 500.
let session = Session::from_request_parts(parts, state) let session = Session::from_request_parts(parts, state)
.await .await
.map_err(|_| AuthError::Unauthenticated)?; .map_err(|_| AuthError::Internal)?;
// For each key: a store error (DB down) is `Internal` (500); an absent key is
// `Unauthenticated` (401) — these must not be conflated.
let id: uuid::Uuid = session let id: uuid::Uuid = session
.get(SESSION_USER_ID) .get(SESSION_USER_ID)
.await .await
.ok() .map_err(|_| AuthError::Internal)?
.flatten()
.ok_or(AuthError::Unauthenticated)?; .ok_or(AuthError::Unauthenticated)?;
let email: String = session let email: String = session
.get(SESSION_EMAIL) .get(SESSION_EMAIL)
.await .await
.ok() .map_err(|_| AuthError::Internal)?
.flatten()
.ok_or(AuthError::Unauthenticated)?; .ok_or(AuthError::Unauthenticated)?;
let role_str: String = session let role_str: String = session
.get(SESSION_ROLE) .get(SESSION_ROLE)
.await .await
.ok() .map_err(|_| AuthError::Internal)?
.flatten()
.ok_or(AuthError::Unauthenticated)?; .ok_or(AuthError::Unauthenticated)?;
let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?; let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?;
@@ -221,9 +227,17 @@ mod tests {
assert!(!verify_password("anything", "not-a-phc-string")); assert!(!verify_password("anything", "not-a-phc-string"));
} }
#[test]
fn verify_dummy_does_not_panic() {
verify_dummy("any input");
verify_dummy("called again"); // exercises the already-initialized OnceLock path
}
#[test] #[test]
fn capability_markers_map_to_domain_capabilities() { fn capability_markers_map_to_domain_capabilities() {
assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers); assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue);
assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects); assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal);
} }
} }