Anon: Scaffold anon service (#74744)
* remove API tagging method and authed tagging * add anonstore move debug to after cache change test order fix issue where mysql trims to second * add old device cleanup lint utc-ize everything trim whitespace * remove dangling setting * Add delete devices * Move anonymous authnclient to anonimpl * Add simple post login hook * move registration of Background Service cleanup * add updated_at index * do not untag device if login err * add delete device integration test
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package anonstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
|
||||
const cacheKeyPrefix = "anon-device"
|
||||
|
||||
type AnonDBStore struct {
|
||||
sqlStore db.DB
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID int64 `json:"-" db:"id"`
|
||||
DeviceID string `json:"device_id" db:"device_id"`
|
||||
ClientIP string `json:"client_ip" db:"client_ip"`
|
||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (a *Device) CacheKey() string {
|
||||
return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":")
|
||||
}
|
||||
|
||||
type AnonStore interface {
|
||||
// ListDevices returns all devices that have been updated between the given times.
|
||||
ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error)
|
||||
// CreateOrUpdateDevice creates or updates a device.
|
||||
CreateOrUpdateDevice(ctx context.Context, device *Device) error
|
||||
// CountDevices returns the number of devices that have been updated between the given times.
|
||||
CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error)
|
||||
// DeleteDevice deletes a device by its ID.
|
||||
DeleteDevice(ctx context.Context, deviceID string) error
|
||||
// DeleteDevicesOlderThan deletes all devices that have no been updated since the given time.
|
||||
DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error
|
||||
}
|
||||
|
||||
func ProvideAnonDBStore(sqlStore db.DB) *AnonDBStore {
|
||||
return &AnonDBStore{sqlStore: sqlStore, log: log.New("anonstore")}
|
||||
}
|
||||
|
||||
func (s *AnonDBStore) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error) {
|
||||
devices := []*Device{}
|
||||
query := "SELECT * FROM anon_device"
|
||||
args := []any{}
|
||||
if from != nil && to != nil {
|
||||
query += " WHERE updated_at BETWEEN ? AND ?"
|
||||
args = append(args, from.UTC(), to.UTC())
|
||||
}
|
||||
err := s.sqlStore.GetSqlxSession().Select(ctx, &devices, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
|
||||
var query string
|
||||
|
||||
args := []any{device.DeviceID, device.ClientIP, device.UserAgent,
|
||||
device.CreatedAt.UTC(), device.UpdatedAt.UTC()}
|
||||
switch s.sqlStore.GetDBType() {
|
||||
case migrator.Postgres:
|
||||
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
client_ip = $2,
|
||||
user_agent = $3,
|
||||
updated_at = $5
|
||||
RETURNING id`
|
||||
case migrator.MySQL:
|
||||
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
client_ip = VALUES(client_ip),
|
||||
user_agent = VALUES(user_agent),
|
||||
updated_at = VALUES(updated_at)`
|
||||
case migrator.SQLite:
|
||||
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
client_ip = excluded.client_ip,
|
||||
user_agent = excluded.user_agent,
|
||||
updated_at = excluded.updated_at`
|
||||
default:
|
||||
return fmt.Errorf("unsupported database driver: %s", s.sqlStore.GetDBType())
|
||||
}
|
||||
|
||||
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AnonDBStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.sqlStore.GetSqlxSession().Get(ctx, &count, "SELECT COUNT(*) FROM anon_device WHERE updated_at BETWEEN ? AND ?", from.UTC(), to.UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *AnonDBStore) DeleteDevice(ctx context.Context, deviceID string) error {
|
||||
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, "DELETE FROM anon_device WHERE device_id = ?", deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOldDevices deletes all devices that have no been updated since the given time.
|
||||
func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error {
|
||||
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, "DELETE FROM anon_device WHERE updated_at <= ?", olderThan.UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package anonstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
)
|
||||
|
||||
func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) {
|
||||
store := db.InitTestDB(t)
|
||||
anonDBStore := ProvideAnonDBStore(store)
|
||||
const keepFor = time.Hour * 24 * 61
|
||||
|
||||
anonDevice := &Device{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "10.30.30.2",
|
||||
UserAgent: "test",
|
||||
UpdatedAt: time.Now().Add(-keepFor).Add(-time.Hour),
|
||||
}
|
||||
|
||||
err := anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
|
||||
require.NoError(t, err)
|
||||
|
||||
anonDevice.DeviceID = "keep"
|
||||
anonDevice.UpdatedAt = time.Now().Add(-time.Hour)
|
||||
|
||||
err = anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
|
||||
require.NoError(t, err)
|
||||
|
||||
from := time.Now().Add(-2 * keepFor)
|
||||
to := time.Now()
|
||||
|
||||
count, err := anonDBStore.CountDevices(context.Background(), from, to)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), count)
|
||||
|
||||
err = anonDBStore.DeleteDevicesOlderThan(context.Background(), time.Now().Add(-keepFor))
|
||||
require.NoError(t, err)
|
||||
|
||||
devices, err := anonDBStore.ListDevices(context.Background(), &from, &to)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(devices))
|
||||
assert.Equal(t, "keep", devices[0].DeviceID)
|
||||
}
|
||||
|
||||
func TestIntegrationAnonStore_DeleteDevice(t *testing.T) {
|
||||
store := db.InitTestDB(t)
|
||||
anonDBStore := ProvideAnonDBStore(store)
|
||||
const keepFor = time.Hour * 24 * 61
|
||||
|
||||
anonDevice := &Device{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "10.30.30.2",
|
||||
UserAgent: "test",
|
||||
UpdatedAt: time.Now().Add(-keepFor).Add(-time.Hour),
|
||||
}
|
||||
|
||||
err := anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
|
||||
require.NoError(t, err)
|
||||
|
||||
from := time.Now().Add(-2 * keepFor)
|
||||
to := time.Now()
|
||||
|
||||
count, err := anonDBStore.CountDevices(context.Background(), from, to)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), count)
|
||||
|
||||
err = anonDBStore.DeleteDevice(context.Background(), "32mdo31deeqwes")
|
||||
require.NoError(t, err)
|
||||
|
||||
devices, err := anonDBStore.ListDevices(context.Background(), &from, &to)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(devices))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package anonstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FakeAnonStore struct {
|
||||
}
|
||||
|
||||
func (s *FakeAnonStore) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package anonimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var _ authn.ContextAwareClient = new(Anonymous)
|
||||
|
||||
const timeoutTag = 2 * time.Minute
|
||||
|
||||
type Anonymous struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
orgService org.Service
|
||||
anonDeviceService anonymous.Service
|
||||
}
|
||||
|
||||
func (a *Anonymous) Name() string {
|
||||
return authn.ClientAnonymous
|
||||
}
|
||||
|
||||
func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||
o, err := a.orgService.GetByName(ctx, &org.GetOrgByNameQuery{Name: a.cfg.AnonymousOrgName})
|
||||
if err != nil {
|
||||
a.log.FromContext(ctx).Error("Failed to find organization", "name", a.cfg.AnonymousOrgName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReqCopy := &http.Request{}
|
||||
if r.HTTPRequest != nil && r.HTTPRequest.Header != nil {
|
||||
// avoid r.HTTPRequest.Clone(context.Background()) as we do not require a full clone
|
||||
httpReqCopy.Header = r.HTTPRequest.Header.Clone()
|
||||
httpReqCopy.RemoteAddr = r.HTTPRequest.RemoteAddr
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
a.log.Warn("Tag anon session panic", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
newCtx, cancel := context.WithTimeout(context.Background(), timeoutTag)
|
||||
defer cancel()
|
||||
if err := a.anonDeviceService.TagDevice(newCtx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
|
||||
a.log.Warn("Failed to tag anonymous session", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return &authn.Identity{
|
||||
IsAnonymous: true,
|
||||
OrgID: o.ID,
|
||||
OrgName: o.Name,
|
||||
OrgRoles: map[int64]org.RoleType{o.ID: org.RoleType(a.cfg.AnonymousOrgRole)},
|
||||
ClientParams: authn.ClientParams{SyncPermissions: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Anonymous) Test(ctx context.Context, r *authn.Request) bool {
|
||||
// If anonymous client is register it can always be used for authentication
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Anonymous) Priority() uint {
|
||||
return 100
|
||||
}
|
||||
|
||||
func (a *Anonymous) UsageStatFn(ctx context.Context) (map[string]any, error) {
|
||||
m := map[string]any{}
|
||||
|
||||
// Add stats about anonymous auth
|
||||
m["stats.anonymous.customized_role.count"] = 0
|
||||
if !strings.EqualFold(a.cfg.AnonymousOrgRole, "Viewer") {
|
||||
m["stats.anonymous.customized_role.count"] = 1
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package anonimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anontest"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestAnonymous_Authenticate(t *testing.T) {
|
||||
type TestCase struct {
|
||||
desc string
|
||||
org *org.Org
|
||||
cfg *setting.Cfg
|
||||
err error
|
||||
}
|
||||
|
||||
tests := []TestCase{
|
||||
{
|
||||
desc: "should success with valid org configured",
|
||||
org: &org.Org{ID: 1, Name: "some org"},
|
||||
cfg: &setting.Cfg{
|
||||
AnonymousOrgName: "some org",
|
||||
AnonymousOrgRole: "Viewer",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should return error if any error occurs during org lookup",
|
||||
err: fmt.Errorf("some error"),
|
||||
cfg: &setting.Cfg{
|
||||
AnonymousOrgName: "some org",
|
||||
AnonymousOrgRole: "Viewer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
c := Anonymous{
|
||||
cfg: tt.cfg,
|
||||
log: log.NewNopLogger(),
|
||||
orgService: &orgtest.FakeOrgService{ExpectedOrg: tt.org, ExpectedError: tt.err},
|
||||
anonDeviceService: &anontest.FakeAnonymousSessionService{},
|
||||
}
|
||||
|
||||
identity, err := c.Authenticate(context.Background(), &authn.Request{})
|
||||
if err != nil {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, identity)
|
||||
} else {
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, true, identity.ID == "")
|
||||
assert.Equal(t, tt.org.ID, identity.OrgID)
|
||||
assert.Equal(t, tt.org.Name, identity.OrgName)
|
||||
assert.Equal(t, tt.cfg.AnonymousOrgRole, string(identity.Role()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,144 +2,76 @@ package anonimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/network"
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
const thirtyDays = 30 * 24 * time.Hour
|
||||
const deviceIDHeader = "X-Grafana-Device-Id"
|
||||
|
||||
type Device struct {
|
||||
Kind anonymous.DeviceKind `json:"kind"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
func (a *Device) Key() (string, error) {
|
||||
key := strings.Builder{}
|
||||
key.WriteString(a.IP)
|
||||
key.WriteString(a.UserAgent)
|
||||
|
||||
hash := fnv.New128a()
|
||||
if _, err := hash.Write([]byte(key.String())); err != nil {
|
||||
return "", fmt.Errorf("failed to write to hash: %w", err)
|
||||
}
|
||||
|
||||
return strings.Join([]string{string(a.Kind), hex.EncodeToString(hash.Sum(nil))}, ":"), nil
|
||||
}
|
||||
|
||||
func (a *Device) UIKey(deviceID string) (string, error) {
|
||||
return strings.Join([]string{string(a.Kind), deviceID}, ":"), nil
|
||||
}
|
||||
const keepFor = time.Hour * 24 * 61
|
||||
|
||||
type AnonDeviceService struct {
|
||||
remoteCache remotecache.CacheStorage
|
||||
log log.Logger
|
||||
localCache *localcache.CacheService
|
||||
log log.Logger
|
||||
localCache *localcache.CacheService
|
||||
anonStore anonstore.AnonStore
|
||||
serverLock *serverlock.ServerLockService
|
||||
}
|
||||
|
||||
func ProvideAnonymousDeviceService(remoteCache remotecache.CacheStorage, usageStats usagestats.Service) *AnonDeviceService {
|
||||
func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker authn.Service,
|
||||
anonStore anonstore.AnonStore, cfg *setting.Cfg, orgService org.Service,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
) *AnonDeviceService {
|
||||
a := &AnonDeviceService{
|
||||
remoteCache: remoteCache,
|
||||
log: log.New("anonymous-session-service"),
|
||||
localCache: localcache.New(29*time.Minute, 15*time.Minute),
|
||||
log: log.New("anonymous-session-service"),
|
||||
localCache: localcache.New(29*time.Minute, 15*time.Minute),
|
||||
anonStore: anonStore,
|
||||
serverLock: serverLockService,
|
||||
}
|
||||
|
||||
usageStats.RegisterMetricsFunc(a.usageStatFn)
|
||||
|
||||
anonClient := &Anonymous{
|
||||
cfg: cfg,
|
||||
log: log.New("authn.anonymous"),
|
||||
orgService: orgService,
|
||||
anonDeviceService: a,
|
||||
}
|
||||
|
||||
if anonClient.cfg.AnonymousEnabled {
|
||||
authBroker.RegisterClient(anonClient)
|
||||
authBroker.RegisterPostLoginHook(a.untagDevice, 100)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) usageStatFn(ctx context.Context) (map[string]any, error) {
|
||||
anonDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AnonDevice))
|
||||
// Count the number of unique devices that have been updated in the last 30 days.
|
||||
// One minute is added to the end time as mysql has a precision of seconds and it will break tests that write too fast.
|
||||
anonUIDeviceCount, err := a.anonStore.CountDevices(ctx, time.Now().Add(-thirtyDays), time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
authedDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AuthedDevice))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
anonUIDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AnonDeviceUI))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
authedUIDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AuthedDeviceUI))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"stats.anonymous.session.count": anonDeviceCount, // keep session for legacy data
|
||||
"stats.users.device.count": authedDeviceCount,
|
||||
"stats.anonymous.device.ui.count": anonUIDeviceCount,
|
||||
"stats.users.device.ui.count": authedUIDeviceCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) untagDevice(ctx context.Context, device *Device) error {
|
||||
key, err := device.Key()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.remoteCache.Delete(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) untagUIDevice(ctx context.Context, deviceID string, device *Device) error {
|
||||
key, err := device.UIKey(deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.remoteCache.Delete(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device Device) error {
|
||||
deviceID := httpReq.Header.Get(deviceIDHeader)
|
||||
if deviceID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if device.Kind == anonymous.AnonDevice {
|
||||
device.Kind = anonymous.AnonDeviceUI
|
||||
} else if device.Kind == anonymous.AuthedDevice {
|
||||
device.Kind = anonymous.AuthedDeviceUI
|
||||
}
|
||||
|
||||
key, err := device.UIKey(deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Env == setting.Dev {
|
||||
a.log.Debug("Tagging device for UI", "deviceID", deviceID, "device", device, "key", key)
|
||||
}
|
||||
func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device *anonstore.Device) error {
|
||||
key := device.CacheKey()
|
||||
|
||||
if _, ok := a.localCache.Get(key); ok {
|
||||
return nil
|
||||
@@ -147,33 +79,41 @@ func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Reque
|
||||
|
||||
a.localCache.SetDefault(key, struct{}{})
|
||||
|
||||
deviceJSON, err := json.Marshal(device)
|
||||
if err != nil {
|
||||
return err
|
||||
if setting.Env == setting.Dev {
|
||||
a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key)
|
||||
}
|
||||
|
||||
if err := a.remoteCache.Set(ctx, key, deviceJSON, thirtyDays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove existing tag when device switches to another kind
|
||||
untagKind := anonymous.AnonDeviceUI
|
||||
if device.Kind == anonymous.AnonDeviceUI {
|
||||
untagKind = anonymous.AuthedDeviceUI
|
||||
}
|
||||
|
||||
if err := a.untagUIDevice(ctx, deviceID, &Device{
|
||||
Kind: untagKind,
|
||||
IP: device.IP,
|
||||
UserAgent: device.UserAgent,
|
||||
}); err != nil {
|
||||
if err := a.anonStore.CreateOrUpdateDevice(ctx, device); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) untagDevice(ctx context.Context,
|
||||
identity *authn.Identity, r *authn.Request, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := r.HTTPRequest.Header.Get(deviceIDHeader)
|
||||
if deviceID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
errD := a.anonStore.DeleteDevice(ctx, deviceID)
|
||||
if errD != nil {
|
||||
a.log.Debug("Failed to untag device", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Unexport and remove interface
|
||||
func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request, kind anonymous.DeviceKind) error {
|
||||
deviceID := httpReq.Header.Get(deviceIDHeader)
|
||||
if deviceID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := web.RemoteAddr(httpReq)
|
||||
ip, err := network.GetIPFromAddress(addr)
|
||||
if err != nil {
|
||||
@@ -186,54 +126,39 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request
|
||||
clientIPStr = ""
|
||||
}
|
||||
|
||||
taggedDevice := &Device{
|
||||
Kind: kind,
|
||||
IP: clientIPStr,
|
||||
taggedDevice := &anonstore.Device{
|
||||
DeviceID: deviceID,
|
||||
ClientIP: clientIPStr,
|
||||
UserAgent: httpReq.UserAgent(),
|
||||
LastSeen: time.Now().UTC(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err = a.tagDeviceUI(ctx, httpReq, *taggedDevice)
|
||||
err = a.tagDeviceUI(ctx, httpReq, taggedDevice)
|
||||
if err != nil {
|
||||
a.log.Debug("Failed to tag device for UI", "error", err)
|
||||
}
|
||||
|
||||
key, err := taggedDevice.Key()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Env == setting.Dev {
|
||||
a.log.Debug("Tagging device", "device", taggedDevice, "key", key)
|
||||
}
|
||||
|
||||
if _, ok := a.localCache.Get(key); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.localCache.SetDefault(key, struct{}{})
|
||||
|
||||
deviceJSON, err := json.Marshal(taggedDevice)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.remoteCache.Set(ctx, key, deviceJSON, thirtyDays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove existing tag when device switches to another kind
|
||||
untagKind := anonymous.AnonDevice
|
||||
if kind == anonymous.AnonDevice {
|
||||
untagKind = anonymous.AuthedDevice
|
||||
}
|
||||
if err := a.untagDevice(ctx, &Device{
|
||||
Kind: untagKind,
|
||||
IP: taggedDevice.IP,
|
||||
UserAgent: taggedDevice.UserAgent,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(2 * time.Hour)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := a.serverLock.LockAndExecute(ctx, "cleanup old anon devices", time.Hour*10, func(context.Context) {
|
||||
if err := a.anonStore.DeleteDevicesOlderThan(ctx, time.Now().Add(-keepFor)); err != nil {
|
||||
a.log.Error("An error occurred while deleting old anon devices", "err", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Error("Failed to lock and execute cleanup old anon devices", "error", err)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package anonimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,79 +9,30 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestAnonDeviceKey(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
session *Device
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "should hash correctly",
|
||||
session: &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.10.10.10",
|
||||
UserAgent: "test",
|
||||
},
|
||||
expected: "anon-session:ad9f5c6bf504a9fa77c37a3a6658c0cd",
|
||||
},
|
||||
{
|
||||
name: "should hash correctly with different ip",
|
||||
session: &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.10.10.1",
|
||||
UserAgent: "test",
|
||||
},
|
||||
expected: "anon-session:580605320245e8289e0b301074a027c3",
|
||||
},
|
||||
{
|
||||
name: "should hash correctly with different user agent",
|
||||
session: &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.10.10.1",
|
||||
UserAgent: "test2",
|
||||
},
|
||||
expected: "anon-session:5fdd04b0bd04a9fa77c4243f8111258b",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.session.Key()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, got)
|
||||
|
||||
// ensure that the key is the same
|
||||
got, err = tc.session.Key()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
type tagReq struct {
|
||||
httpReq *http.Request
|
||||
kind anonymous.DeviceKind
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
req []tagReq
|
||||
expectedAnonCount int64
|
||||
expectedAuthedCount int64
|
||||
expectedAnonUICount int64
|
||||
expectedAuthedUICount int64
|
||||
expectedDevice *Device
|
||||
name string
|
||||
req []tagReq
|
||||
expectedAnonUICount int64
|
||||
expectedKey string
|
||||
expectedDevice *anonstore.Device
|
||||
}{
|
||||
{
|
||||
name: "no requests",
|
||||
req: []tagReq{{httpReq: &http.Request{}, kind: anonymous.AnonDevice}},
|
||||
expectedAnonCount: 0,
|
||||
expectedAuthedCount: 0,
|
||||
name: "no requests",
|
||||
req: []tagReq{{httpReq: &http.Request{}, kind: anonymous.AnonDeviceUI}},
|
||||
},
|
||||
{
|
||||
name: "missing info should not tag",
|
||||
@@ -91,28 +41,8 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
"User-Agent": []string{"test"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
}},
|
||||
expectedAnonCount: 0,
|
||||
expectedAuthedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "should tag once",
|
||||
req: []tagReq{{httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
},
|
||||
},
|
||||
expectedAnonCount: 1,
|
||||
expectedAuthedCount: 0,
|
||||
expectedDevice: &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.30.30.1",
|
||||
UserAgent: "test"},
|
||||
},
|
||||
{
|
||||
name: "should tag device ID once",
|
||||
@@ -123,16 +53,14 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
},
|
||||
},
|
||||
expectedAnonUICount: 1,
|
||||
expectedAuthedUICount: 0,
|
||||
expectedAnonCount: 1,
|
||||
expectedAuthedCount: 0,
|
||||
expectedDevice: &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.30.30.1",
|
||||
expectedAnonUICount: 1,
|
||||
expectedKey: "ui-anon-session:32mdo31deeqwes",
|
||||
expectedDevice: &anonstore.Device{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "10.30.30.1",
|
||||
UserAgent: "test"},
|
||||
},
|
||||
{
|
||||
@@ -144,7 +72,7 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
@@ -152,61 +80,12 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
},
|
||||
},
|
||||
expectedAnonCount: 1,
|
||||
expectedAnonUICount: 1,
|
||||
expectedAuthedCount: 0,
|
||||
}, {
|
||||
name: "authed request should untag anon",
|
||||
req: []tagReq{{httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AuthedDevice,
|
||||
},
|
||||
},
|
||||
expectedAnonCount: 0,
|
||||
expectedAuthedCount: 1,
|
||||
expectedAuthedUICount: 1,
|
||||
}, {
|
||||
name: "anon request should untag authed",
|
||||
req: []tagReq{{httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AuthedDevice,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
"X-Forwarded-For": []string{"10.30.30.1"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
},
|
||||
},
|
||||
expectedAnonCount: 1,
|
||||
expectedAnonUICount: 1,
|
||||
expectedAuthedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "tag 4 different requests - 2 are UI",
|
||||
name: "tag 2 different requests",
|
||||
req: []tagReq{{httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
http.CanonicalHeaderKey("User-Agent"): []string{"test"},
|
||||
@@ -214,106 +93,87 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"a"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.2"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AnonDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.3"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"c"},
|
||||
"X-Forwarded-For": []string{"10.30.30.2"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"b"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AuthedDevice,
|
||||
}, {httpReq: &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.4"},
|
||||
},
|
||||
},
|
||||
kind: anonymous.AuthedDevice,
|
||||
kind: anonymous.AnonDeviceUI,
|
||||
},
|
||||
},
|
||||
expectedAnonCount: 2,
|
||||
expectedAuthedCount: 2,
|
||||
expectedAnonUICount: 1,
|
||||
expectedAuthedUICount: 1,
|
||||
expectedAnonUICount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fakeStore := remotecache.NewFakeStore(t)
|
||||
|
||||
anonService := ProvideAnonymousDeviceService(fakeStore, &usagestats.UsageStatsMock{})
|
||||
store := db.InitTestDB(t)
|
||||
anonDBStore := anonstore.ProvideAnonDBStore(store)
|
||||
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
|
||||
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil)
|
||||
|
||||
for _, req := range tc.req {
|
||||
err := anonService.TagDevice(context.Background(), req.httpReq, req.kind)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
devices, err := anonDBStore.ListDevices(context.Background(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, devices, int(tc.expectedAnonUICount))
|
||||
if tc.expectedDevice != nil {
|
||||
device := devices[0]
|
||||
assert.NotZero(t, device.ID)
|
||||
assert.NotZero(t, device.CreatedAt)
|
||||
assert.NotZero(t, device.UpdatedAt)
|
||||
|
||||
tc.expectedDevice.ID = device.ID
|
||||
tc.expectedDevice.CreatedAt = device.CreatedAt
|
||||
tc.expectedDevice.UpdatedAt = device.UpdatedAt
|
||||
|
||||
assert.Equal(t, tc.expectedDevice, devices[0])
|
||||
}
|
||||
|
||||
stats, err := anonService.usageStatFn(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedAnonCount, stats["stats.anonymous.session.count"].(int64))
|
||||
assert.Equal(t, tc.expectedAuthedCount, stats["stats.users.device.count"].(int64))
|
||||
assert.Equal(t, tc.expectedAnonUICount, stats["stats.anonymous.device.ui.count"].(int64))
|
||||
assert.Equal(t, tc.expectedAuthedUICount, stats["stats.users.device.ui.count"].(int64))
|
||||
|
||||
if tc.expectedDevice != nil {
|
||||
key, err := tc.expectedDevice.Key()
|
||||
require.NoError(t, err)
|
||||
|
||||
k, err := fakeStore.Get(context.Background(), key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotDevice := &Device{}
|
||||
err = json.Unmarshal(k, gotDevice)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, gotDevice.LastSeen)
|
||||
gotDevice.LastSeen = time.Time{}
|
||||
|
||||
assert.Equal(t, tc.expectedDevice, gotDevice)
|
||||
}
|
||||
assert.Equal(t, tc.expectedAnonUICount, stats["stats.anonymous.device.ui.count"].(int64), stats)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that the local cache prevents request from being tagged
|
||||
func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
|
||||
fakeStore := remotecache.NewFakeStore(t)
|
||||
anonService := ProvideAnonymousDeviceService(fakeStore, &usagestats.UsageStatsMock{})
|
||||
store := db.InitTestDB(t)
|
||||
anonDBStore := anonstore.ProvideAnonDBStore(store)
|
||||
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
|
||||
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil)
|
||||
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.2"},
|
||||
"User-Agent": []string{"test"},
|
||||
"X-Forwarded-For": []string{"10.30.30.2"},
|
||||
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
|
||||
},
|
||||
}
|
||||
|
||||
anonDevice := &Device{
|
||||
Kind: anonymous.AnonDevice,
|
||||
IP: "10.30.30.2",
|
||||
anonDevice := &anonstore.Device{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "10.30.30.2",
|
||||
UserAgent: "test",
|
||||
LastSeen: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
key, err := anonDevice.Key()
|
||||
require.NoError(t, err)
|
||||
|
||||
key := anonDevice.CacheKey()
|
||||
anonService.localCache.SetDefault(key, true)
|
||||
|
||||
err = anonService.TagDevice(context.Background(), req, anonymous.AnonDevice)
|
||||
err := anonService.TagDevice(context.Background(), req, anonymous.AnonDeviceUI)
|
||||
require.NoError(t, err)
|
||||
|
||||
stats, err := anonService.usageStatFn(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(0), stats["stats.anonymous.session.count"].(int64))
|
||||
assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user