This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / src / legacy_admin.rs
4.5 kB 147 lines
1use std::sync::Arc; 2 3use axum::Router; 4use axum::body::Bytes; 5use axum::extract::{DefaultBodyLimit, State}; 6use axum::middleware::from_fn_with_state; 7use axum::response::Response; 8use axum::routing::post; 9use http::HeaderMap; 10use subtle::ConstantTimeEq; 11use zeroize::Zeroizing; 12 13use knot_cobs::Grant; 14use knot_runtime::{Clock, HttpTransport}; 15 16use crate::error::XrpcError; 17use crate::members::{SubjectInput, grant_membership}; 18use crate::{XrpcState, basic_credentials, decode, enforce_pre_auth_limit}; 19 20pub const ADD_MEMBER_ROUTE: &str = "/admin/addMember"; 21 22const BASIC_USER: &str = "admin"; 23 24#[derive(Debug, thiserror::Error)] 25#[error("legacy admin secret mustn't be empty")] 26pub struct EmptySecret; 27 28pub struct LegacyAdminSecret(Zeroizing<String>); 29 30impl LegacyAdminSecret { 31 pub fn new(value: &str) -> Result<Self, EmptySecret> { 32 let value = value.trim(); 33 match value.is_empty() { 34 true => Err(EmptySecret), 35 false => Ok(Self(Zeroizing::new(value.to_string()))), 36 } 37 } 38 39 fn authorize(&self, headers: &HeaderMap) -> Result<(), XrpcError> { 40 let denied = || XrpcError::auth_required("invalid admin credentials"); 41 let credentials = headers 42 .get(http::header::AUTHORIZATION) 43 .and_then(|value| value.to_str().ok()) 44 .and_then(basic_credentials) 45 .ok_or_else(denied)?; 46 let admitted = credentials.user.matches(BASIC_USER) 47 && bool::from(credentials.password.as_bytes().ct_eq(self.0.as_bytes())); 48 match admitted { 49 true => Ok(()), 50 false => Err(denied()), 51 } 52 } 53} 54 55struct LegacyAdmin<H, C> { 56 state: Arc<XrpcState<H, C>>, 57 secret: LegacyAdminSecret, 58} 59 60pub fn router<H: HttpTransport, C: Clock>( 61 state: Arc<XrpcState<H, C>>, 62 secret: LegacyAdminSecret, 63) -> Router { 64 let limits = state.byte_limits.body.get(); 65 let limiter = Arc::clone(&state); 66 Router::new() 67 .route(ADD_MEMBER_ROUTE, post(add_member::<H, C>)) 68 .layer(DefaultBodyLimit::max(limits)) 69 .layer(from_fn_with_state(limiter, enforce_pre_auth_limit::<H, C>)) 70 .with_state(Arc::new(LegacyAdmin { state, secret })) 71} 72 73async fn add_member<H: HttpTransport, C: Clock>( 74 State(admin): State<Arc<LegacyAdmin<H, C>>>, 75 headers: HeaderMap, 76 body: Bytes, 77) -> Result<Response, XrpcError> { 78 admin.secret.authorize(&headers)?; 79 let SubjectInput { subject } = decode(&body)?; 80 tracing::warn!( 81 route = ADD_MEMBER_ROUTE, 82 %subject, 83 "legacy admin route authorized a member grant" 84 ); 85 grant_membership( 86 &admin.state, 87 Grant { 88 subject, 89 added_by: admin.state.service_owner.clone(), 90 created_at: admin.state.now(), 91 }, 92 ) 93 .await 94} 95 96#[cfg(test)] 97mod tests { 98 use super::*; 99 100 use base64::Engine; 101 102 fn header(value: &str) -> HeaderMap { 103 let mut headers = HeaderMap::new(); 104 headers.insert( 105 http::header::AUTHORIZATION, 106 value.parse().expect("header is ascii"), 107 ); 108 headers 109 } 110 111 fn basic(scheme: &str, user: &str, password: &str) -> HeaderMap { 112 let encoded = base64::engine::general_purpose::STANDARD 113 .encode(format!("{user}:{password}").as_bytes()); 114 header(&format!("{scheme} {encoded}")) 115 } 116 117 #[test] 118 fn a_trimmed_secret_admits_only_the_admin_user_sending_it_exactly() { 119 assert!(LegacyAdminSecret::new(" ").is_err()); 120 let secret = LegacyAdminSecret::new("\tnekomilk2\n").expect("secret is non-empty"); 121 122 let admitted = 123 ["Basic", "basic", "BASIC"].map(|scheme| basic(scheme, "admin", "nekomilk2")); 124 let refused = [ 125 basic("Basic", "admin", "\tnekomilk2\n"), 126 basic("Basic", "admin", "nope"), 127 basic("Basic", "root", "nekomilk2"), 128 basic("Basic", "admin", ""), 129 header("Basic !!!not-base64!!!"), 130 header("Bearer nekomilk2"), 131 header("nekomilk2"), 132 HeaderMap::new(), 133 ]; 134 assert!( 135 admitted 136 .iter() 137 .all(|headers| secret.authorize(headers).is_ok()), 138 "authorize admits the trimmed secret under any case of the Basic scheme" 139 ); 140 assert!( 141 refused 142 .iter() 143 .all(|headers| secret.authorize(headers).is_err()), 144 "authorize refuses an untrimmed, wrong, empty or malformed credential" 145 ); 146 } 147}