fix(hub): require system access for alert subscriptions and delivery (#2455)

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
user01010111
2026-09-29 10:27:11 -04:00
committed by GitHub
co-authored by hank
parent a77ed345d7
commit 0484c54919
9 changed files with 404 additions and 15 deletions
+4
View File
@@ -212,6 +212,10 @@ func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
// SendAlert sends an alert to the user
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
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
+361
View 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())
}
+4 -1
View File
@@ -63,7 +63,10 @@ func NewAlertsCache(app core.App) *AlertsCache {
// bindEvents sets up event listeners to keep the cache in sync with database changes.
func (c *AlertsCache) bindEvents() *AlertsCache {
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)
return e.Next()
})
+5
View File
@@ -39,6 +39,11 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
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 newTriggered {
_, _ = createAlertHistoryRecord(e.App, new)
+3 -3
View File
@@ -32,9 +32,6 @@ func (am *AlertManager) bindNetworkMonitorAlertEvents() {
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
}
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"))
value := e.Record.GetFloat("value")
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()
for _, alert := range alerts {
if !userHasSystem(tx, alert.GetString("user"), systemID) {
continue
}
var state networkMonitorAlertState
if err := alert.UnmarshalJSONField("state", &state); err != nil {
return err
@@ -248,7 +248,7 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
{name: "negative threshold", value: -1, status: 400},
{name: "unreachable threshold", value: 100, status: 400},
{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 private state", value: 5, direct: 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 {
content = `"status":400`
}
if tc.status == 403 {
content = `"status":403`
}
scenario := beszelTests.ApiScenario{
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
+19
View File
@@ -118,6 +118,25 @@ func setCollectionAuthSettings(app core.App) error {
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{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
+3 -3
View File
@@ -47,8 +47,8 @@ func TestCollectionRulesDefault(t *testing.T) {
require.NoError(t, err, "Failed to find alerts collection")
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser+` && system.users.id ?= @request.auth.id`, *alertsCollection.CreateRule)
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)
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
@@ -183,7 +183,7 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
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)
// alerts_history collection
@@ -11,11 +11,11 @@ func init() {
jsonData := `[
{
"id": "elngm8x1l60zi2v",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"listRule": null,
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "alerts",
"type": "base",
"fields": [