Skip to main content

orca_control/
auth.rs

1//! Bearer token authentication middleware with role-based access control.
2
3use 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
12/// Paths that skip bearer token authentication.
13const SKIP_AUTH_PATHS: &[&str] = &["/api/v1/health", "/api/v1/webhooks/github"];
14
15/// Map an API path + method to a required action for RBAC.
16fn 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        // Secrets are admin-only — they read and write encrypted material
33        // and viewer/deployer roles must not see the key list either.
34        ("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", // default to viewer-level for unknown GETs
38    }
39}
40
41/// Axum middleware that validates bearer tokens and checks RBAC roles.
42///
43/// Supports both legacy `api_tokens` (flat list, all admin) and new
44/// `[[token]]` entries with named roles.
45pub 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 no tokens configured, allow everything (backward compatible)
54    if legacy_tokens.is_empty() && named_tokens.is_empty() {
55        return next.run(request).await;
56    }
57
58    // Skip auth for exempt paths
59    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    // Extract bearer token
65    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    // Check legacy tokens first (all treated as admin)
76    if legacy_tokens.iter().any(|t| t == token) {
77        return next.run(request).await;
78    }
79
80    // Check named tokens with RBAC
81    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}