This repository has no description
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}