mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 13:27:46 +02:00
fix(hub): require system access for alert subscriptions and delivery (#2455)
Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
@@ -212,6 +212,10 @@ func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
|||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
|
// Stored subscriptions and queued notifications may outlive system access.
|
||||||
|
if data.SystemID != "" && !userHasSystem(am.hub, data.UserID, data.SystemID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// Check if alert is silenced
|
// Check if alert is silenced
|
||||||
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
||||||
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
||||||
|
|||||||
361
internal/alerts/alerts_access_test.go
Normal file
361
internal/alerts/alerts_access_test.go
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func alertAPIRouter(t *testing.T, hub *beszelTests.TestHub) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
router, err := apis.NewRouter(hub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.OnServe().Trigger(&core.ServeEvent{App: hub, Router: router}))
|
||||||
|
mux, err := router.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertAPIRequest(t *testing.T, handler http.Handler, token, method, path string, body any, status int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, jsonReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
assert.Equal(t, status, res.Code, "%s %s: %s", method, path, res.Body.String())
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRecordSystemAccess(t *testing.T) {
|
||||||
|
for _, shareAll := range []string{"false", "true"} {
|
||||||
|
t.Run("share_all="+shareAll, func(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", shareAll)
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
hub.StartHub()
|
||||||
|
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
other, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
token, err := user.NewAuthToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
own, err := beszelTests.CreateSystems(hub, 2, user.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
foreign, err := beszelTests.CreateSystems(hub, 1, other.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
handler := alertAPIRouter(t, hub)
|
||||||
|
const records = "/api/collections/alerts/records"
|
||||||
|
// Collection rules reject inaccessible creates as 400 and hide inaccessible updates as 404.
|
||||||
|
readStatus, createStatus, updateStatus := 404, 400, 404
|
||||||
|
if shareAll == "true" {
|
||||||
|
readStatus, createStatus, updateStatus = 200, 200, 200
|
||||||
|
}
|
||||||
|
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+own[0].Id, nil, 200)
|
||||||
|
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+foreign[0].Id, nil, readStatus)
|
||||||
|
collection, err := hub.FindCollectionByNameOrId("alerts")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, name := range collection.Fields.GetByName("name").(*core.SelectField).Values {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
// Scalar and array representations both pass through PocketBase's relation binding.
|
||||||
|
for _, relation := range []any{foreign[0].Id, []string{foreign[0].Id}} {
|
||||||
|
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
|
||||||
|
"name": name, "user": user.Id, "system": relation, "value": 50,
|
||||||
|
}, createStatus)
|
||||||
|
if id, ok := result["id"].(string); ok {
|
||||||
|
record, err := hub.FindRecordById("alerts", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.Delete(record))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
|
||||||
|
"name": name, "user": user.Id, "system": own[0].Id, "value": 50,
|
||||||
|
}, 200)
|
||||||
|
id := result["id"].(string)
|
||||||
|
defer func() {
|
||||||
|
record, err := hub.FindRecordById("alerts", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.Delete(record))
|
||||||
|
}()
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, map[string]any{"value": 51}, 200)
|
||||||
|
// The system of an existing alert is immutable through the API, even between accessible systems.
|
||||||
|
for _, body := range []map[string]any{
|
||||||
|
{"system": foreign[0].Id},
|
||||||
|
{"system+": foreign[0].Id},
|
||||||
|
{"system": []string{own[1].Id}},
|
||||||
|
} {
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, body, 404)
|
||||||
|
}
|
||||||
|
saved, err := hub.FindRecordById("alerts", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, own[0].Id, saved.GetString("system"))
|
||||||
|
// Internal saves can still move an alert, which must remove its previous cache binding.
|
||||||
|
saved.Set("system", own[1].Id)
|
||||||
|
require.NoError(t, hub.Save(saved))
|
||||||
|
cache := hub.GetAlertManager().GetSystemAlertsCache()
|
||||||
|
assert.Empty(t, cache.GetSystemAlerts(own[0].Id), "moving an alert must remove its previous cache binding")
|
||||||
|
assert.Len(t, cache.GetSystemAlerts(own[1].Id), 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": own[0].Id}, 400)
|
||||||
|
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": "missing00000000"}, 400)
|
||||||
|
alertAPIRequest(t, handler, "", "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 400)
|
||||||
|
// An old subscription must still be checked when PATCH omits the relation.
|
||||||
|
oldAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "user": user.Id, "system": foreign[0].Id, "value": 50,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+oldAlert.Id, map[string]any{"value": 51}, updateStatus)
|
||||||
|
require.NoError(t, hub.Delete(oldAlert))
|
||||||
|
otherAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "user": other.Id, "system": foreign[0].Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+otherAlert.Id, map[string]any{"system": own[0].Id}, 404)
|
||||||
|
require.NoError(t, hub.Delete(otherAlert))
|
||||||
|
// Alerts cannot be handed to another user.
|
||||||
|
ownAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "user": user.Id, "system": own[0].Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+ownAlert.Id, map[string]any{"user": other.Id}, 404)
|
||||||
|
ownAlert, err = hub.FindRecordById("alerts", ownAlert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, user.Id, ownAlert.GetString("user"))
|
||||||
|
require.NoError(t, hub.Delete(ownAlert))
|
||||||
|
// Readonly users retain their own alert preferences; superusers retain their bypass.
|
||||||
|
user.Set("role", "readonly")
|
||||||
|
require.NoError(t, hub.Save(user))
|
||||||
|
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 200)
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "Memory"}, 200)
|
||||||
|
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "NetworkMonitorLoss"}, 400)
|
||||||
|
record, err := hub.FindRecordById("alerts", result["id"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.Delete(record))
|
||||||
|
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
superToken, err := superuser.NewAuthToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
result = alertAPIRequest(t, handler, superToken, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": foreign[0].Id}, 200)
|
||||||
|
record, err = hub.FindRecordById("alerts", result["id"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.Delete(record))
|
||||||
|
// Bulk requests continue to skip inaccessible systems and accept accessible ones.
|
||||||
|
alertAPIRequest(t, handler, token, "POST", "/api/beszel/user-alerts", map[string]any{
|
||||||
|
"name": "CPU", "systems": []string{own[0].Id, foreign[0].Id}, "value": 50,
|
||||||
|
}, 200)
|
||||||
|
count, err := hub.CountRecords("alerts")
|
||||||
|
require.NoError(t, err)
|
||||||
|
expectedCount := 1
|
||||||
|
if shareAll == "true" {
|
||||||
|
expectedCount = 2
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, expectedCount, count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRecordForeignSystemDelivery(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
hub.StartHub()
|
||||||
|
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, recipient := range []*core.Record{user, owner} {
|
||||||
|
_, err := beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": recipient.Id, "settings": map[string]any{"emails": []string{recipient.GetString("email")}, "webhooks": []string{}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
foreign, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
foreign[0].Set("name", "private-system")
|
||||||
|
require.NoError(t, hub.Save(foreign[0]))
|
||||||
|
handler := alertAPIRouter(t, hub)
|
||||||
|
for _, recipient := range []*core.Record{user, owner} {
|
||||||
|
token, err := recipient.NewAuthToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
status := 200
|
||||||
|
if recipient.Id == user.Id {
|
||||||
|
status = 400
|
||||||
|
}
|
||||||
|
alertAPIRequest(t, handler, token, "POST", "/api/collections/alerts/records", map[string]any{
|
||||||
|
"name": "CPU", "user": recipient.Id, "system": foreign[0].Id, "min": 1, "value": 50,
|
||||||
|
}, status)
|
||||||
|
}
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(foreign[0], &system.CombinedData{Info: system.Info{Cpu: 91}}))
|
||||||
|
synctest.Wait()
|
||||||
|
messages := hub.TestMailer.Messages()
|
||||||
|
assert.Len(t, messages, 1, "only the authorised subscriber should receive telemetry")
|
||||||
|
for _, message := range messages {
|
||||||
|
assert.Equal(t, "owner@example.com", message.To[0].Address)
|
||||||
|
assert.Contains(t, message.Subject, "private-system CPU above threshold")
|
||||||
|
assert.Contains(t, message.Text, "91.00%")
|
||||||
|
t.Logf("captured synthetic email: recipient=%s subject=%q body=%q", message.To[0].Address, message.Subject, message.Text)
|
||||||
|
}
|
||||||
|
history, err := hub.FindAllRecords("alerts_history", dbx.HashExp{"system": foreign[0].Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, history, 1)
|
||||||
|
for _, record := range history {
|
||||||
|
assert.Equal(t, owner.Id, record.GetString("user"))
|
||||||
|
}
|
||||||
|
t.Logf("captured synthetic history entries=%d", len(history))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertStoredSubscriptionAccess(t *testing.T) {
|
||||||
|
for _, mode := range []string{"foreign", "revoked", "shared", "share_all", "revoked_active"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
||||||
|
if mode == "share_all" {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||||
|
}
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
hub.StartHub()
|
||||||
|
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
subscriber, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
if mode == "revoked" || mode == "shared" || mode == "revoked_active" {
|
||||||
|
systemRecord.Set("users", []string{owner.Id, subscriber.Id})
|
||||||
|
require.NoError(t, hub.Save(systemRecord))
|
||||||
|
}
|
||||||
|
for _, user := range []*core.Record{owner, subscriber} {
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user.Id, "settings": map[string]any{"emails": []string{user.GetString("email")}, "webhooks": []string{}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Direct saves model records already stored before the request-hook fix.
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "user": user.Id, "system": systemRecord.Id, "value": 50, "min": 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, hub.GetAlertManager().GetSystemAlertsCache().PopulateFromDB(true))
|
||||||
|
if mode == "revoked" {
|
||||||
|
systemRecord.Set("users", []string{owner.Id})
|
||||||
|
require.NoError(t, hub.Save(systemRecord))
|
||||||
|
}
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 91}}))
|
||||||
|
synctest.Wait()
|
||||||
|
})
|
||||||
|
allowed := mode == "shared" || mode == "share_all" || mode == "revoked_active"
|
||||||
|
want := 1
|
||||||
|
if allowed {
|
||||||
|
want = 2
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, hub.TestMailer.TotalSend())
|
||||||
|
for _, message := range hub.TestMailer.Messages() {
|
||||||
|
if !allowed {
|
||||||
|
assert.Equal(t, "owner@example.com", message.To[0].Address)
|
||||||
|
}
|
||||||
|
t.Logf("%s captured recipient=%s body=%q", mode, message.To[0].Address, message.Text)
|
||||||
|
}
|
||||||
|
history, err := hub.FindAllRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, history, want)
|
||||||
|
for _, record := range history {
|
||||||
|
if !allowed {
|
||||||
|
assert.Equal(t, owner.Id, record.GetString("user"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count, err := hub.CountRecords("alerts")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, count, "stored subscriptions are preserved")
|
||||||
|
if mode == "revoked_active" {
|
||||||
|
systemRecord.Set("users", []string{owner.Id})
|
||||||
|
require.NoError(t, hub.Save(systemRecord))
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 40}}))
|
||||||
|
synctest.Wait()
|
||||||
|
})
|
||||||
|
assert.Equal(t, 3, hub.TestMailer.TotalSend(), "only the owner should receive recovery after revocation")
|
||||||
|
previous, err := hub.FindFirstRecordByFilter("alerts_history", "user={:user}", dbx.Params{"user": subscriber.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, previous.GetDateTime("resolved").IsZero(), "revoked subscribers must not learn recovery timing through history")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertPendingStatusAccessRevoked(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
hub.StartHub()
|
||||||
|
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user.Id, "settings": map[string]any{"emails": []string{"subscriber@example.com"}, "webhooks": []string{}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status", "user": user.Id, "system": systems[0].Id, "min": 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
defer hub.GetAlertManager().Stop()
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleStatusAlerts("down", systems[0]))
|
||||||
|
require.Equal(t, 1, hub.GetAlertManager().GetPendingAlertsCount())
|
||||||
|
systems[0].Set("users", []string{owner.Id})
|
||||||
|
require.NoError(t, hub.Save(systems[0]))
|
||||||
|
time.Sleep(61 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend())
|
||||||
|
count, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Zero(t, count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertStoredNetworkMonitorAccess(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
||||||
|
hub, systemRecord, _, monitors := networkAlertSetup(t)
|
||||||
|
other, err := beszelTests.CreateUser(hub, "foreign@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
foreign, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "NetworkMonitorLoss", "user": other.Id, "system": systemRecord.Id, "value": 5,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
require.NoError(t, am.HandleNetworkMonitorAlerts(systemRecord, map[string]monitor.Result{monitors[0].Id: monitorResult(10)}))
|
||||||
|
history, err := hub.FindAllRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, history, 1, "foreign subscriptions must not block the authorised incident transaction")
|
||||||
|
assert.NotEqual(t, foreign.Id, history[0].GetString("alert_id"))
|
||||||
|
assert.Equal(t, 1, hub.TestMailer.TotalSend())
|
||||||
|
}
|
||||||
@@ -63,7 +63,10 @@ func NewAlertsCache(app core.App) *AlertsCache {
|
|||||||
// bindEvents sets up event listeners to keep the cache in sync with database changes.
|
// bindEvents sets up event listeners to keep the cache in sync with database changes.
|
||||||
func (c *AlertsCache) bindEvents() *AlertsCache {
|
func (c *AlertsCache) bindEvents() *AlertsCache {
|
||||||
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
||||||
// c.Delete(e.Record.Original()) // this would be needed if the system field on an existing alert was changed, however we don't currently allow that in the UI so we'll leave it commented out
|
// Remove the previous binding if the system changed (only possible through superuser or internal saves).
|
||||||
|
if original := e.Record.Original(); original.GetString("system") != e.Record.GetString("system") {
|
||||||
|
c.Delete(original)
|
||||||
|
}
|
||||||
c.Update(e.Record)
|
c.Update(e.Record)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
|||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History is visible to the subscriber, including after system access is removed.
|
||||||
|
if !userHasSystem(e.App, new.GetString("user"), new.GetString("system")) {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
// if new state is triggered, create new alert history record
|
// if new state is triggered, create new alert history record
|
||||||
if newTriggered {
|
if newTriggered {
|
||||||
_, _ = createAlertHistoryRecord(e.App, new)
|
_, _ = createAlertHistoryRecord(e.App, new)
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ func (am *AlertManager) bindNetworkMonitorAlertEvents() {
|
|||||||
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
|
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
|
||||||
}
|
}
|
||||||
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
|
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
|
||||||
if !e.HasSuperuserAuth() && (e.Auth == nil || !userHasSystem(e.App, e.Auth.Id, e.Record.GetString("system"))) {
|
|
||||||
return e.ForbiddenError("You do not have access to this system", nil)
|
|
||||||
}
|
|
||||||
e.Record.Set("triggered", e.Record.Original().GetBool("triggered"))
|
e.Record.Set("triggered", e.Record.Original().GetBool("triggered"))
|
||||||
value := e.Record.GetFloat("value")
|
value := e.Record.GetFloat("value")
|
||||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
|
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
|
||||||
@@ -136,6 +133,9 @@ func (am *AlertManager) evaluateNetworkMonitorAlerts(app core.App, systemID stri
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
|
if !userHasSystem(tx, alert.GetString("user"), systemID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var state networkMonitorAlertState
|
var state networkMonitorAlertState
|
||||||
if err := alert.UnmarshalJSONField("state", &state); err != nil {
|
if err := alert.UnmarshalJSONField("state", &state); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
|
|||||||
{name: "negative threshold", value: -1, status: 400},
|
{name: "negative threshold", value: -1, status: 400},
|
||||||
{name: "unreachable threshold", value: 100, status: 400},
|
{name: "unreachable threshold", value: 100, status: 400},
|
||||||
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
|
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
|
||||||
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 403},
|
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 400},
|
||||||
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
|
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
|
||||||
{name: "direct private state", value: 5, direct: true, status: 200},
|
{name: "direct private state", value: 5, direct: true, status: 200},
|
||||||
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
|
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
|
||||||
@@ -287,9 +287,6 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
|
|||||||
if tc.status == 400 {
|
if tc.status == 400 {
|
||||||
content = `"status":400`
|
content = `"status":400`
|
||||||
}
|
}
|
||||||
if tc.status == 403 {
|
|
||||||
content = `"status":403`
|
|
||||||
}
|
|
||||||
scenario := beszelTests.ApiScenario{
|
scenario := beszelTests.ApiScenario{
|
||||||
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
|
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
|
||||||
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
|
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alerts belong to their user and may only reference systems the user can access.
|
||||||
|
// The user and system of an existing alert cannot be changed through the API.
|
||||||
|
// Readonly users can still manage their own alerts, so these build on the read rule.
|
||||||
|
alertsOwnerRule := authenticatedRule + " && user = @request.auth.id"
|
||||||
|
alertsCreateRule := alertsOwnerRule
|
||||||
|
alertsUpdateRule := alertsOwnerRule + " && @request.body.user:changed = false && @request.body.system:changed = false"
|
||||||
|
if shareAllSystems != "true" {
|
||||||
|
alertsCreateRule += " && system.users.id ?= @request.auth.id"
|
||||||
|
alertsUpdateRule += " && system.users.id ?= @request.auth.id"
|
||||||
|
}
|
||||||
|
if err := applyCollectionRules(app, []string{"alerts"}, collectionRules{
|
||||||
|
list: &alertsOwnerRule,
|
||||||
|
create: &alertsCreateRule,
|
||||||
|
update: &alertsUpdateRule,
|
||||||
|
delete: &alertsOwnerRule,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedReadRule,
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ func TestCollectionRulesDefault(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to find alerts collection")
|
require.NoError(t, err, "Failed to find alerts collection")
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
||||||
assert.Nil(t, alertsCollection.ViewRule)
|
assert.Nil(t, alertsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
assert.Equal(t, isUserMatchesUser+` && system.users.id ?= @request.auth.id`, *alertsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
assert.Equal(t, isUserMatchesUser+` && @request.body.user:changed = false && @request.body.system:changed = false && system.users.id ?= @request.auth.id`, *alertsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
||||||
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
|
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
|
||||||
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
|
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
|
||||||
@@ -183,7 +183,7 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
|
|||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
||||||
assert.Nil(t, alertsCollection.ViewRule)
|
assert.Nil(t, alertsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
assert.Equal(t, isUserMatchesUser+` && @request.body.user:changed = false && @request.body.system:changed = false`, *alertsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
||||||
|
|
||||||
// alerts_history collection
|
// alerts_history collection
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ func init() {
|
|||||||
jsonData := `[
|
jsonData := `[
|
||||||
{
|
{
|
||||||
"id": "elngm8x1l60zi2v",
|
"id": "elngm8x1l60zi2v",
|
||||||
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"listRule": null,
|
||||||
"viewRule": null,
|
"viewRule": null,
|
||||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"createRule": null,
|
||||||
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"updateRule": null,
|
||||||
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"deleteRule": null,
|
||||||
"name": "alerts",
|
"name": "alerts",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"fields": [
|
"fields": [
|
||||||
|
|||||||
Reference in New Issue
Block a user