From 40bbae16c4750ee80c02a9f4c679d730a51cfc23 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 6 Jun 2025 13:20:37 -0400 Subject: [PATCH] feat(database): add user_library table and library access methods Signed-off-by: Deluan # Conflicts: # tests/mock_library_repo.go --- core/auth/auth.go | 65 +++++ .../20250106113337_add_user_library_table.go | 56 ++++ model/library.go | 3 + model/user.go | 9 + persistence/library_repository.go | 26 ++ persistence/user_repository.go | 79 +++++- persistence/user_repository_test.go | 257 ++++++++++++++++++ tests/mock_library_repo.go | 10 + tests/mock_user_repo.go | 34 +++ 9 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20250106113337_add_user_library_table.go diff --git a/core/auth/auth.go b/core/auth/auth.go index fd2b670a4..3a8e5de52 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -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 +} diff --git a/db/migrations/20250106113337_add_user_library_table.go b/db/migrations/20250106113337_add_user_library_table.go new file mode 100644 index 000000000..d190f4d0e --- /dev/null +++ b/db/migrations/20250106113337_add_user_library_table.go @@ -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 +} diff --git a/model/library.go b/model/library.go index fda22f19f..151336d7b 100644 --- a/model/library.go +++ b/model/library.go @@ -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 diff --git a/model/user.go b/model/user.go index 7c41ac041..e4747b3fd 100644 --- a/model/user.go +++ b/model/user.go @@ -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 } diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 9c305e52b..952300eea 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -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) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 073e32963..dcf3f08b7 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -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) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 7b1ad79d7..2826084c4 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -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(®ularUser) + 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(®ularUser) + 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(®ularUser) + 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)) + }) + }) }) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 7cc8b02f7..8ed764ddb 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -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{} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 09d804ccd..a2cf8dce7 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -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 +}