refactor(hub): move api user role checks to middlewares

This commit is contained in:
henrygd
2026-03-30 19:11:56 -04:00
parent 80135fdad3
commit 5463a38f0f
3 changed files with 70 additions and 28 deletions

View File

@@ -25,6 +25,30 @@ type UpdateInfo struct {
Url string `json:"url"` 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 // registerMiddlewares registers custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) { func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email // authorizes request with user matching the provided email
@@ -33,7 +57,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
return e.Next() return e.Next()
} }
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost 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 { if err != nil || !isAuthRefresh {
return e.Next() return e.Next()
} }
@@ -84,19 +108,19 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// send test notification // send test notification
apiAuth.POST("/test-notification", h.SendTestNotification) apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test // heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
apiAuth.POST("/test-heartbeat", h.testHeartbeat) apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
// get config.yml content // get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig) apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
// handle agent websocket connection // handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect) apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens // get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken) apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
// update / delete user alerts // update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system // refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData) apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
// get systemd service details // get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo) apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes // /containers routes
@@ -246,9 +270,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled // getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { 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 { if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{ return e.JSON(http.StatusOK, map[string]any{
"enabled": false, "enabled": false,
@@ -266,9 +287,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
// testHeartbeat triggers a single heartbeat ping and returns the result // testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error { 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 { if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{ return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",

View File

@@ -34,19 +34,13 @@ func TestApiRoutesAuthentication(t *testing.T) {
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123") user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user") require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{ adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user") require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken() adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{ readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
// "email": "superuser@example.com", require.NoError(t, err, "Failed to create readonly user")
// "password": "password123", readOnlyUserToken, err := readOnlyUser.NewAuthToken()
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken() userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token") require.NoError(t, err, "Failed to create auth token")
@@ -106,7 +100,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -136,7 +130,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -158,7 +152,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -202,6 +196,39 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory, 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", Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost, Method: http.MethodPost,

View File

@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
// Returns the current config.yml file as a JSON object // Returns the current config.yml file as a JSON object
func GetYamlConfig(e *core.RequestEvent) error { func GetYamlConfig(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
configContent, err := generateYAML(e.App) configContent, err := generateYAML(e.App)
if err != nil { if err != nil {
return err return err