From 2afe2ffe0ae69e9468f69618ec360c254fc2524e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 00:54:10 -0400 Subject: [PATCH] feat(api): add api_grant storage for API v1 grants --- .../20260926045200_create_api_grant.sql | 23 +++ model/datastore.go | 1 + model/grant.go | 62 ++++++ persistence/grant_repository.go | 123 +++++++++++ persistence/grant_repository_test.go | 192 ++++++++++++++++++ persistence/persistence.go | 10 + tests/mock_data_store.go | 14 ++ tests/mock_grant_repo.go | 8 + 8 files changed, 433 insertions(+) create mode 100644 db/migrations/20260926045200_create_api_grant.sql create mode 100644 model/grant.go create mode 100644 persistence/grant_repository.go create mode 100644 persistence/grant_repository_test.go create mode 100644 tests/mock_grant_repo.go diff --git a/db/migrations/20260926045200_create_api_grant.sql b/db/migrations/20260926045200_create_api_grant.sql new file mode 100644 index 000000000..84fec2622 --- /dev/null +++ b/db/migrations/20260926045200_create_api_grant.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +create table api_grant ( + id varchar not null primary key, + user_id varchar not null references user(id) on delete cascade, + name varchar not null, + client varchar not null, + client_version varchar not null default '', + scopes varchar not null default '', + provider varchar not null, + secret_hash varchar not null unique, + user_epoch integer not null default 0, + created_at datetime not null, + last_used_at datetime, + last_used_ip varchar not null default '' +); +create index api_grant_user_id on api_grant(user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop table api_grant; +-- +goose StatementEnd diff --git a/model/datastore.go b/model/datastore.go index 6ded8c575..1175d212d 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -37,6 +37,7 @@ type DataStore interface { Plugin() PluginRepository Artwork() ArtworkRepository ArtworkQueue() ArtworkQueueRepository + Grant() GrantRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error diff --git a/model/grant.go b/model/grant.go new file mode 100644 index 000000000..875c3bc86 --- /dev/null +++ b/model/grant.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "database/sql/driver" + "fmt" + "strings" + "time" +) + +type Grant struct { + ID string `structs:"id" json:"id"` + UserID string `structs:"user_id" json:"userId"` + Name string `structs:"name" json:"name"` + Client string `structs:"client" json:"client"` + ClientVersion string `structs:"client_version" json:"clientVersion"` + Scopes Scopes `structs:"scopes" json:"scopes"` + Provider string `structs:"provider" json:"provider"` + SecretHash string `structs:"secret_hash" json:"-"` + UserEpoch int `structs:"user_epoch" json:"-"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"` + LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"` +} + +type Grants []Grant + +// Scopes is stored as a single space-separated column. +type Scopes []string + +func (s Scopes) Value() (driver.Value, error) { + return strings.Join(s, " "), nil +} + +func (s *Scopes) Scan(src any) error { + switch v := src.(type) { + case string: + *s = strings.Fields(v) + case []byte: + *s = strings.Fields(string(v)) + case nil: + *s = nil + default: + return fmt.Errorf("cannot scan %T into Scopes", src) + } + return nil +} + +type GrantRepository interface { + Put(ctx context.Context, g *Grant) error + Get(ctx context.Context, id string) (*Grant, error) + FindBySecretHash(ctx context.Context, hash string) (*Grant, error) + GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (Grants, error) + CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) + Delete(ctx context.Context, id string) error + DeleteForUser(ctx context.Context, userID, id string) error + DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error + SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error + DeleteIfEpoch(ctx context.Context, id string, epoch int) error + Touch(ctx context.Context, id, ip string, at, notSince time.Time) error + DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) +} diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go new file mode 100644 index 000000000..7a9ca9fe8 --- /dev/null +++ b/persistence/grant_repository.go @@ -0,0 +1,123 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type grantRepository struct { + sqlRepository +} + +func NewGrantRepository(db dbx.Builder) model.GrantRepository { + r := &grantRepository{} + r.db = db + r.tableName = "api_grant" + return r +} + +const grantLastActivity = "COALESCE(last_used_at, created_at)" + +func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error { + if g.ID == "" { + g.ID = id.NewRandom() + } + if g.CreatedAt.IsZero() { + g.CreatedAt = time.Now() + } + // Stored as UTC: SQLite compares these timestamps as strings. + g.CreatedAt = g.CreatedAt.UTC() + if g.LastUsedAt != nil { + t := g.LastUsedAt.UTC() + g.LastUsedAt = &t + } + values, err := toSQLArgs(*g) + if err != nil { + return err + } + _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) + return err +} + +func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) { + return r.findOne(ctx, Eq{"id": id}) +} + +func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { + return r.findOne(ctx, Eq{"secret_hash": hash}) +} + +func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) { + var g model.Grant + if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil { + return nil, err + } + return &g, nil +} + +func (r *grantRepository) activeForUser(userID string, idleSince time.Time) Sqlizer { + return And{Eq{"user_id": userID}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} +} + +func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (model.Grants, error) { + sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, idleSince)). + OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id"). + Offset(uint64(offset)).Limit(uint64(limit)) + var res model.Grants + err := r.queryAll(ctx, sel, &res) + return res, err +} + +func (r *grantRepository) CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) { + return r.count(ctx, Select().Where(r.activeForUser(userID, idleSince))) +} + +func (r *grantRepository) Delete(ctx context.Context, id string) error { + return r.delete(ctx, Eq{"id": id}) +} + +func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error { + n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID})) + if err != nil { + return err + } + if n == 0 { + return model.ErrNotFound + } + return nil +} + +func (r *grantRepository) DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error { + _, err := r.executeSQL(ctx, Delete(r.tableName).Where(And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}})) + return err +} + +// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back. +func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error { + cond := Eq{"user_id": userID, "user_epoch": fromEpoch} + if onlyID != "" { + cond["id"] = onlyID + } + _, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond)) + return err +} + +func (r *grantRepository) DeleteIfEpoch(ctx context.Context, id string, epoch int) error { + return r.delete(ctx, Eq{"id": id, "user_epoch": epoch}) +} + +func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error { + upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip). + Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}}) + _, err := r.executeSQL(ctx, upd) + return err +} + +func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { + return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC()))) +} diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go new file mode 100644 index 000000000..f621a5cc2 --- /dev/null +++ b/persistence/grant_repository_test.go @@ -0,0 +1,192 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GrantRepository", func() { + var ctx context.Context + var repo model.GrantRepository + var now time.Time + + newGrant := func(userID, hash string) *model.Grant { + return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"}, + Provider: "password", SecretHash: hash, CreatedAt: now} + } + + BeforeEach(func() { + ctx = log.NewContext(GinkgoT().Context()) + repo = NewGrantRepository(GetDBXBuilder()) + now = time.Now().UTC().Truncate(time.Second) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute() + }) + }) + + It("stores a grant and finds it by id and by secret hash", func() { + g := newGrant(adminUser.ID, "hash-1") + g.Scopes = model.Scopes{"read", "password"} + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(g.ID).ToNot(BeEmpty()) + + byID, err := repo.Get(ctx, g.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"})) + Expect(byID.LastUsedAt).To(BeNil()) + Expect(byID.LastUsedIP).To(BeEmpty()) + + byHash, err := repo.FindBySecretHash(ctx, "hash-1") + Expect(err).ToNot(HaveOccurred()) + Expect(byHash.ID).To(Equal(g.ID)) + }) + + It("returns ErrNotFound for unknown ids and hashes", func() { + _, err := repo.Get(ctx, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.FindBySecretHash(ctx, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("lists and counts only the user's non-idle grants by lastUsedAt, never-used ones last", func() { + old := newGrant(adminUser.ID, "h-old") + old.CreatedAt = now.Add(-100 * 24 * time.Hour) + usedEarly := newGrant(adminUser.ID, "h-used-early") + usedEarly.CreatedAt = now.Add(-10 * time.Hour) + earlyUse := now.Add(-5 * time.Hour) + usedEarly.LastUsedAt = &earlyUse + usedLate := newGrant(adminUser.ID, "h-used-late") + usedLate.CreatedAt = now.Add(-10 * time.Hour) + lateUse := now.Add(-time.Hour) + usedLate.LastUsedAt = &lateUse + freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used + other := newGrant(regularUser.ID, "h-other") + for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other} { + Expect(repo.Put(ctx, g)).To(Succeed()) + } + idleSince := now.Add(-90 * 24 * time.Hour) + + list, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID})) + + Expect(repo.CountForUser(ctx, adminUser.ID, idleSince)).To(Equal(int64(3))) + + page, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 1, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(HaveLen(1)) + Expect(page[0].ID).To(Equal(usedEarly.ID)) + }) + + It("deletes a grant only for its owner", func() { + g := newGrant(adminUser.ID, "h-own") + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound)) + Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("moves epochs forward and deletes grants left on other epochs", func() { + keep := newGrant(adminUser.ID, "h-keep") + drop := newGrant(adminUser.ID, "h-drop") + Expect(repo.Put(ctx, keep)).To(Succeed()) + Expect(repo.Put(ctx, drop)).To(Succeed()) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed()) + Expect(repo.DeleteOtherEpochs(ctx, adminUser.ID, 3)).To(Succeed()) + + kept, err := repo.Get(ctx, keep.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.UserEpoch).To(Equal(3)) + _, err = repo.Get(ctx, drop.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed()) + kept, _ = repo.Get(ctx, keep.ID) + Expect(kept.UserEpoch).To(Equal(4)) + }) + + It("never moves a grant that is not on fromEpoch", func() { + stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change + stale.UserEpoch = 1 + current := newGrant(adminUser.ID, "h-current") + current.UserEpoch = 2 + Expect(repo.Put(ctx, stale)).To(Succeed()) + Expect(repo.Put(ctx, current)).To(Succeed()) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed()) + got, _ := repo.Get(ctx, stale.ID) + Expect(got.UserEpoch).To(Equal(1)) + got, _ = repo.Get(ctx, current.ID) + Expect(got.UserEpoch).To(Equal(3)) + }) + + It("deletes by epoch only while the row is still on it", func() { + g := newGrant(adminUser.ID, "h-cond") + g.UserEpoch = 5 + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(repo.DeleteIfEpoch(ctx, g.ID, 4)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.DeleteIfEpoch(ctx, g.ID, 5)).To(Succeed()) + _, err = repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("touches a never-used grant, then throttles until notSince passes", func() { + g := newGrant(adminUser.ID, "h-touch") + Expect(repo.Put(ctx, g)).To(Succeed()) + + Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed()) + got, _ := repo.Get(ctx, g.ID) + Expect(got.LastUsedAt).ToNot(BeNil()) + Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now)) + Expect(got.LastUsedIP).To(Equal("10.0.0.1")) + + later := now.Add(time.Minute) + Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed()) + got, _ = repo.Get(ctx, g.ID) + Expect(got.LastUsedIP).To(Equal("10.0.0.1")) + + muchLater := now.Add(6 * time.Minute) + Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed()) + got, _ = repo.Get(ctx, g.ID) + Expect(got.LastUsedIP).To(Equal("10.0.0.3")) + }) + + It("deletes idle grants, using created_at for never-used ones", func() { + idle := newGrant(adminUser.ID, "h-idle") + idle.CreatedAt = now.Add(-100 * 24 * time.Hour) + usedRecently := newGrant(adminUser.ID, "h-used-recently") + usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour) + recentUse := now.Add(-time.Hour) + usedRecently.LastUsedAt = &recentUse + Expect(repo.Put(ctx, idle)).To(Succeed()) + Expect(repo.Put(ctx, usedRecently)).To(Succeed()) + + n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + _, err = repo.Get(ctx, usedRecently.ID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("deletes a user's grants when the user is deleted", func() { + users := NewUserRepository(GetDBXBuilder()) + u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"} + Expect(users.Put(ctx, &u)).To(Succeed()) + g := newGrant(u.ID, "h-cascade") + Expect(repo.Put(ctx, g)).To(Succeed()) + + Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/persistence/persistence.go b/persistence/persistence.go index 44e944bff..8feda0c8f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -37,6 +37,7 @@ type SQLStore struct { plugin func() model.PluginRepository artwork func() model.ArtworkRepository artworkQueue func() model.ArtworkQueueRepository + grant func() model.GrantRepository } // Repositories are built on first use, so a transaction store only pays for the ones its block touches. @@ -64,6 +65,7 @@ func newSQLStore(db dbx.Builder) *SQLStore { plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), + grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }), } } @@ -155,6 +157,10 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { return s.artworkQueue() } +func (s *SQLStore) Grant() model.GrantRepository { + return s.grant() +} + func scopeLabel(scope []string) string { if len(scope) > 0 { return scope[0] @@ -271,6 +277,10 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), + trace(ctx, "purge idle API grants", func() error { + _, err := s.grant().DeleteIdle(ctx, time.Now().Add(-90*24*time.Hour)) + return err + }), ) if err != nil { return fmt.Errorf("tidying up database: %w", err) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index a5e4126cf..79b8529b4 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -30,6 +30,7 @@ type MockDataStore struct { MockedPlugin model.PluginRepository MockedArtwork model.ArtworkRepository MockedArtworkQueue model.ArtworkQueueRepository + MockedGrant model.GrantRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -321,6 +322,19 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { return db.MockedArtworkQueue } +func (db *MockDataStore) Grant() model.GrantRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedGrant != nil { + return db.MockedGrant + } + if db.RealDS != nil { + return db.RealDS.Grant() + } + db.MockedGrant = &MockedGrantRepo{} + return db.MockedGrant +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_grant_repo.go b/tests/mock_grant_repo.go new file mode 100644 index 000000000..be9134ece --- /dev/null +++ b/tests/mock_grant_repo.go @@ -0,0 +1,8 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database. +type MockedGrantRepo struct { + model.GrantRepository +}