feat(database): add user_library table and library access methods

Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	tests/mock_library_repo.go
This commit is contained in:
Deluan 2025-06-06 13:20:37 -04:00
commit 40bbae16c4
9 changed files with 533 additions and 6 deletions

View file

@ -145,3 +145,68 @@ func getEncKey() []byte {
sum := sha256.Sum256([]byte(key))
return sum[:]
}
// Library-based permission helpers
// HasLibraryAccess checks if a user has access to a specific library
func HasLibraryAccess(ctx context.Context, ds model.DataStore, userID string, libraryID int) bool {
user, ok := request.UserFrom(ctx)
if !ok {
return false
}
// Admin users have access to all libraries
if user.IsAdmin {
return true
}
// Check if user has explicit access to this library
libraries, err := ds.User(ctx).GetUserLibraries(userID)
if err != nil {
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
return false
}
for _, lib := range libraries {
if lib.ID == libraryID {
return true
}
}
return false
}
// GetUserLibraryIDs returns a list of library IDs the user has access to
func GetUserLibraryIDs(ctx context.Context, ds model.DataStore, userID string) []int {
user, ok := request.UserFrom(ctx)
if !ok {
return []int{}
}
// Admin users have access to all libraries
if user.IsAdmin {
libraries, err := ds.Library(ctx).GetAll()
if err != nil {
log.Error(ctx, "Error getting all libraries for admin", "userID", userID, err)
return []int{}
}
var ids []int
for _, lib := range libraries {
ids = append(ids, lib.ID)
}
return ids
}
// Get user's accessible libraries
libraries, err := ds.User(ctx).GetUserLibraries(userID)
if err != nil {
log.Error(ctx, "Error getting user libraries", "userID", userID, err)
return []int{}
}
var ids []int
for _, lib := range libraries {
ids = append(ids, lib.ID)
}
return ids
}

View file

@ -0,0 +1,56 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddUserLibraryTable, downAddUserLibraryTable)
}
func upAddUserLibraryTable(ctx context.Context, tx *sql.Tx) error {
// Create user_library association table
_, err := tx.ExecContext(ctx, `
CREATE TABLE user_library (
user_id VARCHAR(255) NOT NULL,
library_id INTEGER NOT NULL,
PRIMARY KEY (user_id, library_id),
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE
);
`)
if err != nil {
return err
}
// Create indexes for performance
_, err = tx.ExecContext(ctx, `
CREATE INDEX idx_user_library_user_id ON user_library(user_id);
CREATE INDEX idx_user_library_library_id ON user_library(library_id);
`)
if err != nil {
return err
}
// Populate with existing users having access to library ID 1 (existing setup)
// Admin users get access to all libraries, regular users get access to library 1
_, err = tx.ExecContext(ctx, `
INSERT INTO user_library (user_id, library_id)
SELECT u.id, 1
FROM user u;
`)
return err
}
func downAddUserLibraryTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
DROP INDEX IF EXISTS idx_user_library_library_id;
DROP INDEX IF EXISTS idx_user_library_user_id;
DROP TABLE IF EXISTS user_library;
`)
return err
}

View file

@ -36,6 +36,9 @@ type LibraryRepository interface {
StoreMusicFolder() error
AddArtist(id int, artistID string) error
// User-library association methods
GetUsersWithLibraryAccess(libraryID int) (Users, error)
// TODO These methods should be moved to a core service
ScanBegin(id int, fullScan bool) error
ScanEnd(id int) error

View file

@ -13,6 +13,9 @@ type User struct {
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Library associations (many-to-many relationship)
Libraries []Library `structs:"-" json:"libraries,omitempty"`
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
@ -35,4 +38,10 @@ type UserRepository interface {
FindByUsername(username string) (*User, error)
// FindByUsernameWithPassword is the same as above, but also returns the decrypted password
FindByUsernameWithPassword(username string) (*User, error)
// Library association methods
GetUserLibraries(userID string) (Libraries, error)
SetUserLibraries(userID string, libraryIDs []int) error
AddUserLibrary(userID string, libraryID int) error
RemoveUserLibrary(userID string, libraryID int) error
}

View file

@ -2,6 +2,7 @@ package persistence
import (
"context"
"fmt"
"sync"
"time"
@ -87,6 +88,17 @@ func (r *libraryRepository) Put(l *model.Library) error {
defer libLock.Unlock()
libCache[l.ID] = l.Path
}
// Auto-assign library to all admin users
if err == nil {
sql := Expr(
"INSERT INTO user_library (user_id, library_id) SELECT id, ? FROM user WHERE is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING",
l.ID,
)
if _, addErr := r.executeSQL(sql); addErr != nil {
return fmt.Errorf("failed to assign library to admin users: %w", addErr)
}
}
return err
}
@ -206,4 +218,18 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
return res, err
}
// User-library association methods
func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
sel := Select("u.*").
From("user u").
Join("user_library ul ON u.id = ul.user_id").
Where(Eq{"ul.library_id": libraryID}).
OrderBy("u.name")
var res model.Users
err := r.queryAll(sel, &res)
return res, err
}
var _ model.LibraryRepository = (*libraryRepository)(nil)

View file

@ -79,18 +79,34 @@ func (r *userRepository) Put(u *model.User) error {
return fmt.Errorf("error converting user to SQL args: %w", err)
}
delete(values, "current_password")
// Save/update the user
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
if count > 0 {
return nil
if count == 0 {
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
if err != nil {
return err
}
}
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return err
// Auto-assign all libraries to admin users in a single SQL operation
if u.IsAdmin {
sql := Expr(
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
u.ID,
)
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
}
}
return nil
}
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
@ -365,6 +381,57 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error {
return nil
}
// Library association methods
func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) {
sel := Select("l.*").
From("library l").
Join("user_library ul ON l.id = ul.library_id").
Where(Eq{"ul.user_id": userID}).
OrderBy("l.name")
var res model.Libraries
err := r.queryAll(sel, &res)
return res, err
}
func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error {
// Remove existing associations
delSql := Delete("user_library").Where(Eq{"user_id": userID})
if _, err := r.executeSQL(delSql); err != nil {
return err
}
// Add new associations
if len(libraryIDs) > 0 {
insert := Insert("user_library").Columns("user_id", "library_id")
for _, libID := range libraryIDs {
insert = insert.Values(userID, libID)
}
_, err := r.executeSQL(insert)
return err
}
return nil
}
func (r *userRepository) AddUserLibrary(userID string, libraryID int) error {
insert := Insert("user_library").
Columns("user_id", "library_id").
Values(userID, libraryID).
Suffix("ON CONFLICT (user_id, library_id) DO NOTHING")
_, err := r.executeSQL(insert)
return err
}
func (r *userRepository) RemoveUserLibrary(userID string, libraryID int) error {
del := Delete("user_library").Where(And{
Eq{"user_id": userID},
Eq{"library_id": libraryID},
})
_, err := r.executeSQL(del)
return err
}
var _ model.UserRepository = (*userRepository)(nil)
var _ rest.Repository = (*userRepository)(nil)
var _ rest.Persistable = (*userRepository)(nil)

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@ -235,4 +236,260 @@ var _ = Describe("UserRepository", func() {
Expect(err).To(MatchError("fake error"))
})
})
Describe("Library Association Methods", func() {
var userID string
var library1, library2 model.Library
BeforeEach(func() {
// Create a test user first to satisfy foreign key constraints
testUser := model.User{
ID: "test-user-id",
UserName: "testuser",
Name: "Test User",
Email: "test@example.com",
NewPassword: "password",
IsAdmin: false,
}
Expect(repo.Put(&testUser)).To(BeNil())
userID = testUser.ID
library1 = model.Library{ID: 500, Name: "Library 500", Path: "/path/500"}
library2 = model.Library{ID: 501, Name: "Library 501", Path: "/path/501"}
// Create test libraries
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
Expect(libRepo.Put(&library1)).To(BeNil())
Expect(libRepo.Put(&library2)).To(BeNil())
})
AfterEach(func() {
// Clean up user-library associations to ensure test isolation
_ = repo.SetUserLibraries(userID, []int{})
// Clean up test libraries to ensure isolation between test groups
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{500, 501}})
})
Describe("AddUserLibrary", func() {
It("adds a library association for a user", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library1.ID))
})
It("does not create duplicate associations", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
// Add the same association again
err = repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
})
})
Describe("GetUserLibraries", func() {
It("returns empty list when user has no library associations", func() {
libraries, err := repo.GetUserLibraries("non-existent-user")
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
It("returns user's associated libraries", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
err = repo.AddUserLibrary(userID, library2.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(2))
libIDs := []int{libraries[0].ID, libraries[1].ID}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
})
Describe("SetUserLibraries", func() {
It("sets user's library associations", func() {
libraryIDs := []int{library1.ID, library2.ID}
err := repo.SetUserLibraries(userID, libraryIDs)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(2))
})
It("replaces existing associations", func() {
// Set initial associations
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
// Replace with just one library
err = repo.SetUserLibraries(userID, []int{library1.ID})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library1.ID))
})
It("removes all associations when passed empty slice", func() {
// Set initial associations
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
// Remove all
err = repo.SetUserLibraries(userID, []int{})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
Describe("RemoveUserLibrary", func() {
It("removes a library association", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
err = repo.AddUserLibrary(userID, library2.ID)
Expect(err).To(BeNil())
err = repo.RemoveUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library2.ID))
})
It("does nothing when association doesn't exist", func() {
err := repo.RemoveUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
})
Describe("Admin User Auto-Assignment", func() {
var (
libRepo model.LibraryRepository
library1 model.Library
library2 model.Library
)
BeforeEach(func() {
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
library1 = model.Library{ID: 201, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
library2 = model.Library{ID: 202, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
// Create test libraries
Expect(libRepo.Put(&library1)).To(BeNil())
Expect(libRepo.Put(&library2)).To(BeNil())
})
AfterEach(func() {
// Clean up test libraries and their associations
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{201, 202}})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{201, 202}}))
})
It("automatically assigns all libraries to admin users when created", func() {
adminUser := model.User{
ID: "admin-user-id-1",
UserName: "adminuser1",
Name: "Admin User",
Email: "admin1@example.com",
NewPassword: "password",
IsAdmin: true,
}
err := repo.Put(&adminUser)
Expect(err).To(BeNil())
// Admin should automatically have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(adminUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
libIDs := make([]int, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
})
It("automatically assigns all libraries to admin users when updated", func() {
// Create regular user first
regularUser := model.User{
ID: "regular-user-id-1",
UserName: "regularuser1",
Name: "Regular User",
Email: "regular1@example.com",
NewPassword: "password",
IsAdmin: false,
}
err := repo.Put(&regularUser)
Expect(err).To(BeNil())
// Give them access to just one library
err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
Expect(err).To(BeNil())
// Promote to admin
regularUser.IsAdmin = true
err = repo.Put(&regularUser)
Expect(err).To(BeNil())
// Should now have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
libIDs := make([]int, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
// Should include our test libraries plus all existing ones
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
})
It("does not affect regular users", func() {
regularUser := model.User{
ID: "regular-user-id-2",
UserName: "regularuser2",
Name: "Regular User",
Email: "regular2@example.com",
NewPassword: "password",
IsAdmin: false,
}
err := repo.Put(&regularUser)
Expect(err).To(BeNil())
// Regular user should have no library associations
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
})

View file

@ -39,4 +39,14 @@ func (m *MockLibraryRepo) RefreshStats(id int) error {
return nil
}
// User-library association methods - mock implementations
func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
if m.Err != nil {
return nil, m.Err
}
// Mock: return empty users for now
return model.Users{}, nil
}
var _ model.LibraryRepository = &MockLibraryRepo{}

View file

@ -74,3 +74,37 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
}
return u.Error
}
// Library association methods - mock implementations
func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) {
if u.Error != nil {
return nil, u.Error
}
// Mock: return empty libraries for now
return model.Libraries{}, nil
}
func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error {
if u.Error != nil {
return u.Error
}
// Mock: do nothing for now
return nil
}
func (u *MockedUserRepo) AddUserLibrary(userID string, libraryID int) error {
if u.Error != nil {
return u.Error
}
// Mock: do nothing for now
return nil
}
func (u *MockedUserRepo) RemoveUserLibrary(userID string, libraryID int) error {
if u.Error != nil {
return u.Error
}
// Mock: do nothing for now
return nil
}