feat(api): add api_grant storage for API v1 grants

This commit is contained in:
Deluan 2026-09-26 00:54:10 -04:00
commit 2afe2ffe0a
8 changed files with 433 additions and 0 deletions

View file

@ -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

View file

@ -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

62
model/grant.go Normal file
View file

@ -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)
}

View file

@ -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())))
}

View file

@ -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))
})
})

View file

@ -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)

View file

@ -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)
}

8
tests/mock_grant_repo.go Normal file
View file

@ -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
}