Skip to main content

alopex_server/
auth.rs

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/// Authentication mode for the server.
14#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum AuthMode {
17    /// No authentication.
18    #[default]
19    None,
20    /// Dev API key authentication.
21    Dev { api_key: String },
22}
23
24/// Authentication error for HTTP/gRPC.
25#[derive(Debug, thiserror::Error)]
26pub enum AuthError {
27    #[error("missing credentials")]
28    Missing,
29    #[error("invalid credentials")]
30    Invalid,
31}
32
33/// The server-local policy consulted for every local data read.
34///
35/// A range worker receives only this narrow interface through
36/// [`ServerLocalReadAuthorizationRecheck`].  It must not infer user authority
37/// from the authenticated cluster peer or from a delegation credential alone.
38pub trait LocalReadAuthorizationPolicy: Send + Sync {
39    /// Authorize the exact read that would be permitted on this server locally.
40    fn authorize_local_read(&self, request: &LocalReadAuthorizationRequest) -> Result<(), String>;
41}
42
43/// Adapts the server's local data policy to the cluster worker boundary.
44#[derive(Clone)]
45pub struct ServerLocalReadAuthorizationRecheck {
46    policy: Arc<dyn LocalReadAuthorizationPolicy>,
47}
48
49impl ServerLocalReadAuthorizationRecheck {
50    /// Create an adapter around the same policy used for local data access.
51    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/// Middleware helper for authentication.
63#[derive(Clone)]
64pub struct AuthMiddleware {
65    mode: AuthMode,
66}
67
68impl AuthMiddleware {
69    /// Create a new auth middleware.
70    pub fn new(mode: AuthMode) -> Self {
71        Self { mode }
72    }
73
74    /// Validate HTTP headers and return actor identity (if any).
75    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    /// Validate gRPC metadata and return actor identity (if any).
92    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    /// Derive the only subject that the configured local authentication mode
113    /// can authorize. Call this after the request credentials have been
114    /// validated; callers cannot select an arbitrary subject string.
115    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    /// Return the worker-facing recheck backed by this server's local policy.
127    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}