From 5463a38f0f426b45272dff7bffcd351fe5ddcf03 Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 30 Mar 2026 19:11:56 -0400 Subject: [PATCH] refactor(hub): move api user role checks to middlewares --- internal/hub/api.go | 42 +++++++++++++++++++-------- internal/hub/api_test.go | 53 ++++++++++++++++++++++++++--------- internal/hub/config/config.go | 3 -- 3 files changed, 70 insertions(+), 28 deletions(-) diff --git a/internal/hub/api.go b/internal/hub/api.go index abc12516..9d0fc3f4 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -25,6 +25,30 @@ type UpdateInfo struct { Url string `json:"url"` } +// Middleware to allow only admin role users +var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool { + return e.Auth.GetString("role") == "admin" +}) + +// Middleware to exclude readonly users +var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool { + return e.Auth.GetString("role") != "readonly" +}) + +// customAuthMiddleware handles boilerplate for custom authentication middlewares. fn should +// return true if the request is allowed, false otherwise. e.Auth is guaranteed to be non-nil. +func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error { + return func(e *core.RequestEvent) error { + if e.Auth == nil { + return e.UnauthorizedError("The request requires valid record authorization token.", nil) + } + if !fn(e) { + return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil) + } + return e.Next() + } +} + // registerMiddlewares registers custom middlewares func (h *Hub) registerMiddlewares(se *core.ServeEvent) { // authorizes request with user matching the provided email @@ -33,7 +57,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) { return e.Next() } isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost - e.Auth, err = e.App.FindFirstRecordByData("users", "email", email) + e.Auth, err = e.App.FindAuthRecordByEmail("users", email) if err != nil || !isAuthRefresh { return e.Next() } @@ -84,19 +108,19 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // send test notification apiAuth.POST("/test-notification", h.SendTestNotification) // heartbeat status and test - apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) - apiAuth.POST("/test-heartbeat", h.testHeartbeat) + apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole) + apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole) // get config.yml content - apiAuth.GET("/config-yaml", config.GetYamlConfig) + apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole) // handle agent websocket connection apiNoAuth.GET("/agent-connect", h.handleAgentConnect) // get or create universal tokens - apiAuth.GET("/universal-token", h.getUniversalToken) + apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole) // update / delete user alerts apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) // refresh SMART devices for a system - apiAuth.POST("/smart/refresh", h.refreshSmartData) + apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole) // get systemd service details apiAuth.GET("/systemd/info", h.getSystemdInfo) // /containers routes @@ -246,9 +270,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error { // getHeartbeatStatus returns current heartbeat configuration and whether it's enabled func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { - if e.Auth.GetString("role") != "admin" { - return e.ForbiddenError("Requires admin role", nil) - } if h.hb == nil { return e.JSON(http.StatusOK, map[string]any{ "enabled": false, @@ -266,9 +287,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { // testHeartbeat triggers a single heartbeat ping and returns the result func (h *Hub) testHeartbeat(e *core.RequestEvent) error { - if e.Auth.GetString("role") != "admin" { - return e.ForbiddenError("Requires admin role", nil) - } if h.hb == nil { return e.JSON(http.StatusOK, map[string]any{ "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index 9c094608..70d68296 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -34,19 +34,13 @@ func TestApiRoutesAuthentication(t *testing.T) { user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123") require.NoError(t, err, "Failed to create test user") - adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{ - "email": "admin@example.com", - "password": "password123", - "role": "admin", - }) + adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin") require.NoError(t, err, "Failed to create admin user") adminUserToken, err := adminUser.NewAuthToken() - // superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{ - // "email": "superuser@example.com", - // "password": "password123", - // }) - // require.NoError(t, err, "Failed to create superuser") + readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly") + require.NoError(t, err, "Failed to create readonly user") + readOnlyUserToken, err := readOnlyUser.NewAuthToken() userToken, err := user.NewAuthToken() require.NoError(t, err, "Failed to create auth token") @@ -106,7 +100,7 @@ func TestApiRoutesAuthentication(t *testing.T) { "Authorization": userToken, }, ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin"}, + ExpectedContent: []string{"The authorized record is not allowed to perform this action."}, TestAppFactory: testAppFactory, }, { @@ -136,7 +130,7 @@ func TestApiRoutesAuthentication(t *testing.T) { "Authorization": userToken, }, ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin role"}, + ExpectedContent: []string{"The authorized record is not allowed to perform this action."}, TestAppFactory: testAppFactory, }, { @@ -158,7 +152,7 @@ func TestApiRoutesAuthentication(t *testing.T) { "Authorization": userToken, }, ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin role"}, + ExpectedContent: []string{"The authorized record is not allowed to perform this action."}, TestAppFactory: testAppFactory, }, { @@ -202,6 +196,39 @@ func TestApiRoutesAuthentication(t *testing.T) { ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, TestAppFactory: testAppFactory, }, + { + Name: "GET /universal-token - with readonly auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/universal-token", + Headers: map[string]string{ + "Authorization": readOnlyUserToken, + }, + ExpectedStatus: 403, + ExpectedContent: []string{"The authorized record is not allowed to perform this action."}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /smart/refresh - missing system should fail 400 with user auth", + Method: http.MethodPost, + URL: "/api/beszel/smart/refresh", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"system parameter is required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /smart/refresh - with readonly auth should fail", + Method: http.MethodPost, + URL: "/api/beszel/smart/refresh", + Headers: map[string]string{ + "Authorization": readOnlyUserToken, + }, + ExpectedStatus: 403, + ExpectedContent: []string{"The authorized record is not allowed to perform this action."}, + TestAppFactory: testAppFactory, + }, { Name: "POST /user-alerts - no auth should fail", Method: http.MethodPost, diff --git a/internal/hub/config/config.go b/internal/hub/config/config.go index 18cb6407..2b3d8a95 100644 --- a/internal/hub/config/config.go +++ b/internal/hub/config/config.go @@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error { // Returns the current config.yml file as a JSON object func GetYamlConfig(e *core.RequestEvent) error { - if e.Auth.GetString("role") != "admin" { - return e.ForbiddenError("Requires admin role", nil) - } configContent, err := generateYAML(e.App) if err != nil { return err