Skip to main content

haystack_server/
app.rs

1//! Server builder and startup.
2
3use actix_web::body::MessageBody;
4use actix_web::dev::{ServiceRequest, ServiceResponse};
5use actix_web::middleware::from_fn;
6use actix_web::{App, HttpMessage, HttpServer, web};
7
8use haystack_core::auth::{AuthHeader, parse_auth_header};
9use haystack_core::graph::SharedGraph;
10use haystack_core::ontology::DefNamespace;
11
12use crate::actions::ActionRegistry;
13use crate::auth::AuthManager;
14use crate::federation::Federation;
15use crate::his_store::HisStore;
16use crate::ops;
17use crate::state::AppState;
18use crate::ws;
19use crate::ws::WatchManager;
20
21/// Builder for the Haystack HTTP server.
22pub struct HaystackServer {
23    graph: SharedGraph,
24    namespace: DefNamespace,
25    auth_manager: AuthManager,
26    actions: ActionRegistry,
27    federation: Federation,
28    port: u16,
29    host: String,
30}
31
32impl HaystackServer {
33    /// Create a new server with the given entity graph.
34    pub fn new(graph: SharedGraph) -> Self {
35        Self {
36            graph,
37            namespace: DefNamespace::new(),
38            auth_manager: AuthManager::empty(),
39            actions: ActionRegistry::new(),
40            federation: Federation::new(),
41            port: 8080,
42            host: "127.0.0.1".to_string(),
43        }
44    }
45
46    /// Set the ontology namespace for def/spec operations.
47    pub fn with_namespace(mut self, ns: DefNamespace) -> Self {
48        self.namespace = ns;
49        self
50    }
51
52    /// Set the authentication manager.
53    pub fn with_auth(mut self, auth: AuthManager) -> Self {
54        self.auth_manager = auth;
55        self
56    }
57
58    /// Set the port to listen on (default: 8080).
59    pub fn port(mut self, port: u16) -> Self {
60        self.port = port;
61        self
62    }
63
64    /// Set the host to bind to (default: "127.0.0.1").
65    pub fn host(mut self, host: &str) -> Self {
66        self.host = host.to_string();
67        self
68    }
69
70    /// Set the action registry for the `invokeAction` op.
71    pub fn with_actions(mut self, actions: ActionRegistry) -> Self {
72        self.actions = actions;
73        self
74    }
75
76    /// Set the federation manager for remote connector queries.
77    pub fn with_federation(mut self, federation: Federation) -> Self {
78        self.federation = federation;
79        self
80    }
81
82    /// Start the HTTP server. This blocks until the server is stopped.
83    pub async fn run(self) -> std::io::Result<()> {
84        let state = web::Data::new(AppState {
85            graph: self.graph,
86            namespace: parking_lot::RwLock::new(self.namespace),
87            auth: self.auth_manager,
88            watches: WatchManager::new(),
89            actions: self.actions,
90            his: HisStore::new(),
91            started_at: std::time::Instant::now(),
92            federation: self.federation,
93        });
94
95        log::info!("Starting haystack-server on {}:{}", self.host, self.port);
96
97        HttpServer::new(move || {
98            App::new()
99                .app_data(state.clone())
100                .app_data(actix_web::web::PayloadConfig::default().limit(2 * 1024 * 1024))
101                .wrap(from_fn(auth_middleware))
102                .configure(ops::configure)
103                .route("/api/ws", web::get().to(ws::ws_handler))
104        })
105        .bind((self.host.as_str(), self.port))?
106        .run()
107        .await
108    }
109}
110
111/// Determine the required permission for a given request path.
112///
113/// Returns `None` if the path does not require permission checking
114/// (e.g. public endpoints handled before auth).
115fn required_permission(path: &str) -> Option<&'static str> {
116    // System / admin endpoints
117    if path.starts_with("/api/system/") {
118        return Some("admin");
119    }
120
121    // Write operations
122    match path {
123        "/api/pointWrite"
124        | "/api/hisWrite"
125        | "/api/invokeAction"
126        | "/api/loadLib"
127        | "/api/unloadLib"
128        | "/api/import"
129        | "/api/federation/sync" => return Some("write"),
130        _ => {}
131    }
132
133    // Everything else that reaches here is a read-level operation:
134    // /api/about, /api/read, /api/nav, /api/defs, /api/libs,
135    // /api/hisRead, /api/watchSub, /api/watchPoll, /api/watchUnsub,
136    // /api/close, /api/ops, /api/formats, etc.
137    Some("read")
138}
139
140/// Authentication middleware.
141///
142/// - GET /api/about: pass through (about handles auth itself for SCRAM)
143/// - GET /api/ops, GET /api/formats: pass through (public info)
144/// - All other endpoints: require BEARER token if auth is enabled,
145///   then check the user has the required permission for that route.
146async fn auth_middleware(
147    req: ServiceRequest,
148    next: actix_web::middleware::Next<impl MessageBody>,
149) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
150    let path = req.path().to_string();
151    let method = req.method().clone();
152
153    // Allow about endpoint through (it handles auth itself for SCRAM handshake)
154    if path == "/api/about" {
155        return next.call(req).await;
156    }
157
158    // Allow ops and formats through without auth (public endpoints)
159    if (path == "/api/ops" || path == "/api/formats") && method == actix_web::http::Method::GET {
160        return next.call(req).await;
161    }
162
163    // Check if auth is enabled
164    let auth_enabled = {
165        let state = req
166            .app_data::<web::Data<AppState>>()
167            .expect("AppState must be configured");
168        state.auth.is_enabled()
169    };
170
171    if !auth_enabled {
172        // Auth is not enabled, pass through
173        return next.call(req).await;
174    }
175
176    // Extract and validate BEARER token
177    let auth_header = req
178        .headers()
179        .get("Authorization")
180        .and_then(|v| v.to_str().ok())
181        .map(|s| s.to_string());
182
183    match auth_header {
184        Some(header) => {
185            match parse_auth_header(&header) {
186                Ok(AuthHeader::Bearer { auth_token }) => {
187                    let user = {
188                        let state = req
189                            .app_data::<web::Data<AppState>>()
190                            .expect("AppState must be configured");
191                        state.auth.validate_token(&auth_token)
192                    };
193
194                    match user {
195                        Some(auth_user) => {
196                            // Check permission for the requested path
197                            if let Some(required) = required_permission(&path)
198                                && !AuthManager::check_permission(&auth_user, required)
199                            {
200                                return Err(crate::error::HaystackError::forbidden(format!(
201                                    "user '{}' lacks '{}' permission",
202                                    auth_user.username, required
203                                ))
204                                .into());
205                            }
206
207                            // Inject AuthUser into request extensions
208                            req.extensions_mut().insert(auth_user);
209                            next.call(req).await
210                        }
211                        None => Err(crate::error::HaystackError::new(
212                            "invalid or expired auth token",
213                            actix_web::http::StatusCode::UNAUTHORIZED,
214                        )
215                        .into()),
216                    }
217                }
218                _ => Err(crate::error::HaystackError::new(
219                    "BEARER token required",
220                    actix_web::http::StatusCode::UNAUTHORIZED,
221                )
222                .into()),
223            }
224        }
225        None => Err(crate::error::HaystackError::new(
226            "Authorization header required",
227            actix_web::http::StatusCode::UNAUTHORIZED,
228        )
229        .into()),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn required_permission_read_ops() {
239        assert_eq!(required_permission("/api/read"), Some("read"));
240        assert_eq!(required_permission("/api/nav"), Some("read"));
241        assert_eq!(required_permission("/api/defs"), Some("read"));
242        assert_eq!(required_permission("/api/libs"), Some("read"));
243        assert_eq!(required_permission("/api/hisRead"), Some("read"));
244        assert_eq!(required_permission("/api/watchSub"), Some("read"));
245        assert_eq!(required_permission("/api/watchPoll"), Some("read"));
246        assert_eq!(required_permission("/api/watchUnsub"), Some("read"));
247        assert_eq!(required_permission("/api/close"), Some("read"));
248        assert_eq!(required_permission("/api/about"), Some("read"));
249        assert_eq!(required_permission("/api/ops"), Some("read"));
250        assert_eq!(required_permission("/api/formats"), Some("read"));
251    }
252
253    #[test]
254    fn required_permission_write_ops() {
255        assert_eq!(required_permission("/api/pointWrite"), Some("write"));
256        assert_eq!(required_permission("/api/hisWrite"), Some("write"));
257        assert_eq!(required_permission("/api/invokeAction"), Some("write"));
258        assert_eq!(required_permission("/api/import"), Some("write"));
259        assert_eq!(required_permission("/api/federation/sync"), Some("write"));
260    }
261
262    #[test]
263    fn required_permission_admin_ops() {
264        assert_eq!(required_permission("/api/system/backup"), Some("admin"));
265        assert_eq!(required_permission("/api/system/restart"), Some("admin"));
266        assert_eq!(required_permission("/api/system/"), Some("admin"));
267    }
268
269    // ---- Integration tests for the auth middleware ----
270
271    use crate::auth::users::hash_password;
272    use crate::ws::WatchManager;
273    use actix_web::dev::Service;
274    use actix_web::middleware::from_fn;
275    use actix_web::test as actix_test;
276    use actix_web::{App, HttpResponse};
277
278    /// Build an AppState with auth enabled: one admin user and one read-only viewer.
279    fn test_state() -> web::Data<AppState> {
280        let hash = hash_password("s3cret");
281        let toml_str = format!(
282            r#"
283[users.admin]
284password_hash = "{hash}"
285permissions = ["read", "write", "admin"]
286
287[users.viewer]
288password_hash = "{hash}"
289permissions = ["read"]
290"#
291        );
292        let auth = AuthManager::from_toml_str(&toml_str).unwrap();
293        web::Data::new(AppState {
294            graph: haystack_core::graph::SharedGraph::new(haystack_core::graph::EntityGraph::new()),
295            namespace: parking_lot::RwLock::new(DefNamespace::new()),
296            auth,
297            watches: WatchManager::new(),
298            actions: ActionRegistry::new(),
299            his: HisStore::new(),
300            started_at: std::time::Instant::now(),
301            federation: Federation::new(),
302        })
303    }
304
305    /// Insert a token directly into the AuthManager and return it.
306    fn insert_token(
307        state: &web::Data<AppState>,
308        username: &str,
309        permissions: Vec<String>,
310    ) -> String {
311        let token = uuid::Uuid::new_v4().to_string();
312        let user = crate::auth::AuthUser {
313            username: username.to_string(),
314            permissions,
315        };
316        state.auth.inject_token(token.clone(), user);
317        token
318    }
319
320    /// Minimal read handler.
321    async fn dummy_handler() -> HttpResponse {
322        HttpResponse::Ok().body("ok")
323    }
324
325    /// Create a test app with auth middleware and dummy routes.
326    fn test_app(
327        state: web::Data<AppState>,
328    ) -> App<
329        impl actix_web::dev::ServiceFactory<
330            ServiceRequest,
331            Config = (),
332            Response = ServiceResponse<impl MessageBody>,
333            Error = actix_web::Error,
334            InitError = (),
335        >,
336    > {
337        App::new()
338            .app_data(state)
339            .wrap(from_fn(auth_middleware))
340            .route("/api/about", web::get().to(dummy_handler))
341            .route("/api/ops", web::get().to(dummy_handler))
342            .route("/api/formats", web::get().to(dummy_handler))
343            .route("/api/read", web::post().to(dummy_handler))
344            .route("/api/hisRead", web::post().to(dummy_handler))
345            .route("/api/pointWrite", web::post().to(dummy_handler))
346            .route("/api/hisWrite", web::post().to(dummy_handler))
347            .route("/api/invokeAction", web::post().to(dummy_handler))
348            .route("/api/close", web::post().to(dummy_handler))
349            .route("/api/system/backup", web::post().to(dummy_handler))
350    }
351
352    /// Helper: call the service and extract the status code, handling both
353    /// success responses and middleware errors (which implement ResponseError).
354    async fn call_status(
355        app: &impl Service<
356            actix_http::Request,
357            Response = ServiceResponse<impl MessageBody>,
358            Error = actix_web::Error,
359        >,
360        req: actix_http::Request,
361    ) -> u16 {
362        match app.call(req).await {
363            Ok(resp) => resp.status().as_u16(),
364            Err(err) => err.as_response_error().status_code().as_u16(),
365        }
366    }
367
368    #[actix_rt::test]
369    async fn about_passes_through_without_auth() {
370        let state = test_state();
371        let app = actix_test::init_service(test_app(state)).await;
372
373        let req = actix_test::TestRequest::get()
374            .uri("/api/about")
375            .to_request();
376        assert_eq!(call_status(&app, req).await, 200);
377    }
378
379    #[actix_rt::test]
380    async fn ops_passes_through_without_auth() {
381        let state = test_state();
382        let app = actix_test::init_service(test_app(state)).await;
383
384        let req = actix_test::TestRequest::get().uri("/api/ops").to_request();
385        assert_eq!(call_status(&app, req).await, 200);
386    }
387
388    #[actix_rt::test]
389    async fn formats_passes_through_without_auth() {
390        let state = test_state();
391        let app = actix_test::init_service(test_app(state)).await;
392
393        let req = actix_test::TestRequest::get()
394            .uri("/api/formats")
395            .to_request();
396        assert_eq!(call_status(&app, req).await, 200);
397    }
398
399    #[actix_rt::test]
400    async fn protected_endpoint_without_token_returns_401() {
401        let state = test_state();
402        let app = actix_test::init_service(test_app(state)).await;
403
404        let req = actix_test::TestRequest::post()
405            .uri("/api/read")
406            .to_request();
407        assert_eq!(call_status(&app, req).await, 401);
408    }
409
410    #[actix_rt::test]
411    async fn viewer_can_read() {
412        let state = test_state();
413        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
414        let app = actix_test::init_service(test_app(state)).await;
415
416        let req = actix_test::TestRequest::post()
417            .uri("/api/read")
418            .insert_header(("Authorization", format!("BEARER authToken={token}")))
419            .to_request();
420        assert_eq!(call_status(&app, req).await, 200);
421    }
422
423    #[actix_rt::test]
424    async fn viewer_cannot_write() {
425        let state = test_state();
426        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
427        let app = actix_test::init_service(test_app(state)).await;
428
429        let req = actix_test::TestRequest::post()
430            .uri("/api/pointWrite")
431            .insert_header(("Authorization", format!("BEARER authToken={token}")))
432            .to_request();
433        assert_eq!(call_status(&app, req).await, 403);
434    }
435
436    #[actix_rt::test]
437    async fn viewer_cannot_invoke_action() {
438        let state = test_state();
439        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
440        let app = actix_test::init_service(test_app(state)).await;
441
442        let req = actix_test::TestRequest::post()
443            .uri("/api/invokeAction")
444            .insert_header(("Authorization", format!("BEARER authToken={token}")))
445            .to_request();
446        assert_eq!(call_status(&app, req).await, 403);
447    }
448
449    #[actix_rt::test]
450    async fn viewer_cannot_his_write() {
451        let state = test_state();
452        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
453        let app = actix_test::init_service(test_app(state)).await;
454
455        let req = actix_test::TestRequest::post()
456            .uri("/api/hisWrite")
457            .insert_header(("Authorization", format!("BEARER authToken={token}")))
458            .to_request();
459        assert_eq!(call_status(&app, req).await, 403);
460    }
461
462    #[actix_rt::test]
463    async fn writer_can_write() {
464        let state = test_state();
465        let token = insert_token(
466            &state,
467            "writer",
468            vec!["read".to_string(), "write".to_string()],
469        );
470        let app = actix_test::init_service(test_app(state)).await;
471
472        let req = actix_test::TestRequest::post()
473            .uri("/api/pointWrite")
474            .insert_header(("Authorization", format!("BEARER authToken={token}")))
475            .to_request();
476        assert_eq!(call_status(&app, req).await, 200);
477    }
478
479    #[actix_rt::test]
480    async fn admin_can_access_system() {
481        let state = test_state();
482        let token = insert_token(&state, "admin", vec!["admin".to_string()]);
483        let app = actix_test::init_service(test_app(state)).await;
484
485        let req = actix_test::TestRequest::post()
486            .uri("/api/system/backup")
487            .insert_header(("Authorization", format!("BEARER authToken={token}")))
488            .to_request();
489        assert_eq!(call_status(&app, req).await, 200);
490    }
491
492    #[actix_rt::test]
493    async fn viewer_cannot_access_system() {
494        let state = test_state();
495        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
496        let app = actix_test::init_service(test_app(state)).await;
497
498        let req = actix_test::TestRequest::post()
499            .uri("/api/system/backup")
500            .insert_header(("Authorization", format!("BEARER authToken={token}")))
501            .to_request();
502        assert_eq!(call_status(&app, req).await, 403);
503    }
504
505    #[actix_rt::test]
506    async fn writer_cannot_access_system() {
507        let state = test_state();
508        let token = insert_token(
509            &state,
510            "writer",
511            vec!["read".to_string(), "write".to_string()],
512        );
513        let app = actix_test::init_service(test_app(state)).await;
514
515        let req = actix_test::TestRequest::post()
516            .uri("/api/system/backup")
517            .insert_header(("Authorization", format!("BEARER authToken={token}")))
518            .to_request();
519        assert_eq!(call_status(&app, req).await, 403);
520    }
521
522    #[actix_rt::test]
523    async fn viewer_can_close() {
524        let state = test_state();
525        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
526        let app = actix_test::init_service(test_app(state)).await;
527
528        let req = actix_test::TestRequest::post()
529            .uri("/api/close")
530            .insert_header(("Authorization", format!("BEARER authToken={token}")))
531            .to_request();
532        assert_eq!(call_status(&app, req).await, 200);
533    }
534
535    #[actix_rt::test]
536    async fn viewer_can_his_read() {
537        let state = test_state();
538        let token = insert_token(&state, "viewer", vec!["read".to_string()]);
539        let app = actix_test::init_service(test_app(state)).await;
540
541        let req = actix_test::TestRequest::post()
542            .uri("/api/hisRead")
543            .insert_header(("Authorization", format!("BEARER authToken={token}")))
544            .to_request();
545        assert_eq!(call_status(&app, req).await, 200);
546    }
547
548    #[actix_rt::test]
549    async fn invalid_token_returns_401() {
550        let state = test_state();
551        let app = actix_test::init_service(test_app(state)).await;
552
553        let req = actix_test::TestRequest::post()
554            .uri("/api/read")
555            .insert_header(("Authorization", "BEARER authToken=bogus-token"))
556            .to_request();
557        assert_eq!(call_status(&app, req).await, 401);
558    }
559}