diff --git a/internal/cmd/hub/hub.go b/internal/cmd/hub/hub.go index 0b0866dc..3748916a 100644 --- a/internal/cmd/hub/hub.go +++ b/internal/cmd/hub/hub.go @@ -28,7 +28,7 @@ func main() { } baseApp := getBaseApp() - h := hub.NewHub(baseApp) + h, _ := hub.NewHub(baseApp) if err := h.StartHub(); err != nil { log.Fatal(err) } diff --git a/internal/hub/agent_connect_test.go b/internal/hub/agent_connect_test.go index 131edb88..2ec8d041 100644 --- a/internal/hub/agent_connect_test.go +++ b/internal/hub/agent_connect_test.go @@ -32,7 +32,8 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) { if err != nil { return nil, nil, err } - return NewHub(testApp), testApp, nil + h, err := NewHub(testApp) + return h, testApp, err } // cleanupTestHub stops background system goroutines before tearing down the app. diff --git a/internal/hub/collections.go b/internal/hub/collections.go new file mode 100644 index 00000000..d8013b17 --- /dev/null +++ b/internal/hub/collections.go @@ -0,0 +1,128 @@ +package hub + +import "github.com/pocketbase/pocketbase/core" + +type collectionRules struct { + list *string + view *string + create *string + update *string + delete *string +} + +// setCollectionAuthSettings applies Beszel's collection auth settings. +func setCollectionAuthSettings(app core.App) error { + usersCollection, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + return err + } + + // disable email auth if DISABLE_PASSWORD_AUTH env var is set + disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") + usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" + usersCollection.PasswordAuth.IdentityFields = []string{"email"} + // allow oauth user creation if USER_CREATION is set + if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { + cr := "@request.context = 'oauth2'" + usersCollection.CreateRule = &cr + } else { + usersCollection.CreateRule = nil + } + + // enable mfaOtp mfa if MFA_OTP env var is set + mfaOtp, _ := GetEnv("MFA_OTP") + usersCollection.OTP.Length = 6 + superusersCollection.OTP.Length = 6 + usersCollection.OTP.Enabled = mfaOtp == "true" + usersCollection.MFA.Enabled = mfaOtp == "true" + superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers" + superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers" + if err := app.Save(superusersCollection); err != nil { + return err + } + if err := app.Save(usersCollection); err != nil { + return err + } + + // When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read + // system-scoped data. Write rules continue to block readonly users. + shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") + + authenticatedRule := "@request.auth.id != \"\"" + systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id" + systemMemberRule := authenticatedRule + " && system.users.id ?= @request.auth.id" + + systemsReadRule := systemsMemberRule + systemScopedReadRule := systemMemberRule + if shareAllSystems == "true" { + systemsReadRule = authenticatedRule + systemScopedReadRule = authenticatedRule + } + systemsWriteRule := systemsReadRule + " && @request.auth.role != \"readonly\"" + systemScopedWriteRule := systemScopedReadRule + " && @request.auth.role != \"readonly\"" + + if err := applyCollectionRules(app, []string{"systems"}, collectionRules{ + list: &systemsReadRule, + view: &systemsReadRule, + create: &systemsWriteRule, + update: &systemsWriteRule, + delete: &systemsWriteRule, + }); err != nil { + return err + } + + if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{ + list: &systemScopedReadRule, + }); err != nil { + return err + } + + if err := applyCollectionRules(app, []string{"smart_devices"}, collectionRules{ + list: &systemScopedReadRule, + view: &systemScopedReadRule, + delete: &systemScopedWriteRule, + }); err != nil { + return err + } + + if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{ + list: &systemScopedReadRule, + view: &systemScopedReadRule, + create: &systemScopedWriteRule, + update: &systemScopedWriteRule, + delete: &systemScopedWriteRule, + }); err != nil { + return err + } + + if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{ + list: &systemScopedReadRule, + view: &systemScopedReadRule, + }); err != nil { + return err + } + + return nil +} + +func applyCollectionRules(app core.App, collectionNames []string, rules collectionRules) error { + for _, collectionName := range collectionNames { + collection, err := app.FindCollectionByNameOrId(collectionName) + if err != nil { + return err + } + collection.ListRule = rules.list + collection.ViewRule = rules.view + collection.CreateRule = rules.create + collection.UpdateRule = rules.update + collection.DeleteRule = rules.delete + if err := app.Save(collection); err != nil { + return err + } + } + return nil +} diff --git a/internal/hub/collections_test.go b/internal/hub/collections_test.go new file mode 100644 index 00000000..eb583006 --- /dev/null +++ b/internal/hub/collections_test.go @@ -0,0 +1,527 @@ +//go:build testing + +package hub_test + +import ( + "fmt" + "net/http" + "testing" + + beszelTests "github.com/henrygd/beszel/internal/tests" + "github.com/pocketbase/pocketbase/core" + pbTests "github.com/pocketbase/pocketbase/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectionRulesDefault(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + const isUserMatchesUser = `@request.auth.id != "" && user = @request.auth.id` + + const isUserInUsers = `@request.auth.id != "" && users.id ?= @request.auth.id` + const isUserInUsersNotReadonly = `@request.auth.id != "" && users.id ?= @request.auth.id && @request.auth.role != "readonly"` + + const isUserInSystemUsers = `@request.auth.id != "" && system.users.id ?= @request.auth.id` + const isUserInSystemUsersNotReadonly = `@request.auth.id != "" && system.users.id ?= @request.auth.id && @request.auth.role != "readonly"` + + // users collection + usersCollection, err := hub.FindCollectionByNameOrId("users") + assert.NoError(t, err, "Failed to find users collection") + assert.True(t, usersCollection.PasswordAuth.Enabled) + assert.Equal(t, usersCollection.PasswordAuth.IdentityFields, []string{"email"}) + assert.Nil(t, usersCollection.CreateRule) + assert.False(t, usersCollection.MFA.Enabled) + + // superusers collection + superusersCollection, err := hub.FindCollectionByNameOrId(core.CollectionNameSuperusers) + assert.NoError(t, err, "Failed to find superusers collection") + assert.True(t, superusersCollection.PasswordAuth.Enabled) + assert.Equal(t, superusersCollection.PasswordAuth.IdentityFields, []string{"email"}) + assert.Nil(t, superusersCollection.CreateRule) + assert.False(t, superusersCollection.MFA.Enabled) + + // alerts collection + alertsCollection, err := hub.FindCollectionByNameOrId("alerts") + 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, *alertsCollection.DeleteRule) + + // alerts_history collection + alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history") + require.NoError(t, err, "Failed to find alerts_history collection") + assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.ListRule) + assert.Nil(t, alertsHistoryCollection.ViewRule) + assert.Nil(t, alertsHistoryCollection.CreateRule) + assert.Nil(t, alertsHistoryCollection.UpdateRule) + assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.DeleteRule) + + // containers collection + containersCollection, err := hub.FindCollectionByNameOrId("containers") + require.NoError(t, err, "Failed to find containers collection") + assert.Equal(t, isUserInSystemUsers, *containersCollection.ListRule) + assert.Nil(t, containersCollection.ViewRule) + assert.Nil(t, containersCollection.CreateRule) + assert.Nil(t, containersCollection.UpdateRule) + assert.Nil(t, containersCollection.DeleteRule) + + // container_stats collection + containerStatsCollection, err := hub.FindCollectionByNameOrId("container_stats") + require.NoError(t, err, "Failed to find container_stats collection") + assert.Equal(t, isUserInSystemUsers, *containerStatsCollection.ListRule) + assert.Nil(t, containerStatsCollection.ViewRule) + assert.Nil(t, containerStatsCollection.CreateRule) + assert.Nil(t, containerStatsCollection.UpdateRule) + assert.Nil(t, containerStatsCollection.DeleteRule) + + // fingerprints collection + fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") + require.NoError(t, err, "Failed to find fingerprints collection") + assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ListRule) + assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ViewRule) + assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule) + assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule) + assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule) + + // quiet_hours collection + quietHoursCollection, err := hub.FindCollectionByNameOrId("quiet_hours") + require.NoError(t, err, "Failed to find quiet_hours collection") + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ListRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ViewRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.CreateRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.UpdateRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.DeleteRule) + + // smart_devices collection + smartDevicesCollection, err := hub.FindCollectionByNameOrId("smart_devices") + require.NoError(t, err, "Failed to find smart_devices collection") + assert.Equal(t, isUserInSystemUsers, *smartDevicesCollection.ListRule) + assert.Equal(t, isUserInSystemUsers, *smartDevicesCollection.ViewRule) + assert.Nil(t, smartDevicesCollection.CreateRule) + assert.Nil(t, smartDevicesCollection.UpdateRule) + assert.Equal(t, isUserInSystemUsersNotReadonly, *smartDevicesCollection.DeleteRule) + + // system_details collection + systemDetailsCollection, err := hub.FindCollectionByNameOrId("system_details") + require.NoError(t, err, "Failed to find system_details collection") + assert.Equal(t, isUserInSystemUsers, *systemDetailsCollection.ListRule) + assert.Equal(t, isUserInSystemUsers, *systemDetailsCollection.ViewRule) + assert.Nil(t, systemDetailsCollection.CreateRule) + assert.Nil(t, systemDetailsCollection.UpdateRule) + assert.Nil(t, systemDetailsCollection.DeleteRule) + + // system_stats collection + systemStatsCollection, err := hub.FindCollectionByNameOrId("system_stats") + require.NoError(t, err, "Failed to find system_stats collection") + assert.Equal(t, isUserInSystemUsers, *systemStatsCollection.ListRule) + assert.Nil(t, systemStatsCollection.ViewRule) + assert.Nil(t, systemStatsCollection.CreateRule) + assert.Nil(t, systemStatsCollection.UpdateRule) + assert.Nil(t, systemStatsCollection.DeleteRule) + + // systemd_services collection + systemdServicesCollection, err := hub.FindCollectionByNameOrId("systemd_services") + require.NoError(t, err, "Failed to find systemd_services collection") + assert.Equal(t, isUserInSystemUsers, *systemdServicesCollection.ListRule) + assert.Nil(t, systemdServicesCollection.ViewRule) + assert.Nil(t, systemdServicesCollection.CreateRule) + assert.Nil(t, systemdServicesCollection.UpdateRule) + assert.Nil(t, systemdServicesCollection.DeleteRule) + + // systems collection + systemsCollection, err := hub.FindCollectionByNameOrId("systems") + require.NoError(t, err, "Failed to find systems collection") + assert.Equal(t, isUserInUsers, *systemsCollection.ListRule) + assert.Equal(t, isUserInUsers, *systemsCollection.ViewRule) + assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.CreateRule) + assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.UpdateRule) + assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.DeleteRule) + + // universal_tokens collection + universalTokensCollection, err := hub.FindCollectionByNameOrId("universal_tokens") + require.NoError(t, err, "Failed to find universal_tokens collection") + assert.Nil(t, universalTokensCollection.ListRule) + assert.Nil(t, universalTokensCollection.ViewRule) + assert.Nil(t, universalTokensCollection.CreateRule) + assert.Nil(t, universalTokensCollection.UpdateRule) + assert.Nil(t, universalTokensCollection.DeleteRule) + + // user_settings collection + userSettingsCollection, err := hub.FindCollectionByNameOrId("user_settings") + require.NoError(t, err, "Failed to find user_settings collection") + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.ListRule) + assert.Nil(t, userSettingsCollection.ViewRule) + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.CreateRule) + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.UpdateRule) + assert.Nil(t, userSettingsCollection.DeleteRule) +} + +func TestCollectionRulesShareAllSystems(t *testing.T) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + const isUser = `@request.auth.id != ""` + const isUserNotReadonly = `@request.auth.id != "" && @request.auth.role != "readonly"` + + const isUserMatchesUser = `@request.auth.id != "" && user = @request.auth.id` + + // alerts collection + alertsCollection, err := hub.FindCollectionByNameOrId("alerts") + 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, *alertsCollection.DeleteRule) + + // alerts_history collection + alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history") + require.NoError(t, err, "Failed to find alerts_history collection") + assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.ListRule) + assert.Nil(t, alertsHistoryCollection.ViewRule) + assert.Nil(t, alertsHistoryCollection.CreateRule) + assert.Nil(t, alertsHistoryCollection.UpdateRule) + assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.DeleteRule) + + // containers collection + containersCollection, err := hub.FindCollectionByNameOrId("containers") + require.NoError(t, err, "Failed to find containers collection") + assert.Equal(t, isUser, *containersCollection.ListRule) + assert.Nil(t, containersCollection.ViewRule) + assert.Nil(t, containersCollection.CreateRule) + assert.Nil(t, containersCollection.UpdateRule) + assert.Nil(t, containersCollection.DeleteRule) + + // container_stats collection + containerStatsCollection, err := hub.FindCollectionByNameOrId("container_stats") + require.NoError(t, err, "Failed to find container_stats collection") + assert.Equal(t, isUser, *containerStatsCollection.ListRule) + assert.Nil(t, containerStatsCollection.ViewRule) + assert.Nil(t, containerStatsCollection.CreateRule) + assert.Nil(t, containerStatsCollection.UpdateRule) + assert.Nil(t, containerStatsCollection.DeleteRule) + + // fingerprints collection + fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") + require.NoError(t, err, "Failed to find fingerprints collection") + assert.Equal(t, isUser, *fingerprintsCollection.ListRule) + assert.Equal(t, isUser, *fingerprintsCollection.ViewRule) + assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule) + assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule) + assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule) + + // quiet_hours collection + quietHoursCollection, err := hub.FindCollectionByNameOrId("quiet_hours") + require.NoError(t, err, "Failed to find quiet_hours collection") + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ListRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ViewRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.CreateRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.UpdateRule) + assert.Equal(t, isUserMatchesUser, *quietHoursCollection.DeleteRule) + + // smart_devices collection + smartDevicesCollection, err := hub.FindCollectionByNameOrId("smart_devices") + require.NoError(t, err, "Failed to find smart_devices collection") + assert.Equal(t, isUser, *smartDevicesCollection.ListRule) + assert.Equal(t, isUser, *smartDevicesCollection.ViewRule) + assert.Nil(t, smartDevicesCollection.CreateRule) + assert.Nil(t, smartDevicesCollection.UpdateRule) + assert.Equal(t, isUserNotReadonly, *smartDevicesCollection.DeleteRule) + + // system_details collection + systemDetailsCollection, err := hub.FindCollectionByNameOrId("system_details") + require.NoError(t, err, "Failed to find system_details collection") + assert.Equal(t, isUser, *systemDetailsCollection.ListRule) + assert.Equal(t, isUser, *systemDetailsCollection.ViewRule) + assert.Nil(t, systemDetailsCollection.CreateRule) + assert.Nil(t, systemDetailsCollection.UpdateRule) + assert.Nil(t, systemDetailsCollection.DeleteRule) + + // system_stats collection + systemStatsCollection, err := hub.FindCollectionByNameOrId("system_stats") + require.NoError(t, err, "Failed to find system_stats collection") + assert.Equal(t, isUser, *systemStatsCollection.ListRule) + assert.Nil(t, systemStatsCollection.ViewRule) + assert.Nil(t, systemStatsCollection.CreateRule) + assert.Nil(t, systemStatsCollection.UpdateRule) + assert.Nil(t, systemStatsCollection.DeleteRule) + + // systemd_services collection + systemdServicesCollection, err := hub.FindCollectionByNameOrId("systemd_services") + require.NoError(t, err, "Failed to find systemd_services collection") + assert.Equal(t, isUser, *systemdServicesCollection.ListRule) + assert.Nil(t, systemdServicesCollection.ViewRule) + assert.Nil(t, systemdServicesCollection.CreateRule) + assert.Nil(t, systemdServicesCollection.UpdateRule) + assert.Nil(t, systemdServicesCollection.DeleteRule) + + // systems collection + systemsCollection, err := hub.FindCollectionByNameOrId("systems") + require.NoError(t, err, "Failed to find systems collection") + assert.Equal(t, isUser, *systemsCollection.ListRule) + assert.Equal(t, isUser, *systemsCollection.ViewRule) + assert.Equal(t, isUserNotReadonly, *systemsCollection.CreateRule) + assert.Equal(t, isUserNotReadonly, *systemsCollection.UpdateRule) + assert.Equal(t, isUserNotReadonly, *systemsCollection.DeleteRule) + + // universal_tokens collection + universalTokensCollection, err := hub.FindCollectionByNameOrId("universal_tokens") + require.NoError(t, err, "Failed to find universal_tokens collection") + assert.Nil(t, universalTokensCollection.ListRule) + assert.Nil(t, universalTokensCollection.ViewRule) + assert.Nil(t, universalTokensCollection.CreateRule) + assert.Nil(t, universalTokensCollection.UpdateRule) + assert.Nil(t, universalTokensCollection.DeleteRule) + + // user_settings collection + userSettingsCollection, err := hub.FindCollectionByNameOrId("user_settings") + require.NoError(t, err, "Failed to find user_settings collection") + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.ListRule) + assert.Nil(t, userSettingsCollection.ViewRule) + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.CreateRule) + assert.Equal(t, isUserMatchesUser, *userSettingsCollection.UpdateRule) + assert.Nil(t, userSettingsCollection.DeleteRule) +} + +func TestDisablePasswordAuth(t *testing.T) { + t.Setenv("DISABLE_PASSWORD_AUTH", "true") + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + usersCollection, err := hub.FindCollectionByNameOrId("users") + assert.NoError(t, err) + assert.False(t, usersCollection.PasswordAuth.Enabled) +} + +func TestUserCreation(t *testing.T) { + t.Setenv("USER_CREATION", "true") + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + usersCollection, err := hub.FindCollectionByNameOrId("users") + assert.NoError(t, err) + assert.Equal(t, "@request.context = 'oauth2'", *usersCollection.CreateRule) +} + +func TestMFAOtp(t *testing.T) { + t.Setenv("MFA_OTP", "true") + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + usersCollection, err := hub.FindCollectionByNameOrId("users") + assert.NoError(t, err) + assert.True(t, usersCollection.OTP.Enabled) + assert.True(t, usersCollection.MFA.Enabled) + + superusersCollection, err := hub.FindCollectionByNameOrId(core.CollectionNameSuperusers) + assert.NoError(t, err) + assert.True(t, superusersCollection.OTP.Enabled) + assert.True(t, superusersCollection.MFA.Enabled) +} + +func TestApiCollectionsAuthRules(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + hub.StartHub() + + user1, _ := beszelTests.CreateUser(hub, "user1@example.com", "password") + user1Token, _ := user1.NewAuthToken() + + user2, _ := beszelTests.CreateUser(hub, "user2@example.com", "password") + // user2Token, _ := user2.NewAuthToken() + + userReadonly, _ := beszelTests.CreateUserWithRole(hub, "userreadonly@example.com", "password", "readonly") + userReadonlyToken, _ := userReadonly.NewAuthToken() + + userOneSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "system1", + "users": []string{user1.Id}, + "host": "127.0.0.1", + }) + + sharedSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "system2", + "users": []string{user1.Id, user2.Id}, + "host": "127.0.0.2", + }) + + userTwoSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "system3", + "users": []string{user2.Id}, + "host": "127.0.0.2", + }) + + userRecords, _ := hub.CountRecords("users") + assert.EqualValues(t, 3, userRecords, "all users should be created") + + systemRecords, _ := hub.CountRecords("systems") + assert.EqualValues(t, 3, systemRecords, "all systems should be created") + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + { + Name: "Unauthorized user cannot list systems", + Method: http.MethodGet, + URL: "/api/collections/systems/records", + ExpectedStatus: 200, // https://github.com/pocketbase/pocketbase/discussions/1570 + TestAppFactory: testAppFactory, + ExpectedContent: []string{`"items":[]`, `"totalItems":0`}, + NotExpectedContent: []string{userOneSystem.Id, sharedSystem.Id, userTwoSystem.Id}, + }, + { + Name: "Unauthorized user cannot delete a system", + Method: http.MethodDelete, + URL: fmt.Sprintf("/api/collections/systems/records/%s", userOneSystem.Id), + ExpectedStatus: 404, + TestAppFactory: testAppFactory, + ExpectedContent: []string{"resource wasn't found"}, + NotExpectedContent: []string{userOneSystem.Id}, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 3, systemsCount, "should have 3 systems before deletion") + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 3, systemsCount, "should still have 3 systems after failed deletion") + }, + }, + { + Name: "User 1 can list their own systems", + Method: http.MethodGet, + URL: "/api/collections/systems/records", + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 200, + ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id}, + NotExpectedContent: []string{userTwoSystem.Id}, + TestAppFactory: testAppFactory, + }, + { + Name: "User 1 cannot list user 2's system", + Method: http.MethodGet, + URL: "/api/collections/systems/records", + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 200, + ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id}, + NotExpectedContent: []string{userTwoSystem.Id}, + TestAppFactory: testAppFactory, + }, + { + Name: "User 1 can see user 2's system if SHARE_ALL_SYSTEMS is enabled", + Method: http.MethodGet, + URL: "/api/collections/systems/records", + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 200, + ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id, userTwoSystem.Id}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + hub.SetCollectionAuthSettings() + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + t.Setenv("SHARE_ALL_SYSTEMS", "") + hub.SetCollectionAuthSettings() + }, + }, + { + Name: "User 1 can delete their own system", + Method: http.MethodDelete, + URL: fmt.Sprintf("/api/collections/systems/records/%s", userOneSystem.Id), + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 204, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 3, systemsCount, "should have 3 systems before deletion") + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount, "should have 2 systems after deletion") + }, + }, + { + Name: "User 1 cannot delete user 2's system", + Method: http.MethodDelete, + URL: fmt.Sprintf("/api/collections/systems/records/%s", userTwoSystem.Id), + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 404, + TestAppFactory: testAppFactory, + ExpectedContent: []string{"resource wasn't found"}, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount) + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount) + }, + }, + { + Name: "Readonly cannot delete a system even if SHARE_ALL_SYSTEMS is enabled", + Method: http.MethodDelete, + URL: fmt.Sprintf("/api/collections/systems/records/%s", sharedSystem.Id), + Headers: map[string]string{ + "Authorization": userReadonlyToken, + }, + ExpectedStatus: 404, + ExpectedContent: []string{"resource wasn't found"}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + hub.SetCollectionAuthSettings() + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount) + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + t.Setenv("SHARE_ALL_SYSTEMS", "") + hub.SetCollectionAuthSettings() + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount) + }, + }, + { + Name: "User 1 can delete user 2's system if SHARE_ALL_SYSTEMS is enabled", + Method: http.MethodDelete, + URL: fmt.Sprintf("/api/collections/systems/records/%s", userTwoSystem.Id), + Headers: map[string]string{ + "Authorization": user1Token, + }, + ExpectedStatus: 204, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + hub.SetCollectionAuthSettings() + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 2, systemsCount) + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + t.Setenv("SHARE_ALL_SYSTEMS", "") + hub.SetCollectionAuthSettings() + systemsCount, _ := app.CountRecords("systems") + assert.EqualValues(t, 1, systemsCount) + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 3a5b9504..91cc6ed3 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -4,6 +4,7 @@ package hub import ( "crypto/ed25519" "encoding/pem" + "errors" "fmt" "net/http" "net/url" @@ -45,20 +46,17 @@ type Hub struct { var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`) // NewHub creates a new Hub instance with default configuration -func NewHub(app core.App) *Hub { - hub := &Hub{} - hub.App = app - +func NewHub(app core.App) (*Hub, error) { + hub := &Hub{App: app} hub.AlertManager = alerts.NewAlertManager(hub) hub.um = users.NewUserManager(hub) hub.rm = records.NewRecordManager(hub) hub.sm = systems.NewSystemManager(hub) - hub.appURL, _ = GetEnv("APP_URL") hub.hb = heartbeat.New(app, GetEnv) if hub.hb != nil { hub.hbStop = make(chan struct{}) } - return hub + return hub, initialize(hub) } // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. @@ -72,10 +70,6 @@ func GetEnv(key string) (value string, exists bool) { func (h *Hub) StartHub() error { h.App.OnServe().BindFunc(func(e *core.ServeEvent) error { - // initialize settings / collections - if err := h.initialize(e); err != nil { - return err - } // sync systems with config if err := config.SyncSystems(e); err != nil { return err @@ -110,132 +104,32 @@ func (h *Hub) StartHub() error { h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) - if pb, ok := h.App.(*pocketbase.PocketBase); ok { - // log.Println("Starting pocketbase") - err := pb.Start() - if err != nil { - return err - } + pb, ok := h.App.(*pocketbase.PocketBase) + if !ok { + return errors.New("not a pocketbase app") } - - return nil + return pb.Start() } // initialize sets up initial configuration (collections, settings, etc.) -func (h *Hub) initialize(e *core.ServeEvent) error { - // set general settings - settings := e.App.Settings() - // batch requests (for global alerts) - settings.Batch.Enabled = true - // set URL if BASE_URL env is set - if h.appURL != "" { - settings.Meta.AppURL = h.appURL +func initialize(hub *Hub) error { + if !hub.App.IsBootstrapped() { + hub.App.Bootstrap() } - if err := e.App.Save(settings); err != nil { + // set general settings + settings := hub.App.Settings() + // batch requests (for alerts) + settings.Batch.Enabled = true + // set URL if APP_URL env is set + if appURL, isSet := GetEnv("APP_URL"); isSet { + hub.appURL = appURL + settings.Meta.AppURL = hub.appURL + } + if err := hub.App.Save(settings); err != nil { return err } // set auth settings - if err := setCollectionAuthSettings(e.App); err != nil { - return err - } - return nil -} - -// setCollectionAuthSettings sets up default authentication settings for the app -func setCollectionAuthSettings(app core.App) error { - usersCollection, err := app.FindCollectionByNameOrId("users") - if err != nil { - return err - } - superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) - if err != nil { - return err - } - - // disable email auth if DISABLE_PASSWORD_AUTH env var is set - disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") - usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" - usersCollection.PasswordAuth.IdentityFields = []string{"email"} - // allow oauth user creation if USER_CREATION is set - if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { - cr := "@request.context = 'oauth2'" - usersCollection.CreateRule = &cr - } else { - usersCollection.CreateRule = nil - } - - // enable mfaOtp mfa if MFA_OTP env var is set - mfaOtp, _ := GetEnv("MFA_OTP") - usersCollection.OTP.Length = 6 - superusersCollection.OTP.Length = 6 - usersCollection.OTP.Enabled = mfaOtp == "true" - usersCollection.MFA.Enabled = mfaOtp == "true" - superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers" - superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers" - if err := app.Save(superusersCollection); err != nil { - return err - } - if err := app.Save(usersCollection); err != nil { - return err - } - - shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") - - // allow all users to access systems if SHARE_ALL_SYSTEMS is set - systemsCollection, err := app.FindCollectionByNameOrId("systems") - if err != nil { - return err - } - var systemsReadRule string - if shareAllSystems == "true" { - systemsReadRule = "@request.auth.id != \"\"" - } else { - systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id" - } - updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\"" - systemsCollection.ListRule = &systemsReadRule - systemsCollection.ViewRule = &systemsReadRule - systemsCollection.UpdateRule = &updateDeleteRule - systemsCollection.DeleteRule = &updateDeleteRule - if err := app.Save(systemsCollection); err != nil { - return err - } - - // allow all users to access all containers if SHARE_ALL_SYSTEMS is set - containersCollection, err := app.FindCollectionByNameOrId("containers") - if err != nil { - return err - } - containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1) - containersCollection.ListRule = &containersListRule - if err := app.Save(containersCollection); err != nil { - return err - } - - // allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set - // these collections all have a "system" relation field - systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"} - for _, collectionName := range systemRelatedCollections { - collection, err := app.FindCollectionByNameOrId(collectionName) - if err != nil { - return err - } - collection.ListRule = &containersListRule - // set viewRule for collections that need it (system_details, smart_devices) - if collection.ViewRule != nil { - collection.ViewRule = &containersListRule - } - // set deleteRule for smart_devices (allows user to dismiss disk warnings) - if collectionName == "smart_devices" { - deleteRule := containersListRule + " && @request.auth.role != \"readonly\"" - collection.DeleteRule = &deleteRule - } - if err := app.Save(collection); err != nil { - return err - } - } - - return nil + return setCollectionAuthSettings(hub.App) } // registerCronJobs sets up scheduled tasks diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index dc7058c3..40e35f17 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -961,3 +961,21 @@ func TestTrustedHeaderMiddleware(t *testing.T) { scenario.Test(t) } } + +func TestAppUrl(t *testing.T) { + t.Run("no APP_URL does't change app url", func(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + settings := hub.Settings() + assert.Equal(t, "http://localhost:8090", settings.Meta.AppURL) + }) + t.Run("APP_URL changes app url", func(t *testing.T) { + t.Setenv("APP_URL", "http://example.com/app") + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + settings := hub.Settings() + assert.Equal(t, "http://example.com/app", settings.Meta.AppURL) + }) +} diff --git a/internal/hub/hub_test_helpers.go b/internal/hub/hub_test_helpers.go index 4527970f..2ecb6ebe 100644 --- a/internal/hub/hub_test_helpers.go +++ b/internal/hub/hub_test_helpers.go @@ -2,7 +2,9 @@ package hub -import "github.com/henrygd/beszel/internal/hub/systems" +import ( + "github.com/henrygd/beszel/internal/hub/systems" +) // TESTING ONLY: GetSystemManager returns the system manager func (h *Hub) GetSystemManager() *systems.SystemManager { @@ -18,3 +20,7 @@ func (h *Hub) GetPubkey() string { func (h *Hub) SetPubkey(pubkey string) { h.pubKey = pubkey } + +func (h *Hub) SetCollectionAuthSettings() error { + return setCollectionAuthSettings(h) +} diff --git a/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go index eb8cea23..b14d1cd8 100644 --- a/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go +++ b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go @@ -11,11 +11,11 @@ func init() { jsonData := `[ { "id": "elngm8x1l60zi2v", - "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "viewRule": "", - "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", + "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", "name": "alerts", "type": "base", "fields": [ @@ -143,11 +143,11 @@ func init() { }, { "id": "pbc_1697146157", - "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", + "viewRule": null, "createRule": null, "updateRule": null, - "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "deleteRule": "@request.auth.id != \"\" && user = @request.auth.id", "name": "alerts_history", "type": "base", "fields": [ @@ -261,7 +261,7 @@ func init() { }, { "id": "juohu4jipgc13v7", - "listRule": "@request.auth.id != \"\"", + "listRule": null, "viewRule": null, "createRule": null, "updateRule": null, @@ -351,10 +351,10 @@ func init() { }, { "id": "pbc_3663931638", - "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", - "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", - "createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", - "updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, "deleteRule": null, "name": "fingerprints", "type": "base", @@ -433,7 +433,7 @@ func init() { }, { "id": "ej9oowivz8b2mht", - "listRule": "@request.auth.id != \"\"", + "listRule": null, "viewRule": null, "createRule": null, "updateRule": null, @@ -523,10 +523,10 @@ func init() { }, { "id": "4afacsdnlu8q8r2", - "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", "viewRule": null, - "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && user = @request.auth.id", "deleteRule": null, "name": "user_settings", "type": "base", @@ -596,11 +596,11 @@ func init() { }, { "id": "2hz5ncl8tizk5nx", - "listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id", - "viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id", - "createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", - "updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", - "deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, "name": "systems", "type": "base", "fields": [ @@ -866,7 +866,7 @@ func init() { }, { "id": "pbc_1864144027", - "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "listRule": null, "viewRule": null, "createRule": null, "updateRule": null, @@ -1159,7 +1159,7 @@ func init() { "CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)", "CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)" ], - "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "listRule": null, "name": "systemd_services", "system": false, "type": "base", @@ -1167,8 +1167,8 @@ func init() { "viewRule": null }, { - "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "deleteRule": "@request.auth.id != \"\" && user = @request.auth.id", "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", @@ -1252,16 +1252,16 @@ func init() { "CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)", "CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)" ], - "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", "name": "quiet_hours", "system": false, "type": "base", - "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id" + "updateRule": "@request.auth.id != \"\" && user = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && user = @request.auth.id" }, { "createRule": null, - "deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "deleteRule": null, "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", @@ -1447,16 +1447,16 @@ func init() { "indexes": [ "CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)" ], - "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "listRule": null, "name": "smart_devices", "system": false, "type": "base", "updateRule": null, - "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" + "viewRule": null }, { - "createRule": "", - "deleteRule": "", + "createRule": null, + "deleteRule": null, "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", @@ -1625,12 +1625,12 @@ func init() { ], "id": "pbc_3116237454", "indexes": [], - "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "name": "system_details", "system": false, "type": "base", - "updateRule": "", - "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" + "updateRule": null, + "listRule": null, + "viewRule": null }, { "createRule": null, diff --git a/internal/tests/hub.go b/internal/tests/hub.go index db035103..647a2103 100644 --- a/internal/tests/hub.go +++ b/internal/tests/hub.go @@ -52,7 +52,10 @@ func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) { return nil, err } - hub := hub.NewHub(testApp) + hub, err := hub.NewHub(testApp) + if err != nil { + return nil, err + } t := &TestHub{ App: testApp, @@ -77,6 +80,16 @@ func CreateUser(app core.App, email string, password string) (*core.Record, erro return user, app.Save(user) } +func CreateUserWithRole(app core.App, email string, password string, roleName string) (*core.Record, error) { + user, err := CreateUser(app, email, password) + if err != nil { + return nil, err + } + + user.Set("role", roleName) + return user, app.Save(user) +} + // Helper function to create a test record func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) { collection, err := app.FindCachedCollectionByNameOrId(collectionName)