Skip to content

Commit 3647ba7

Browse files
Jguereleijonmarck
andauthored
Anonymous: Add configurable device limit (#79265)
* Anonymous: Add device limiter * break auth if limit reached * fix typo * refactored const to make it clearer with expiration * anon device limit for config --------- Co-authored-by: Eric Leijonmarck <[email protected]>
1 parent ed86583 commit 3647ba7

File tree

11 files changed

+105
-37
lines changed

11 files changed

+105
-37
lines changed

packages/grafana-data/src/types/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export interface GrafanaConfig {
197197
theme: GrafanaTheme;
198198
theme2: GrafanaTheme2;
199199
anonymousEnabled: boolean;
200+
anonymousDeviceLimit: number | undefined;
200201
featureToggles: FeatureToggles;
201202
licenseInfo: LicenseInfo;
202203
http2Enabled: boolean;

packages/grafana-runtime/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
9494
theme2: GrafanaTheme2;
9595
featureToggles: FeatureToggles = {};
9696
anonymousEnabled = false;
97+
anonymousDeviceLimit = undefined;
9798
licenseInfo: LicenseInfo = {} as LicenseInfo;
9899
rendererAvailable = false;
99100
rendererVersion = '';

pkg/api/dtos/frontend_settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ type FrontendSettingsDTO struct {
192192

193193
FeatureToggles map[string]bool `json:"featureToggles"`
194194
AnonymousEnabled bool `json:"anonymousEnabled"`
195+
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
195196
RendererAvailable bool `json:"rendererAvailable"`
196197
RendererVersion string `json:"rendererVersion"`
197198
SecretsManagerPluginEnabled bool `json:"secretsManagerPluginEnabled"`

pkg/api/frontendsettings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
195195

196196
FeatureToggles: hs.Features.GetEnabled(c.Req.Context()),
197197
AnonymousEnabled: hs.Cfg.AnonymousEnabled,
198+
AnonymousDeviceLimit: hs.Cfg.AnonymousDeviceLimit,
198199
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
199200
RendererVersion: hs.RenderService.Version(),
200201
SecretsManagerPluginEnabled: secretsManagerPluginEnabled,

pkg/services/anonymous/anonimpl/anonstore/database.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ import (
1313
)
1414

1515
const cacheKeyPrefix = "anon-device"
16+
const anonymousDeviceExpiration = 30 * 24 * time.Hour
17+
18+
var ErrDeviceLimitReached = fmt.Errorf("device limit reached")
1619

1720
type AnonDBStore struct {
18-
sqlStore db.DB
19-
log log.Logger
21+
sqlStore db.DB
22+
log log.Logger
23+
deviceLimit int64
2024
}
2125

2226
type Device struct {
@@ -45,8 +49,8 @@ type AnonStore interface {
4549
DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error
4650
}
4751

48-
func ProvideAnonDBStore(sqlStore db.DB) *AnonDBStore {
49-
return &AnonDBStore{sqlStore: sqlStore, log: log.New("anonstore")}
52+
func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore {
53+
return &AnonDBStore{sqlStore: sqlStore, log: log.New("anonstore"), deviceLimit: deviceLimit}
5054
}
5155

5256
func (s *AnonDBStore) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error) {
@@ -65,9 +69,54 @@ func (s *AnonDBStore) ListDevices(ctx context.Context, from *time.Time, to *time
6569
return devices, err
6670
}
6771

72+
// updateDevice updates a device if it exists and has been updated between the given times.
73+
func (s *AnonDBStore) updateDevice(ctx context.Context, device *Device) error {
74+
const query = `UPDATE anon_device SET
75+
client_ip = ?,
76+
user_agent = ?,
77+
updated_at = ?
78+
WHERE device_id = ? AND updated_at BETWEEN ? AND ?`
79+
80+
args := []interface{}{device.ClientIP, device.UserAgent, device.UpdatedAt.UTC(), device.DeviceID,
81+
device.UpdatedAt.UTC().Add(-anonymousDeviceExpiration), device.UpdatedAt.UTC().Add(time.Minute),
82+
}
83+
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
84+
args = append([]interface{}{query}, args...)
85+
result, err := dbSession.Exec(args...)
86+
if err != nil {
87+
return err
88+
}
89+
90+
rowsAffected, err := result.RowsAffected()
91+
if err != nil {
92+
return err
93+
}
94+
95+
if rowsAffected == 0 {
96+
return ErrDeviceLimitReached
97+
}
98+
99+
return nil
100+
})
101+
102+
return err
103+
}
104+
68105
func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
69106
var query string
70107

108+
// if device limit is reached, only update devices
109+
if s.deviceLimit > 0 {
110+
count, err := s.CountDevices(ctx, time.Now().UTC().Add(-anonymousDeviceExpiration), time.Now().UTC().Add(time.Minute))
111+
if err != nil {
112+
return err
113+
}
114+
115+
if count >= s.deviceLimit {
116+
return s.updateDevice(ctx, device)
117+
}
118+
}
119+
71120
args := []any{device.DeviceID, device.ClientIP, device.UserAgent,
72121
device.CreatedAt.UTC(), device.UpdatedAt.UTC()}
73122
switch s.sqlStore.GetDBType() {

pkg/services/anonymous/anonimpl/anonstore/database_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) {
1515
store := db.InitTestDB(t)
16-
anonDBStore := ProvideAnonDBStore(store)
16+
anonDBStore := ProvideAnonDBStore(store, 0)
1717
const keepFor = time.Hour * 24 * 61
1818

1919
anonDevice := &Device{
@@ -48,9 +48,30 @@ func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) {
4848
assert.Equal(t, "keep", devices[0].DeviceID)
4949
}
5050

51+
func TestIntegrationBeyondDeviceLimit(t *testing.T) {
52+
store := db.InitTestDB(t)
53+
anonDBStore := ProvideAnonDBStore(store, 1)
54+
55+
anonDevice := &Device{
56+
DeviceID: "32mdo31deeqwes",
57+
ClientIP: "10.30.30.2",
58+
UserAgent: "test",
59+
UpdatedAt: time.Now().Add(-time.Hour),
60+
}
61+
62+
err := anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
63+
require.NoError(t, err)
64+
65+
anonDevice.DeviceID = "keep"
66+
anonDevice.UpdatedAt = time.Now().Add(-time.Hour)
67+
68+
err = anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
69+
require.ErrorIs(t, err, ErrDeviceLimitReached)
70+
}
71+
5172
func TestIntegrationAnonStore_DeleteDevice(t *testing.T) {
5273
store := db.InitTestDB(t)
53-
anonDBStore := ProvideAnonDBStore(store)
74+
anonDBStore := ProvideAnonDBStore(store, 0)
5475
const keepFor = time.Hour * 24 * 61
5576

5677
anonDevice := &Device{

pkg/services/anonymous/anonimpl/api/api.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ import (
1515
"github.com/grafana/grafana/pkg/util"
1616
)
1717

18-
const (
19-
thirtyDays = 30 * 24 * time.Hour
20-
)
18+
const anonymousDeviceExpiration = 30 * 24 * time.Hour
2119

2220
type deviceDTO struct {
2321
anonstore.Device
@@ -70,7 +68,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() {
7068
// 404: notFoundError
7169
// 500: internalServerError
7270
func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) response.Response {
73-
fromTime := time.Now().Add(-thirtyDays)
71+
fromTime := time.Now().Add(-anonymousDeviceExpiration)
7472
toTime := time.Now()
7573

7674
devices, err := api.store.ListDevices(c.Req.Context(), &fromTime, &toTime)

pkg/services/anonymous/anonimpl/client.go

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@ package anonimpl
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
67
"strings"
7-
"time"
88

99
"github.com/grafana/grafana/pkg/infra/log"
1010
"github.com/grafana/grafana/pkg/services/anonymous"
11+
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
1112
"github.com/grafana/grafana/pkg/services/authn"
1213
"github.com/grafana/grafana/pkg/services/org"
1314
"github.com/grafana/grafana/pkg/setting"
1415
)
1516

1617
var _ authn.ContextAwareClient = new(Anonymous)
1718

18-
const timeoutTag = 2 * time.Minute
19-
2019
type Anonymous struct {
2120
cfg *setting.Cfg
2221
log log.Logger
@@ -42,19 +41,13 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.
4241
httpReqCopy.RemoteAddr = r.HTTPRequest.RemoteAddr
4342
}
4443

45-
go func() {
46-
defer func() {
47-
if err := recover(); err != nil {
48-
a.log.Warn("Tag anon session panic", "err", err)
49-
}
50-
}()
51-
52-
newCtx, cancel := context.WithTimeout(context.Background(), timeoutTag)
53-
defer cancel()
54-
if err := a.anonDeviceService.TagDevice(newCtx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
55-
a.log.Warn("Failed to tag anonymous session", "error", err)
44+
if err := a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
45+
if errors.Is(err, anonstore.ErrDeviceLimitReached) {
46+
return nil, err
5647
}
57-
}()
48+
49+
a.log.Warn("Failed to tag anonymous session", "error", err)
50+
}
5851

5952
return &authn.Identity{
6053
ID: authn.AnonymousNamespaceID,

pkg/services/anonymous/anonimpl/impl.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/grafana/grafana/pkg/api/routing"
9+
"github.com/grafana/grafana/pkg/infra/db"
910
"github.com/grafana/grafana/pkg/infra/localcache"
1011
"github.com/grafana/grafana/pkg/infra/log"
1112
"github.com/grafana/grafana/pkg/infra/network"
@@ -33,13 +34,13 @@ type AnonDeviceService struct {
3334
}
3435

3536
func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker authn.Service,
36-
anonStore anonstore.AnonStore, cfg *setting.Cfg, orgService org.Service,
37+
sqlStore db.DB, cfg *setting.Cfg, orgService org.Service,
3738
serverLockService *serverlock.ServerLockService, accesscontrol accesscontrol.AccessControl, routeRegister routing.RouteRegister,
3839
) *AnonDeviceService {
3940
a := &AnonDeviceService{
4041
log: log.New("anonymous-session-service"),
4142
localCache: localcache.New(29*time.Minute, 15*time.Minute),
42-
anonStore: anonStore,
43+
anonStore: anonstore.ProvideAnonDBStore(sqlStore, cfg.AnonymousDeviceLimit),
4344
serverLock: serverLockService,
4445
}
4546

@@ -57,7 +58,7 @@ func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker aut
5758
authBroker.RegisterPostLoginHook(a.untagDevice, 100)
5859
}
5960

60-
anonAPI := api.NewAnonDeviceServiceAPI(cfg, anonStore, accesscontrol, routeRegister)
61+
anonAPI := api.NewAnonDeviceServiceAPI(cfg, a.anonStore, accesscontrol, routeRegister)
6162
anonAPI.RegisterAPIEndpoints()
6263

6364
return a
@@ -143,6 +144,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request
143144
err = a.tagDeviceUI(ctx, httpReq, taggedDevice)
144145
if err != nil {
145146
a.log.Debug("Failed to tag device for UI", "error", err)
147+
return err
146148
}
147149

148150
return nil

pkg/services/anonymous/anonimpl/impl_test.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,15 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
113113
for _, tc := range testCases {
114114
t.Run(tc.name, func(t *testing.T) {
115115
store := db.InitTestDB(t)
116-
anonDBStore := anonstore.ProvideAnonDBStore(store)
117116
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
118-
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
117+
&authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
119118

120119
for _, req := range tc.req {
121120
err := anonService.TagDevice(context.Background(), req.httpReq, req.kind)
122121
require.NoError(t, err)
123122
}
124123

125-
devices, err := anonDBStore.ListDevices(context.Background(), nil, nil)
124+
devices, err := anonService.anonStore.ListDevices(context.Background(), nil, nil)
126125
require.NoError(t, err)
127126
require.Len(t, devices, int(tc.expectedAnonUICount))
128127
if tc.expectedDevice != nil {
@@ -149,9 +148,8 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
149148
// Ensure that the local cache prevents request from being tagged
150149
func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
151150
store := db.InitTestDB(t)
152-
anonDBStore := anonstore.ProvideAnonDBStore(store)
153151
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
154-
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
152+
&authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
155153

156154
req := &http.Request{
157155
Header: http.Header{

0 commit comments

Comments
 (0)