1use std::sync::Arc;
4
5use axum::extract::{Request, State};
6use axum::http::StatusCode;
7use axum::middleware::Next;
8use axum::response::{IntoResponse, Response};
9
10use crate::state::AppState;
11
12const SKIP_AUTH_PATHS: &[&str] = &["/api/v1/health", "/api/v1/webhooks/github"];
14
15fn required_action(path: &str, method: &str) -> &'static str {
17 match (method, path) {
18 ("POST", "/api/v1/deploy") => "deploy",
19 ("DELETE", p) if p.starts_with("/api/v1/services/") => "stop",
20 ("DELETE", p) if p.starts_with("/api/v1/projects/") => "stop",
21 ("POST", "/api/v1/stop") => "stop",
22 ("POST", p) if p.contains("/scale") => "scale",
23 ("POST", p) if p.contains("/rollback") => "rollback",
24 ("POST", p) if p.contains("/redeploy") => "deploy",
25 ("POST", p) if p.contains("/drain") => "deploy",
26 ("POST", p) if p.contains("/undrain") => "deploy",
27 ("POST", p) if p.contains("/register") => "deploy",
28 ("POST", p) if p.contains("/heartbeat") => "deploy",
29 ("GET", p) if p.contains("/logs") => "logs",
30 ("GET", "/api/v1/status") => "status",
31 ("GET", "/api/v1/cluster/info") => "cluster_info",
32 ("GET", "/api/v1/secrets") => "secrets",
35 ("POST", p) if p.starts_with("/api/v1/secrets/") => "secrets",
36 ("DELETE", p) if p.starts_with("/api/v1/secrets/") => "secrets",
37 _ => "status", }
39}
40
41pub async fn auth_middleware(
46 State(state): State<Arc<AppState>>,
47 request: Request,
48 next: Next,
49) -> Response {
50 let legacy_tokens = &state.api_tokens;
51 let named_tokens = &state.cluster_config.token;
52
53 if legacy_tokens.is_empty() && named_tokens.is_empty() {
55 return next.run(request).await;
56 }
57
58 let path = request.uri().path().to_string();
60 if SKIP_AUTH_PATHS.contains(&path.as_str()) {
61 return next.run(request).await;
62 }
63
64 let auth_header = request
66 .headers()
67 .get("authorization")
68 .and_then(|v| v.to_str().ok());
69
70 let token = match auth_header {
71 Some(header) if header.starts_with("Bearer ") => &header[7..],
72 _ => return (StatusCode::UNAUTHORIZED, "missing bearer token").into_response(),
73 };
74
75 if legacy_tokens.iter().any(|t| t == token) {
77 return next.run(request).await;
78 }
79
80 let method = request.method().as_str().to_string();
82 if let Some(api_token) = named_tokens.iter().find(|t| t.value == token) {
83 let action = required_action(&path, &method);
84 if api_token.role.can(action) {
85 return next.run(request).await;
86 }
87 return (
88 StatusCode::FORBIDDEN,
89 format!(
90 "role '{}' cannot perform '{}' (requires admin or deployer)",
91 serde_json::to_string(&api_token.role).unwrap_or_default(),
92 action
93 ),
94 )
95 .into_response();
96 }
97
98 (StatusCode::UNAUTHORIZED, "invalid bearer token").into_response()
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn deploy_requires_deploy_action() {
107 assert_eq!(required_action("/api/v1/deploy", "POST"), "deploy");
108 }
109
110 #[test]
111 fn redeploy_requires_deploy_action() {
112 assert_eq!(
113 required_action("/api/v1/services/nginx/redeploy", "POST"),
114 "deploy"
115 );
116 }
117
118 #[test]
119 fn rollback_requires_rollback_action() {
120 assert_eq!(
121 required_action("/api/v1/services/nginx/rollback", "POST"),
122 "rollback"
123 );
124 }
125
126 #[test]
127 fn scale_requires_scale_action() {
128 assert_eq!(
129 required_action("/api/v1/services/nginx/scale", "POST"),
130 "scale"
131 );
132 }
133
134 #[test]
135 fn stop_service_requires_stop_action() {
136 assert_eq!(required_action("/api/v1/services/nginx", "DELETE"), "stop");
137 }
138
139 #[test]
140 fn status_requires_status_action() {
141 assert_eq!(required_action("/api/v1/status", "GET"), "status");
142 }
143
144 #[test]
145 fn secrets_requires_secrets_action() {
146 assert_eq!(required_action("/api/v1/secrets", "GET"), "secrets");
147 assert_eq!(required_action("/api/v1/secrets/MY_KEY", "POST"), "secrets");
148 }
149
150 #[test]
151 fn unknown_path_defaults_to_status() {
152 assert_eq!(required_action("/unknown/path", "GET"), "status");
153 }
154}