mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
* feat(persistence): store hashed API keys on players * feat(core): refresh key-bound players without renaming them Add Players.Touch, which records usage for a player already identified by an API key without guessing its identity or overwriting its name. Register also stops renaming players that have an API key. Register no longer returns player save errors (or a stale FindMatch ErrNotFound when the save is rate-limited); save failures are only logged, and only the transcoding lookup error is returned, same as Touch. * feat(subsonic): authenticate with OpenSubsonic API keys Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com> * feat(subsonic): add tokenInfo and advertise apiKeyAuthentication * feat(server): add endpoints to generate and revoke player API keys * feat(ui): manage player API keys Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com> * fix(subsonic): throttle API keys per key and IP A stale key on one device exhausted the shared per-IP bucket and locked out every valid key from the same IP. The limiter only stores a hash of the bucket string, so the key is not retained. Also adds e2e coverage of API key auth through the real repository, and clarifies the player resolution log message. * fix(ui): keep the new API key dialog open until closed The key is shown only once, so Escape and backdrop clicks no longer dismiss it. Also clarifies when the key can be used as a password. * refactor: simplify API key code paths Share the player refresh tail between Register and Touch, fold the ownership-filtered write tail into execOwned, parse the query once for apiKey conflicts, derive HasAPIKey in the player mock, share the player form inputs between create and edit, and pick the delete button by key state instead of spreading conditional props. * feat(players): set API keys through the player record The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints. * fix(players): reject API keys already in use Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create. * feat(ui): edit player API keys as a form field Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create. * fix(ui): keep new player API keys out of the record cache The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails. * fix(ui): polish player API key field Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners. * refactor: simplify player API key create and field Write the key hash in the create INSERT so the unique index settles races, re-read the created player instead of hand-building the cached record, reuse isWritable for the revoke check, and collapse the key field's derived state and generate/regenerate buttons. * fix(ui): let the API key field size like other inputs fullWidth is now opt-in instead of forced. * fix(ui): align the API key field with other player inputs Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits. * fix(ui): redirect to the player list after create Matches the other create pages. * refactor(persistence): name the write-access rule for owned rows Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures. * fix(players): apply an edit's key change and fields atomically Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path. * fix(subsonic): treat any credential param sent with apiKey as a conflict The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value. * refactor(subsonic): leave the player cookie code unchanged for key-bound requests Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master. * fix(subsonic): don't count key lookup errors as failed logins A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter. * feat(players): use nds_ as the API key prefix Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants. * feat(ui): make player API keys easier to find Label the Settings menu entry "Players & API keys", add an API key filter to the player list, show the key icon in the mobile list, and add Brazilian Portuguese translations for the new player strings. Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): always show the player API key filter Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): hide the unset Last Seen date in the player list Players created by hand have no last_seen yet, which showed as 12/31/1. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
621 lines
22 KiB
Go
621 lines
22 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/deluan/rest"
|
|
"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"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
const testAPIKey = "nds_0123456789abcdefghijkl"
|
|
|
|
func expectAPIKeyError(err error, msg string) {
|
|
var verr *rest.ValidationError
|
|
ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue())
|
|
ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg))
|
|
}
|
|
|
|
var _ = Describe("PlayerRepository", func() {
|
|
var adminRepo *playerRepository
|
|
var database *dbx.DB
|
|
var ctx context.Context
|
|
|
|
var (
|
|
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
|
|
adminPlayer2 = model.Player{ID: "2", Name: "GenericClient [Chrome/Windows]", IP: "192.168.0.5", UserAgent: "Chrome/Windows", UserId: adminUser.ID, Username: adminUser.UserName, Client: "GenericClient", MaxBitRate: 128}
|
|
regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false}
|
|
|
|
players = model.Players{adminPlayer1, adminPlayer2, regularPlayer}
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
|
|
|
database = GetDBXBuilder()
|
|
adminRepo = NewPlayerRepository(database).(*playerRepository)
|
|
|
|
for idx := range players {
|
|
err := adminRepo.Put(ctx, &players[idx])
|
|
Expect(err).To(BeNil())
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
players, err := adminRepo.ReadAll(ctx)
|
|
Expect(err).To(BeNil())
|
|
for i := range players {
|
|
err = adminRepo.Delete(ctx, players[i].ID)
|
|
Expect(err).To(BeNil())
|
|
}
|
|
})
|
|
|
|
Describe("FindMatch", func() {
|
|
It("finds existing match", func() {
|
|
player, err := adminRepo.FindMatch(ctx, adminUser.ID, "NavidromeUI", "Firefox/Linux")
|
|
Expect(err).To(BeNil())
|
|
Expect(*player).To(Equal(adminPlayer1))
|
|
})
|
|
|
|
It("doesn't find bad match", func() {
|
|
_, err := adminRepo.FindMatch(ctx, regularUser.ID, "NavidromeUI", "Firefox/Linux")
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("Get", func() {
|
|
It("Gets an existing item from user", func() {
|
|
player, err := adminRepo.Get(ctx, adminPlayer1.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(*player).To(Equal(adminPlayer1))
|
|
})
|
|
|
|
It("Gets an existing item from another user", func() {
|
|
player, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(*player).To(Equal(regularPlayer))
|
|
})
|
|
|
|
It("does not get nonexistent item", func() {
|
|
_, err := adminRepo.Get(ctx, "i don't exist")
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
|
|
var repo *playerRepository
|
|
var repoCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
repoCtx = ctx
|
|
if admin {
|
|
repo = adminRepo
|
|
} else {
|
|
repoCtx = request.WithUser(ctx, regularUser)
|
|
repo = NewPlayerRepository(database).(*playerRepository)
|
|
}
|
|
})
|
|
|
|
baseCount := int64(len(players))
|
|
|
|
Describe("Count", func() {
|
|
It("should return all", func() {
|
|
count, err := repo.Count(repoCtx)
|
|
Expect(err).To(BeNil())
|
|
Expect(count).To(Equal(baseCount))
|
|
})
|
|
})
|
|
|
|
Describe("Delete", func() {
|
|
It("deletes a player owned by the current user", func() {
|
|
err := repo.Delete(repoCtx, userPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
|
|
count, err := repo.Count(repoCtx)
|
|
Expect(err).To(BeNil())
|
|
Expect(count).To(Equal(baseCount - 1))
|
|
|
|
_, err = repo.Get(repoCtx, userPlayer.ID)
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
})
|
|
|
|
It("does not delete another user's player when not admin", func() {
|
|
err := repo.Delete(repoCtx, otherPlayer.ID)
|
|
|
|
if admin {
|
|
// Admins may delete any player.
|
|
Expect(err).To(BeNil())
|
|
Expect(repo.Count(repoCtx)).To(Equal(baseCount - 1))
|
|
_, err = repo.Get(repoCtx, otherPlayer.ID)
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
} else {
|
|
// The ownership-restricted delete matches no owned row, so it reports
|
|
// permission-denied and leaves the other user's player untouched.
|
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
Expect(repo.Count(repoCtx)).To(Equal(baseCount))
|
|
item, err := repo.Get(repoCtx, otherPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(*item).To(Equal(otherPlayer))
|
|
}
|
|
})
|
|
|
|
It("returns not-found for a nonexistent player", func() {
|
|
err := repo.Delete(repoCtx, "i don't exist")
|
|
Expect(err).To(Equal(rest.ErrNotFound))
|
|
Expect(repo.Count(repoCtx)).To(Equal(baseCount))
|
|
})
|
|
})
|
|
|
|
Describe("Read", func() {
|
|
It("can read from current user", func() {
|
|
player, err := repo.Read(repoCtx, userPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(player).To(Equal(&userPlayer))
|
|
})
|
|
|
|
It("can read from other user or fail if not admin", func() {
|
|
player, err := repo.Read(repoCtx, otherPlayer.ID)
|
|
if admin {
|
|
Expect(err).To(BeNil())
|
|
Expect(player).To(Equal(&otherPlayer))
|
|
} else {
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
}
|
|
})
|
|
|
|
It("does not get nonexistent item", func() {
|
|
_, err := repo.Read(repoCtx, "i don't exist")
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("ReadAll", func() {
|
|
It("should get all items", func() {
|
|
data, err := repo.ReadAll(repoCtx)
|
|
Expect(err).To(BeNil())
|
|
Expect(model.Players(data)).To(Equal(players))
|
|
})
|
|
})
|
|
|
|
Describe("Save", func() {
|
|
DescribeTable("item type", func(player model.Player) {
|
|
clone := player
|
|
clone.ID = ""
|
|
clone.IP = "192.168.1.1"
|
|
clone.APIKey = new(testAPIKey)
|
|
id, err := repo.Save(repoCtx, &clone)
|
|
|
|
if clone.UserId == "" {
|
|
Expect(err).To(HaveOccurred())
|
|
} else if player.UserId != userPlayer.UserId {
|
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
clone.UserId = ""
|
|
} else {
|
|
Expect(err).To(BeNil())
|
|
Expect(id).ToNot(BeEmpty())
|
|
}
|
|
|
|
count, err := repo.Count(repoCtx)
|
|
Expect(err).To(BeNil())
|
|
|
|
clone.ID = id
|
|
newItem, err := repo.Get(repoCtx, id)
|
|
|
|
if clone.UserId == "" {
|
|
Expect(count).To(Equal(baseCount))
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
} else {
|
|
Expect(count).To(Equal(baseCount + 1))
|
|
Expect(err).To(BeNil())
|
|
clone.APIKey = nil
|
|
clone.HasAPIKey = true
|
|
Expect(*newItem).To(Equal(clone))
|
|
}
|
|
},
|
|
Entry("same user", userPlayer),
|
|
Entry("other item", otherPlayer),
|
|
)
|
|
})
|
|
|
|
Describe("Update", func() {
|
|
DescribeTable("item type", func(player model.Player) {
|
|
clone := player
|
|
clone.IP = "192.168.1.1"
|
|
clone.MaxBitRate = 10000
|
|
err := repo.Update(repoCtx, clone.ID, clone, "ip")
|
|
|
|
if player.UserId == "" {
|
|
Expect(err).To(HaveOccurred())
|
|
} else if !admin && player.Username == adminPlayer1.Username {
|
|
// A non-admin cannot target another user's player: the ownership-restricted
|
|
// update matches no owned row, so it reports permission-denied rather than
|
|
// touching it.
|
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
clone.IP = player.IP
|
|
} else {
|
|
Expect(err).To(BeNil())
|
|
}
|
|
|
|
clone.MaxBitRate = player.MaxBitRate
|
|
newItem, err := repo.Get(repoCtx, clone.ID)
|
|
|
|
if player.UserId == "" {
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
} else if !admin && player.UserId == adminUser.ID {
|
|
Expect(*newItem).To(Equal(player))
|
|
} else {
|
|
Expect(*newItem).To(Equal(clone))
|
|
}
|
|
},
|
|
Entry("same user", userPlayer),
|
|
Entry("other item", otherPlayer),
|
|
Entry("fake item", model.Player{}),
|
|
)
|
|
})
|
|
},
|
|
Entry("admin context", true, players, adminPlayer1, regularPlayer),
|
|
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
|
|
)
|
|
|
|
Describe("API keys", func() {
|
|
const key = testAPIKey
|
|
const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV"
|
|
var ownerCtx, otherCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser)
|
|
otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser)
|
|
})
|
|
|
|
storedHash := func(id string) string {
|
|
var row struct {
|
|
Hash string `db:"api_key_hash"`
|
|
}
|
|
Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}").
|
|
Bind(dbx.Params{"id": id}).One(&row)).To(Succeed())
|
|
return row.Hash
|
|
}
|
|
|
|
Describe("SetAPIKey", func() {
|
|
It("stores only the hash and finds the player by the key", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
|
|
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
|
plr, err := adminRepo.FindByAPIKey(ctx, key)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(plr.ID).To(Equal(regularPlayer.ID))
|
|
Expect(plr.HasAPIKey).To(BeTrue())
|
|
Expect(plr.APIKey).To(BeNil())
|
|
})
|
|
|
|
It("replaces the previous key", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed())
|
|
|
|
_, err := adminRepo.FindByAPIKey(ctx, key)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
_, err = adminRepo.FindByAPIKey(ctx, otherKey)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
DescribeTable("rejects malformed keys",
|
|
func(bad string) {
|
|
err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad)
|
|
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
},
|
|
Entry("no prefix", "0123456789abcdefghijklmn"),
|
|
Entry("too short", "nds_short"),
|
|
Entry("too long", key+"x"),
|
|
Entry("bad chars", "nds_0123456789abcdefghij-!"),
|
|
)
|
|
|
|
It("revokes with an empty key, by the owner or an admin", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
})
|
|
|
|
It("accepts revoking a player that has no key", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
|
|
})
|
|
|
|
It("does not let an admin set a key on another user's player", func() {
|
|
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied))
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
})
|
|
|
|
It("does not let another user set or revoke", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied))
|
|
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied))
|
|
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
|
})
|
|
|
|
It("returns not found for a missing player", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound))
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound))
|
|
})
|
|
|
|
It("does not find unknown or empty keys", func() {
|
|
_, err := adminRepo.FindByAPIKey(ctx, otherKey)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
_, err = adminRepo.FindByAPIKey(ctx, "")
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("drops the key with the player", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed())
|
|
_, err := adminRepo.FindByAPIKey(ctx, key)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("hasApiKey filter", func() {
|
|
BeforeEach(func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
})
|
|
|
|
filtered := func(value string) []string {
|
|
res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var ids []string
|
|
for _, p := range res {
|
|
ids = append(ids, p.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
It("lists only players with a key", func() {
|
|
Expect(filtered("true")).To(ConsistOf(regularPlayer.ID))
|
|
count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(Equal(int64(1)))
|
|
})
|
|
|
|
It("lists only players without a key", func() {
|
|
Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID))
|
|
})
|
|
})
|
|
|
|
Describe("Save (create)", func() {
|
|
It("creates the player with the key, owned by the logged-in user", func() {
|
|
id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
plr, err := adminRepo.FindByAPIKey(ctx, key)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(plr.ID).To(Equal(id))
|
|
Expect(plr.UserId).To(Equal(regularUser.ID))
|
|
})
|
|
|
|
It("requires a key", func() {
|
|
count, _ := adminRepo.CountAll(ctx)
|
|
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"})
|
|
expectAPIKeyError(err, "ra.validation.required")
|
|
|
|
_, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")})
|
|
expectAPIKeyError(err, "ra.validation.required")
|
|
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
|
})
|
|
|
|
It("rejects a malformed key without creating the player", func() {
|
|
count, _ := adminRepo.CountAll(ctx)
|
|
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")})
|
|
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
|
|
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
|
})
|
|
|
|
It("does not let an admin create a keyed player for another user", func() {
|
|
count, _ := adminRepo.CountAll(ctx)
|
|
_, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)})
|
|
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
|
_, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID})
|
|
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
|
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
|
})
|
|
|
|
It("rejects a key already used by another player without creating the player", func() {
|
|
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
|
|
count, _ := adminRepo.CountAll(ctx)
|
|
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)})
|
|
expectAPIKeyError(err, "ra.validation.unique")
|
|
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
|
})
|
|
})
|
|
|
|
Describe("Update (edit)", func() {
|
|
It("rolls back the key change when the rest of the edit fails", func() {
|
|
_, err := database.NewQuery(`create trigger fail_player_rename before update of name on player
|
|
when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
DeferCleanup(func() {
|
|
_, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute()
|
|
})
|
|
|
|
plr := regularPlayer
|
|
plr.Name = "boom"
|
|
plr.APIKey = new(key)
|
|
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
})
|
|
|
|
It("keeps the key when apiKey is absent (a normal edit)", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
|
|
plr := regularPlayer
|
|
plr.Name = "Renamed"
|
|
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed())
|
|
Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed())
|
|
|
|
found, err := adminRepo.FindByAPIKey(ctx, key)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(found.Name).To(Equal("Renamed"))
|
|
})
|
|
|
|
It("sets a new key when apiKey has a value", func() {
|
|
plr := regularPlayer
|
|
plr.APIKey = new(key)
|
|
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
|
})
|
|
|
|
It("revokes the key when apiKey is empty", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
plr := regularPlayer
|
|
plr.APIKey = new("")
|
|
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
})
|
|
|
|
It("lets an admin edit another user's keyed player without touching the key", func() {
|
|
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
|
plr := regularPlayer
|
|
plr.MaxBitRate = 192
|
|
Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed())
|
|
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
|
})
|
|
|
|
It("refuses an admin setting a key on another user's player and leaves other columns alone", func() {
|
|
plr := regularPlayer
|
|
plr.Name = "Hijacked"
|
|
plr.APIKey = new(key)
|
|
Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied))
|
|
|
|
got, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.Name).To(Equal(regularPlayer.Name))
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
})
|
|
|
|
It("refuses a key already used by another player and leaves other columns alone", func() {
|
|
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
|
|
plr := regularPlayer
|
|
plr.Name = "Renamed"
|
|
plr.APIKey = new(key)
|
|
err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")
|
|
expectAPIKeyError(err, "ra.validation.unique")
|
|
|
|
got, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.Name).To(Equal(regularPlayer.Name))
|
|
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
|
Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key)))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Ownership enforcement (cross-tenant write protection)", func() {
|
|
var regularRepo *playerRepository
|
|
var regularCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
regularCtx = request.WithUser(ctx, regularUser)
|
|
regularRepo = NewPlayerRepository(database).(*playerRepository)
|
|
})
|
|
|
|
It("does not let a regular user hijack another user's player by spoofing userId in the body", func() {
|
|
// Attacker (regularUser) targets the victim's (adminUser) player by URL id,
|
|
// but sets userId in the body to their own id to try to pass the permission check.
|
|
spoofed := model.Player{
|
|
ID: adminPlayer1.ID,
|
|
Name: "HIJACKED",
|
|
UserId: regularUser.ID, // attacker's own id, spoofed in the body
|
|
MaxBitRate: 1,
|
|
}
|
|
|
|
// The ownership-restricted update matches no row owned by the attacker, so the write
|
|
// targets nothing and reports permission-denied rather than overwriting the victim's row.
|
|
err := regularRepo.Update(regularCtx, adminPlayer1.ID, spoofed, "name", "user_id", "max_bit_rate")
|
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
|
|
// The victim's player must remain untouched.
|
|
stored, err := adminRepo.Get(ctx, adminPlayer1.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(*stored).To(Equal(adminPlayer1))
|
|
})
|
|
|
|
It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() {
|
|
spoofed := model.Player{
|
|
ID: adminPlayer1.ID,
|
|
Name: "HIJACKED",
|
|
UserId: regularUser.ID,
|
|
ReportRealPath: true,
|
|
APIKey: new(testAPIKey),
|
|
}
|
|
|
|
id, err := regularRepo.Save(regularCtx, &spoofed)
|
|
Expect(err).To(BeNil())
|
|
Expect(id).ToNot(Equal(adminPlayer1.ID))
|
|
|
|
stored, err := adminRepo.Get(ctx, adminPlayer1.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(*stored).To(Equal(adminPlayer1))
|
|
|
|
created, err := adminRepo.Get(ctx, id)
|
|
Expect(err).To(BeNil())
|
|
Expect(created.UserId).To(Equal(regularUser.ID))
|
|
})
|
|
|
|
It("does not let a regular user reassign their own player to another user", func() {
|
|
// Owner updates their own player but tries to give it away to the admin. The update
|
|
// succeeds for the other fields, but user_id is never written, so ownership stays put.
|
|
reassign := regularPlayer
|
|
reassign.UserId = adminUser.ID
|
|
reassign.Name = "given-away"
|
|
|
|
err := regularRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id")
|
|
Expect(err).To(BeNil())
|
|
|
|
// Ownership must not have changed.
|
|
stored, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(stored.UserId).To(Equal(regularUser.ID))
|
|
})
|
|
|
|
It("does not let an admin reassign a player to another user", func() {
|
|
// Even an admin cannot change a player's owner via update.
|
|
reassign := regularPlayer
|
|
reassign.UserId = adminUser.ID
|
|
reassign.Name = "admin-renamed"
|
|
|
|
err := adminRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id")
|
|
Expect(err).To(BeNil())
|
|
|
|
// The name change applies, but ownership must not have moved.
|
|
stored, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(stored.Name).To(Equal("admin-renamed"))
|
|
Expect(stored.UserId).To(Equal(regularUser.ID))
|
|
})
|
|
|
|
It("lets the owner update their own player", func() {
|
|
update := regularPlayer
|
|
update.Name = "renamed-by-owner"
|
|
|
|
err := regularRepo.Update(regularCtx, regularPlayer.ID, update, "name")
|
|
Expect(err).To(BeNil())
|
|
|
|
stored, err := adminRepo.Get(ctx, regularPlayer.ID)
|
|
Expect(err).To(BeNil())
|
|
Expect(stored.Name).To(Equal("renamed-by-owner"))
|
|
Expect(stored.UserId).To(Equal(regularUser.ID))
|
|
})
|
|
|
|
It("returns not found when updating a nonexistent player", func() {
|
|
ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID}
|
|
err := regularRepo.Update(regularCtx, "does-not-exist", ghost, "name")
|
|
Expect(err).To(Equal(rest.ErrNotFound))
|
|
})
|
|
})
|
|
})
|