@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,149 @@
1
+ package security
2
+
3
+ import (
4
+ "net/http/httptest"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/pocketbase/pocketbase/apis"
11
+ "github.com/pocketbase/pocketbase/core"
12
+ "github.com/pocketbase/pocketbase/tests"
13
+ )
14
+
15
+ func TestNATFairnessAndRejectedAccounting(t *testing.T) {
16
+ app, err := tests.NewTestApp()
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ defer app.Cleanup()
21
+ c := core.NewAuthCollection("security_users")
22
+ if err := app.Save(c); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ tokens := []string{}
26
+ for _, email := range []string{"a@example.com", "b@example.com"} {
27
+ r := core.NewRecord(c)
28
+ r.SetEmail(email)
29
+ r.SetPassword("1234567890")
30
+ if err := app.Save(r); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ token, err := r.NewAuthToken()
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ tokens = append(tokens, token)
38
+ }
39
+ config := DefaultConfig()
40
+ config.Mode = "enforce"
41
+ config.IdentityBurst = 1
42
+ config.IdentityPerSecond = .001
43
+ s, err := New(config)
44
+ if err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ defer s.Close()
48
+ router, _ := apis.NewRouter(app)
49
+ s.Bind(router)
50
+ router.GET("/probe", func(e *core.RequestEvent) error { return e.String(200, "ok") })
51
+ handler, err := router.BuildMux()
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ for _, tc := range []struct {
56
+ token string
57
+ want int
58
+ }{{tokens[0], 200}, {tokens[0], 429}, {"", 200}, {"", 429}, {tokens[1], 200}} {
59
+ req := httptest.NewRequest("GET", "/probe", nil)
60
+ req.RemoteAddr = "192.0.2.1:1000"
61
+ req.Header.Set("Authorization", tc.token)
62
+ w := httptest.NewRecorder()
63
+ handler.ServeHTTP(w, req)
64
+ if w.Code != tc.want {
65
+ t.Fatalf("got %d want %d", w.Code, tc.want)
66
+ }
67
+ if w.Code == 429 && w.Header().Get("Retry-After") == "" {
68
+ t.Fatal("missing retry header")
69
+ }
70
+ }
71
+ if s.total != 5 || s.rejected != 2 {
72
+ t.Fatalf("incorrect accounting: %d/%d", s.total, s.rejected)
73
+ }
74
+ }
75
+ func TestStateRestartAndCorruption(t *testing.T) {
76
+ config := DefaultConfig()
77
+ config.StatePath = filepath.Join(t.TempDir(), "state.json")
78
+ s, err := New(config)
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ s.mu.Lock()
83
+ s.revision = 4
84
+ s.nextID = 1
85
+ s.incidents = []Incident{{ID: 1, Family: "auth", Phase: "active"}}
86
+ s.actions["auth"] = Action{Family: "auth", Reason: "test", ExpiresAt: time.Now().Add(time.Minute)}
87
+ s.mu.Unlock()
88
+ s.Close()
89
+ restored, err := New(config)
90
+ if err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ if restored.revision != 4 || len(restored.incidents) != 1 || len(restored.actions) != 1 {
94
+ t.Fatal("lost persisted state")
95
+ }
96
+ restored.Close()
97
+ os.WriteFile(config.StatePath, []byte("corrupt"), 0600)
98
+ degraded, err := New(config)
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ defer degraded.Close()
103
+ if !degraded.storageError || degraded.config.Mode != config.Mode {
104
+ t.Fatal("invalid state weakened startup policy")
105
+ }
106
+ }
107
+
108
+ func TestOperatorLaneRequiresExplicitPeerAndKeepsAuth(t *testing.T) {
109
+ app, err := tests.NewTestApp()
110
+ if err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ defer app.Cleanup()
114
+ c := DefaultConfig()
115
+ c.Mode = "enforce"
116
+ c.OperatorPeers = []string{"192.0.2.10/32"}
117
+ s, err := New(c)
118
+ if err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ defer s.Close()
122
+ s.actions["other"] = Action{Family: "other", ExpiresAt: time.Now().Add(time.Minute)}
123
+ r, _ := apis.NewRouter(app)
124
+ s.Bind(r)
125
+ r.GET("/probe", func(e *core.RequestEvent) error { return e.NoContent(204) })
126
+ r.GET("/private-probe", func(e *core.RequestEvent) error { return e.NoContent(204) }).Bind(apis.RequireSuperuserAuth())
127
+ mux, err := r.BuildMux()
128
+ if err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ for _, tc := range []struct {
132
+ peer, marker, path string
133
+ want int
134
+ }{
135
+ {"192.0.2.10:1", "1", "/probe", 204},
136
+ {"192.0.2.10:1", "", "/probe", 429},
137
+ {"198.51.100.1:1", "1", "/probe", 429},
138
+ {"192.0.2.10:1", "1", "/private-probe", 401},
139
+ } {
140
+ req := httptest.NewRequest("GET", tc.path, nil)
141
+ req.RemoteAddr = tc.peer
142
+ req.Header.Set("X-Spink-Operator", tc.marker)
143
+ w := httptest.NewRecorder()
144
+ mux.ServeHTTP(w, req)
145
+ if w.Code != tc.want {
146
+ t.Fatalf("%+v got %d", tc, w.Code)
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,128 @@
1
+ package security
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/pocketbase/pocketbase/apis"
11
+ "github.com/pocketbase/pocketbase/core"
12
+ "github.com/pocketbase/pocketbase/tests"
13
+ "github.com/pocketbase/pocketbase/tools/subscriptions"
14
+ )
15
+
16
+ func TestBatchAndSubscriptionReservationRelease(t *testing.T) {
17
+ app, err := tests.NewTestApp()
18
+ if err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ defer app.Cleanup()
22
+ config := DefaultConfig()
23
+ config.Mode = "enforce"
24
+ config.MaxWrites = 2
25
+ config.MaxSubscriptionsTotal = 2
26
+ if err := Register(app, config); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ s := app.Store().Get("pb.security.service").(*Service)
30
+ defer s.Close()
31
+ event := func() *core.RequestEvent {
32
+ e := &core.RequestEvent{App: app}
33
+ e.Request = httptest.NewRequest("POST", "/api/batch", nil)
34
+ e.Response = httptest.NewRecorder()
35
+ return e
36
+ }
37
+ s.active["writes"] = 1
38
+ called := false
39
+ err = app.OnBatchRequest().Trigger(&core.BatchRequestEvent{RequestEvent: event(), Batch: make([]*core.InternalRequest, 3)}, func(e *core.BatchRequestEvent) error { called = true; return nil })
40
+ if err == nil || called || s.active["writes"] != 1 {
41
+ t.Fatal("oversized weighted batch admitted or permit leaked")
42
+ }
43
+ sentinel := errors.New("handler failed")
44
+ err = app.OnBatchRequest().Trigger(&core.BatchRequestEvent{RequestEvent: event(), Batch: make([]*core.InternalRequest, 2)}, func(e *core.BatchRequestEvent) error {
45
+ if s.active["writes"] != 2 {
46
+ t.Fatal("missing weight")
47
+ }
48
+ return sentinel
49
+ })
50
+ if err != sentinel || s.active["writes"] != 1 {
51
+ t.Fatal("batch failure leaked permits")
52
+ }
53
+ client := subscriptions.NewDefaultClient()
54
+ err = app.OnRealtimeConnectRequest().Trigger(&core.RealtimeConnectRequestEvent{RequestEvent: event(), Client: client}, func(e *core.RealtimeConnectRequestEvent) error {
55
+ subscribe := func(items []string, fail bool) error {
56
+ return app.OnRealtimeSubscribeRequest().Trigger(&core.RealtimeSubscribeRequestEvent{RequestEvent: event(), Client: client, Subscriptions: items}, func(e *core.RealtimeSubscribeRequestEvent) error {
57
+ if fail {
58
+ return sentinel
59
+ }
60
+ client.Unsubscribe()
61
+ client.Subscribe(items...)
62
+ return nil
63
+ })
64
+ }
65
+ if subscribe([]string{"a", "b"}, false) != nil || s.totalSubscriptions != 2 {
66
+ t.Fatal("subscriptions not reserved")
67
+ }
68
+ if subscribe([]string{"a", "b", "c"}, false) == nil || s.totalSubscriptions != 2 {
69
+ t.Fatal("aggregate limit not enforced")
70
+ }
71
+ if subscribe([]string{"a"}, true) != sentinel || s.totalSubscriptions != 2 {
72
+ t.Fatal("failed update changed reservation")
73
+ }
74
+ if subscribe([]string{"a"}, false) != nil || s.totalSubscriptions != 1 {
75
+ t.Fatal("unsubscribe did not free capacity")
76
+ }
77
+ return sentinel
78
+ })
79
+ if err != sentinel || s.totalSubscriptions != 0 || len(s.clients) != 0 {
80
+ t.Fatal("disconnect leaked subscriptions")
81
+ }
82
+ }
83
+
84
+ func TestManagementRevisionAndNetwork(t *testing.T) {
85
+ app, err := tests.NewTestApp()
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ defer app.Cleanup()
90
+ admin, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", "test@example.com")
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ token, err := admin.NewAuthToken()
95
+ if err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ s, err := New(DefaultConfig())
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ defer s.Close()
103
+ r, _ := apis.NewRouter(app)
104
+ s.Bind(r)
105
+ h, err := r.BuildMux()
106
+ if err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ body, _ := json.Marshal(DefaultConfig())
110
+ for _, tc := range []struct {
111
+ peer, revision string
112
+ want int
113
+ }{{"192.0.2.1:1234", "1", 403}, {"127.0.0.1:1234", "0", 409}, {"127.0.0.1:1234", "1", 200}, {"127.0.0.1:1234", "1", 409}} {
114
+ req := httptest.NewRequest("PUT", "/api/security/policy", strings.NewReader(string(body)))
115
+ req.RemoteAddr = tc.peer
116
+ req.Header.Set("Content-Type", "application/json")
117
+ req.Header.Set("Authorization", token)
118
+ req.Header.Set("If-Match", tc.revision)
119
+ w := httptest.NewRecorder()
120
+ h.ServeHTTP(w, req)
121
+ if w.Code != tc.want {
122
+ t.Fatalf("got %d want %d", w.Code, tc.want)
123
+ }
124
+ }
125
+ if s.revision != 2 {
126
+ t.Fatal("rejected mutation changed policy")
127
+ }
128
+ }
@@ -0,0 +1,78 @@
1
+ package security
2
+
3
+ import (
4
+ "errors"
5
+ "math"
6
+ "net/netip"
7
+ "path/filepath"
8
+ )
9
+
10
+ type Config struct {
11
+ OperatorPeers []string `json:"-"`
12
+ StatePath string `json:"-"`
13
+ Mode string `json:"mode"`
14
+ RequestsPerSecond float64 `json:"requestsPerSecond"`
15
+ Burst int `json:"burst"`
16
+ IdentityPerSecond float64 `json:"identityPerSecond"`
17
+ IdentityBurst int `json:"identityBurst"`
18
+ MaxConcurrent int `json:"maxConcurrent"`
19
+ MaxWrites int `json:"maxWrites"`
20
+ MaxFiles int `json:"maxFiles"`
21
+ MaxRealtime int `json:"maxRealtime"`
22
+ MaxSubscriptionsTotal int `json:"maxSubscriptionsTotal"`
23
+ MaxSubscriptions int `json:"maxSubscriptions"`
24
+ MaxIdentities int `json:"maxIdentities"`
25
+ TrustedPeers []string `json:"trustedPeers"`
26
+ ManagementPeers []string `json:"managementPeers"`
27
+ }
28
+
29
+ func DefaultConfig() Config {
30
+ return Config{Mode: "observe", RequestsPerSecond: 100, Burst: 200, IdentityPerSecond: 20, IdentityBurst: 40, MaxConcurrent: 64, MaxWrites: 8, MaxFiles: 8, MaxRealtime: 128, MaxSubscriptions: 100, MaxSubscriptionsTotal: 4096, MaxIdentities: 4096, ManagementPeers: []string{"127.0.0.0/8", "::1/128"}}
31
+ }
32
+ func (c Config) Validate() error {
33
+ if c.StatePath != "" && !filepath.IsAbs(c.StatePath) {
34
+ return errors.New("state path must be absolute")
35
+ }
36
+ if c.Mode != "disabled" && c.Mode != "observe" && c.Mode != "enforce" {
37
+ return errors.New("invalid security mode")
38
+ }
39
+ for _, v := range []float64{c.RequestsPerSecond, c.IdentityPerSecond} {
40
+ if math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 || v > 100000 {
41
+ return errors.New("rate must be finite and between 0 and 100000")
42
+ }
43
+ }
44
+ for _, v := range []int{c.Burst, c.IdentityBurst, c.MaxConcurrent, c.MaxWrites, c.MaxFiles, c.MaxRealtime, c.MaxSubscriptions, c.MaxSubscriptionsTotal, c.MaxIdentities} {
45
+ if v < 1 || v > 50000 {
46
+ return errors.New("security limits must be between 1 and 50000")
47
+ }
48
+ }
49
+ if len(c.TrustedPeers) > 64 || len(c.ManagementPeers) > 64 || len(c.OperatorPeers) > 64 {
50
+ return errors.New("too many trusted networks")
51
+ }
52
+ for _, v := range append(append(append([]string{}, c.TrustedPeers...), c.ManagementPeers...), c.OperatorPeers...) {
53
+ if _, err := netip.ParsePrefix(v); err != nil {
54
+ return errors.New("invalid trusted network")
55
+ }
56
+ }
57
+ return nil
58
+ }
59
+ func cloneConfig(c Config) Config {
60
+ c.OperatorPeers = append([]string{}, c.OperatorPeers...)
61
+ c.TrustedPeers = append([]string{}, c.TrustedPeers...)
62
+ c.ManagementPeers = append([]string{}, c.ManagementPeers...)
63
+ return c
64
+ }
65
+ func inNetworks(ip string, networks []string) bool {
66
+ a, err := netip.ParseAddr(ip)
67
+ if err != nil {
68
+ return false
69
+ }
70
+ a = a.Unmap()
71
+ for _, raw := range networks {
72
+ p, err := netip.ParsePrefix(raw)
73
+ if err == nil && p.Contains(a) {
74
+ return true
75
+ }
76
+ }
77
+ return false
78
+ }
@@ -0,0 +1,103 @@
1
+ package security
2
+
3
+ import (
4
+ "encoding/json"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestNativeDashboardExtensionAndSnapshotAuthorization(t *testing.T) {
15
+ app, err := tests.NewTestApp()
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ defer app.Cleanup()
20
+ if err := Register(app, DefaultConfig()); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ service := app.Store().Get("pb.security.service").(*Service)
24
+ defer service.Close()
25
+ r, err := apis.NewRouter(app)
26
+ if err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ event := &core.ServeEvent{App: app, Router: r}
30
+ if err := app.OnServe().Trigger(event); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ if len(event.UIExtensions) != 1 || event.UIExtensions[0].Name != "security" {
34
+ t.Fatal("native extension missing")
35
+ }
36
+ h, err := r.BuildMux()
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ w := httptest.NewRecorder()
41
+ h.ServeHTTP(w, httptest.NewRequest("GET", "/_/extensions.js", nil))
42
+ if w.Code != 200 || !strings.Contains(w.Body.String(), "superuserOnly('#/security'") {
43
+ t.Fatal("extension is not loadable")
44
+ }
45
+ w = httptest.NewRecorder()
46
+ h.ServeHTTP(w, httptest.NewRequest("GET", "/_security/", nil))
47
+ if w.Code != 307 || w.Header().Get("Location") != "/_/#/security" {
48
+ t.Fatal("legacy link not redirected")
49
+ }
50
+ admin, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", "test@example.com")
51
+ if err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ adminToken, _ := admin.NewAuthToken()
55
+ users, err := app.FindCollectionByNameOrId("users")
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ user := core.NewRecord(users)
60
+ user.SetEmail("adapter@example.com")
61
+ user.SetPassword("fixture-password-123")
62
+ if err := app.Save(user); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ userToken, _ := user.NewAuthToken()
66
+ for _, tc := range []struct {
67
+ token, peer string
68
+ code int
69
+ }{{"", "127.0.0.1:123", 401}, {userToken, "127.0.0.1:123", 403}, {adminToken, "192.0.2.1:123", 403}, {adminToken, "127.0.0.1:123", 200}} {
70
+ request := httptest.NewRequest("GET", "/api/security/policy/snapshot", nil)
71
+ request.Header.Set("Authorization", tc.token)
72
+ request.RemoteAddr = tc.peer
73
+ w := httptest.NewRecorder()
74
+ h.ServeHTTP(w, request)
75
+ if w.Code != tc.code {
76
+ t.Fatalf("snapshot returned %d want %d", w.Code, tc.code)
77
+ }
78
+ if w.Code == 200 {
79
+ var snapshot struct {
80
+ Revision uint64
81
+ Policy Config
82
+ }
83
+ if json.Unmarshal(w.Body.Bytes(), &snapshot) != nil || snapshot.Revision != 1 || snapshot.Policy.Mode != "observe" {
84
+ t.Fatal("invalid snapshot")
85
+ }
86
+ }
87
+ }
88
+ }
89
+ func TestBoundedTrafficHistory(t *testing.T) {
90
+ s, err := New(DefaultConfig())
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ defer s.Close()
95
+ now := time.Now()
96
+ for i := 0; i < 80; i++ {
97
+ s.observe("reads", true, 429, 0)
98
+ s.evaluate(now.Add(time.Duration(i) * 10 * time.Second))
99
+ }
100
+ if len(s.history) != 60 || s.history[59].Count != 1 || s.history[59].Rejected != 1 {
101
+ t.Fatal("invalid bounded traffic history")
102
+ }
103
+ }
@@ -0,0 +1,169 @@
1
+ package security
2
+
3
+ import (
4
+ "maps"
5
+ "net/http"
6
+ "strconv"
7
+ "time"
8
+
9
+ "github.com/pocketbase/pocketbase/apis"
10
+ "github.com/pocketbase/pocketbase/core"
11
+ "github.com/pocketbase/pocketbase/tools/router"
12
+ )
13
+
14
+ func (s *Service) bindManagement(r *router.Router[*core.RequestEvent]) {
15
+ g := r.Group("/api/security").Bind(apis.RequireSuperuserAuth()).BindFunc(func(e *core.RequestEvent) error {
16
+ s.mu.Lock()
17
+ allowed := inNetworks(e.RemoteIP(), s.config.ManagementPeers)
18
+ s.mu.Unlock()
19
+ if !allowed {
20
+ return e.ForbiddenError("Management network required", nil)
21
+ }
22
+ return e.Next()
23
+ })
24
+ g.GET("/status", func(e *core.RequestEvent) error {
25
+ s.mu.Lock()
26
+ result := map[string]any{"mode": s.config.Mode, "revision": s.revision, "identities": len(s.identities), "active": maps.Clone(s.active), "observed": s.total, "rejected": s.rejected, "subscriptions": s.totalSubscriptions, "storageDegraded": s.storageError, "detector": "heuristic-v1", "actions": currentActions(s.actions, time.Now()), "startedAt": s.startedAt, "lastEvaluated": s.lastEvaluated, "traffic": append([]TrafficWindow{}, s.history...), "checkpointEnabled": s.config.StatePath != ""}
27
+ s.mu.Unlock()
28
+ return e.JSON(http.StatusOK, result)
29
+ })
30
+ g.GET("/incidents", func(e *core.RequestEvent) error {
31
+ s.mu.Lock()
32
+ items := append([]Incident{}, s.incidents...)
33
+ s.mu.Unlock()
34
+ limit := 50
35
+ if raw := e.Request.URL.Query().Get("limit"); raw != "" {
36
+ n, err := strconv.Atoi(raw)
37
+ if err != nil || n < 1 || n > 100 {
38
+ return e.BadRequestError("Limit must be 1 to 100", nil)
39
+ }
40
+ limit = n
41
+ }
42
+ after := uint64(0)
43
+ if raw := e.Request.URL.Query().Get("after"); raw != "" {
44
+ var err error
45
+ after, err = strconv.ParseUint(raw, 10, 64)
46
+ if err != nil {
47
+ return e.BadRequestError("Invalid cursor", nil)
48
+ }
49
+ }
50
+ page := []Incident{}
51
+ next := uint64(0)
52
+ for _, item := range items {
53
+ if item.ID <= after {
54
+ continue
55
+ }
56
+ if len(page) == limit {
57
+ next = page[len(page)-1].ID
58
+ break
59
+ }
60
+ page = append(page, item)
61
+ }
62
+ return e.JSON(http.StatusOK, map[string]any{"items": page, "nextCursor": next})
63
+ })
64
+ g.GET("/policy/snapshot", func(e *core.RequestEvent) error {
65
+ s.mu.Lock()
66
+ result := struct {
67
+ Policy Config `json:"policy"`
68
+ Revision uint64 `json:"revision"`
69
+ }{cloneConfig(s.config), s.revision}
70
+ s.mu.Unlock()
71
+ return e.JSON(200, result)
72
+ })
73
+ g.GET("/policy", func(e *core.RequestEvent) error {
74
+ s.mu.Lock()
75
+ c, revision := cloneConfig(s.config), s.revision
76
+ s.mu.Unlock()
77
+ e.Response.Header().Set("ETag", strconv.FormatUint(revision, 10))
78
+ return e.JSON(http.StatusOK, c)
79
+ })
80
+ g.PUT("/policy", func(e *core.RequestEvent) error {
81
+ c := Config{}
82
+ if err := e.BindBody(&c); err != nil {
83
+ return e.BadRequestError("Invalid policy", err)
84
+ }
85
+ if err := c.Validate(); err != nil {
86
+ return e.BadRequestError("Invalid policy", err)
87
+ }
88
+ s.mu.Lock()
89
+ if e.Request.Header.Get("If-Match") != strconv.FormatUint(s.revision, 10) {
90
+ s.mu.Unlock()
91
+ return router.NewApiError(409, "Policy revision conflict", nil)
92
+ }
93
+ if c.MaxIdentities < len(s.identities) {
94
+ s.mu.Unlock()
95
+ return e.BadRequestError("Cannot shrink below current identity count", nil)
96
+ }
97
+ c.OperatorPeers = s.config.OperatorPeers
98
+ c.StatePath = s.config.StatePath
99
+ s.config = cloneConfig(c)
100
+ s.revision++
101
+ revision := s.revision
102
+ s.mu.Unlock()
103
+ s.persist()
104
+ e.App.Logger().Info("Security policy updated", "revision", revision, "operator", e.Auth.Id)
105
+ return e.JSON(http.StatusOK, map[string]any{"revision": revision})
106
+ })
107
+ g.POST("/incidents/{id}/acknowledge", func(e *core.RequestEvent) error {
108
+ id, err := strconv.ParseUint(e.Request.PathValue("id"), 10, 64)
109
+ if err != nil {
110
+ return e.BadRequestError("Invalid incident", nil)
111
+ }
112
+ s.mu.Lock()
113
+ for i := range s.incidents {
114
+ if s.incidents[i].ID == id {
115
+ s.incidents[i].Acknowledged = true
116
+ s.mu.Unlock()
117
+ s.persist()
118
+ e.App.Logger().Info("Security incident acknowledged", "incident", id, "operator", e.Auth.Id)
119
+ return e.NoContent(204)
120
+ }
121
+ }
122
+ s.mu.Unlock()
123
+ return e.NotFoundError("Incident not found", nil)
124
+ })
125
+ g.POST("/actions", func(e *core.RequestEvent) error {
126
+ var a Action
127
+ if err := e.BindBody(&a); err != nil {
128
+ return e.BadRequestError("Invalid action", err)
129
+ }
130
+ switch a.Family {
131
+ case "auth", "reads", "writes", "files", "realtime", "other":
132
+ default:
133
+ return e.BadRequestError("Invalid action family", nil)
134
+ }
135
+ if len(a.Reason) < 1 || len(a.Reason) > 200 || !a.ExpiresAt.After(time.Now()) || a.ExpiresAt.After(time.Now().Add(15*time.Minute)) {
136
+ return e.BadRequestError("Reason and expiry within 15 minutes required", nil)
137
+ }
138
+ s.mu.Lock()
139
+ s.actions[a.Family] = a
140
+ s.mu.Unlock()
141
+ s.persist()
142
+ e.App.Logger().Info("Temporary security action set", "family", a.Family, "expiresAt", a.ExpiresAt, "operator", e.Auth.Id)
143
+ return e.JSON(http.StatusOK, a)
144
+ })
145
+ g.DELETE("/actions/{family}", func(e *core.RequestEvent) error {
146
+ family := e.Request.PathValue("family")
147
+ s.mu.Lock()
148
+ _, exists := s.actions[family]
149
+ delete(s.actions, family)
150
+ s.mu.Unlock()
151
+ if !exists {
152
+ return e.NotFoundError("Action not found", nil)
153
+ }
154
+ s.persist()
155
+ e.App.Logger().Info("Security action cancelled", "family", family, "operator", e.Auth.Id)
156
+ return e.NoContent(204)
157
+ })
158
+
159
+ }
160
+
161
+ func currentActions(actions map[string]Action, now time.Time) map[string]Action {
162
+ result := maps.Clone(actions)
163
+ for family, action := range result {
164
+ if !action.ExpiresAt.After(now) {
165
+ delete(result, family)
166
+ }
167
+ }
168
+ return result
169
+ }