1use std::sync::Arc;
2
3use alopex_cluster::{
4 AuthenticatedSubject, LocalReadAuthorizationRecheck, LocalReadAuthorizationRequest,
5};
6use axum::http::HeaderMap;
7use serde::{Deserialize, Serialize};
8use tonic::metadata::MetadataMap;
9
10const ANONYMOUS_SUBJECT: &str = "anonymous";
11const DEV_SUBJECT: &str = "dev";
12
13#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum AuthMode {
17 #[default]
19 None,
20 Dev { api_key: String },
22}
23
24#[derive(Debug, thiserror::Error)]
26pub enum AuthError {
27 #[error("missing credentials")]
28 Missing,
29 #[error("invalid credentials")]
30 Invalid,
31}
32
33pub trait LocalReadAuthorizationPolicy: Send + Sync {
39 fn authorize_local_read(&self, request: &LocalReadAuthorizationRequest) -> Result<(), String>;
41}
42
43#[derive(Clone)]
45pub struct ServerLocalReadAuthorizationRecheck {
46 policy: Arc<dyn LocalReadAuthorizationPolicy>,
47}
48
49impl ServerLocalReadAuthorizationRecheck {
50 pub fn new(policy: Arc<dyn LocalReadAuthorizationPolicy>) -> Self {
52 Self { policy }
53 }
54}
55
56impl LocalReadAuthorizationRecheck for ServerLocalReadAuthorizationRecheck {
57 fn authorize(&self, request: &LocalReadAuthorizationRequest) -> Result<(), String> {
58 self.policy.authorize_local_read(request)
59 }
60}
61
62#[derive(Clone)]
64pub struct AuthMiddleware {
65 mode: AuthMode,
66}
67
68impl AuthMiddleware {
69 pub fn new(mode: AuthMode) -> Self {
71 Self { mode }
72 }
73
74 pub fn validate_http(&self, headers: &HeaderMap) -> Result<Option<String>, AuthError> {
76 match &self.mode {
77 AuthMode::None => Ok(None),
78 AuthMode::Dev { api_key } => {
79 let provided = extract_api_key(headers);
80 if provided.as_deref() == Some(api_key.as_str()) {
81 Ok(Some("dev".to_string()))
82 } else if provided.is_none() {
83 Err(AuthError::Missing)
84 } else {
85 Err(AuthError::Invalid)
86 }
87 }
88 }
89 }
90
91 pub fn validate_grpc(&self, metadata: &MetadataMap) -> Result<Option<String>, AuthError> {
93 match &self.mode {
94 AuthMode::None => Ok(None),
95 AuthMode::Dev { api_key } => {
96 let provided = extract_api_key_from_metadata(metadata);
97 if provided.as_deref() == Some(api_key.as_str()) {
98 Ok(Some("dev".to_string()))
99 } else if provided.is_none() {
100 Err(AuthError::Missing)
101 } else {
102 Err(AuthError::Invalid)
103 }
104 }
105 }
106 }
107
108 pub fn mode(&self) -> &AuthMode {
109 &self.mode
110 }
111
112 pub fn authenticated_subject(
116 &self,
117 actor: Option<&str>,
118 ) -> Result<AuthenticatedSubject, AuthError> {
119 match (&self.mode, actor) {
120 (AuthMode::None, None) => Ok(AuthenticatedSubject::new(ANONYMOUS_SUBJECT)),
121 (AuthMode::Dev { .. }, Some(DEV_SUBJECT)) => Ok(AuthenticatedSubject::new(DEV_SUBJECT)),
122 _ => Err(AuthError::Invalid),
123 }
124 }
125
126 pub fn local_read_authorization_recheck(&self) -> Arc<dyn LocalReadAuthorizationRecheck> {
128 Arc::new(ServerLocalReadAuthorizationRecheck::new(Arc::new(
129 self.clone(),
130 )))
131 }
132}
133
134impl LocalReadAuthorizationPolicy for AuthMiddleware {
135 fn authorize_local_read(&self, request: &LocalReadAuthorizationRequest) -> Result<(), String> {
136 let expected_subject = match self.mode() {
137 AuthMode::None => ANONYMOUS_SUBJECT,
138 AuthMode::Dev { .. } => DEV_SUBJECT,
139 };
140 if request.subject.as_str() == expected_subject {
141 Ok(())
142 } else {
143 Err("delegated subject is not authorized for the corresponding local read".into())
144 }
145 }
146}
147
148fn extract_api_key(headers: &HeaderMap) -> Option<String> {
149 if let Some(value) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
150 return Some(value.to_string());
151 }
152 headers
153 .get(axum::http::header::AUTHORIZATION)
154 .and_then(|v| v.to_str().ok())
155 .and_then(|raw| raw.strip_prefix("Bearer "))
156 .map(|v| v.to_string())
157}
158
159fn extract_api_key_from_metadata(metadata: &MetadataMap) -> Option<String> {
160 if let Some(value) = metadata.get("x-api-key").and_then(|v| v.to_str().ok()) {
161 return Some(value.to_string());
162 }
163 metadata
164 .get("authorization")
165 .and_then(|v| v.to_str().ok())
166 .and_then(|raw| raw.strip_prefix("Bearer "))
167 .map(|v| v.to_string())
168}
169
170#[cfg(test)]
171mod tests {
172 use alopex_cluster::{RangeId, ReadOperationScope, RequestId};
173 use alopex_core::ReadAtPoint;
174
175 use super::*;
176
177 fn local_request(subject: &str) -> LocalReadAuthorizationRequest {
178 LocalReadAuthorizationRequest {
179 subject: AuthenticatedSubject::new(subject),
180 operation: ReadOperationScope::Select,
181 table_id: 7,
182 range_id: RangeId::new("range-a"),
183 request_id: RequestId::new("request-a"),
184 query_digest: "query-a".into(),
185 read_at: ReadAtPoint::new(4, 3, 2, 1),
186 }
187 }
188
189 #[test]
190 fn local_recheck_admits_only_the_subject_allowed_by_local_auth_mode() {
191 let auth = AuthMiddleware::new(AuthMode::Dev {
192 api_key: "secret".into(),
193 });
194 let recheck = auth.local_read_authorization_recheck();
195 assert!(recheck.authorize(&local_request(DEV_SUBJECT)).is_ok());
196 assert!(recheck
197 .authorize(&local_request(ANONYMOUS_SUBJECT))
198 .is_err());
199 }
200
201 #[test]
202 fn authenticated_subject_is_derived_from_a_validated_actor_not_caller_input() {
203 let auth = AuthMiddleware::new(AuthMode::Dev {
204 api_key: "secret".into(),
205 });
206 assert_eq!(
207 auth.authenticated_subject(Some(DEV_SUBJECT))
208 .unwrap()
209 .as_str(),
210 DEV_SUBJECT
211 );
212 assert!(auth.authenticated_subject(Some("other-user")).is_err());
213
214 let anonymous = AuthMiddleware::new(AuthMode::None);
215 assert_eq!(
216 anonymous.authenticated_subject(None).unwrap().as_str(),
217 ANONYMOUS_SUBJECT
218 );
219 assert!(anonymous.authenticated_subject(Some(DEV_SUBJECT)).is_err());
220 }
221}