diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go new file mode 100644 index 00000000..b6e11d62 --- /dev/null +++ b/internal/auth/permissions.go @@ -0,0 +1,83 @@ +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type Permission string + +const PermissionMetadataCuration Permission = "metadata_curation" + +var assignablePermissions = map[Permission]struct{}{ + PermissionMetadataCuration: {}, +} + +var effectiveAdminPermissions = []string{ + string(PermissionMetadataCuration), +} + +func NormalizePermissions(values []string) ([]string, error) { + if len(values) == 0 { + return []string{}, nil + } + + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, raw := range values { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + permission := Permission(key) + if _, ok := assignablePermissions[permission]; !ok { + return nil, fmt.Errorf("unknown permission %q", key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + sort.Strings(out) + return out, nil +} + +func HasAssignedPermission(user *models.User, permission Permission) bool { + if user == nil { + return false + } + for _, value := range user.Permissions { + if value == string(permission) { + return true + } + } + return false +} + +func HasEffectivePermission(user *models.User, permission Permission) bool { + if user == nil || !user.Enabled { + return false + } + if user.Role == "admin" { + return true + } + return HasAssignedPermission(user, permission) +} + +func EffectivePermissions(user *models.User) []string { + if user == nil || !user.Enabled { + return []string{} + } + if user.Role == "admin" { + return append([]string(nil), effectiveAdminPermissions...) + } + permissions, err := NormalizePermissions(user.Permissions) + if err != nil { + return []string{} + } + return permissions +} diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go new file mode 100644 index 00000000..43e65b2d --- /dev/null +++ b/internal/auth/permissions_test.go @@ -0,0 +1,47 @@ +package auth + +import ( + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) { + got, err := NormalizePermissions([]string{ + " metadata_curation ", + "metadata_curation", + "", + }) + if err != nil { + t.Fatalf("NormalizePermissions returned error: %v", err) + } + want := []string{"metadata_curation"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("permissions = %#v, want %#v", got, want) + } +} + +func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) { + if _, err := NormalizePermissions([]string{"server_owner"}); err == nil { + t.Fatal("expected unknown permission error") + } +} + +func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) { + user := &models.User{Role: "admin", Enabled: true} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("admin should have metadata curation") + } +} + +func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) { + user := &models.User{Role: "user", Enabled: true} + if HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("plain user should not have metadata curation") + } + user.Permissions = []string{"metadata_curation"} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("assigned user should have metadata curation") + } +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 76df5933..f4a7f8e1 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -49,7 +49,7 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository { // allColumns is the list of columns returned by all SELECT queries. // Kept in one place so scanUser stays in sync. -const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, enabled, +const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled, library_ids, max_playback_quality, access_policy_revision, max_streams, max_transcodes, max_profiles, download_allowed, download_transcode_allowed, created_at, updated_at` @@ -64,6 +64,7 @@ func scanUser(row pgx.Row) (*models.User, error) { &u.PasswordHash, &u.LocalPasswordLoginEnabled, &u.Role, + &u.Permissions, &u.Enabled, &u.LibraryIDs, &u.MaxPlaybackQuality, @@ -97,6 +98,7 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) { &u.PasswordHash, &u.LocalPasswordLoginEnabled, &u.Role, + &u.Permissions, &u.Enabled, &u.LibraryIDs, &u.MaxPlaybackQuality, @@ -133,13 +135,19 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled } - cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "library_ids", "max_playback_quality"} + permissions, err := NormalizePermissions(input.Permissions) + if err != nil { + return nil, err + } + + cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"} args := []any{ input.Email, input.Username, string(hash), localPasswordLoginEnabled, input.Role, + permissions, input.LibraryIDs, input.MaxPlaybackQuality, } @@ -245,6 +253,15 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update args = append(args, *input.Role) argIndex++ } + if input.Permissions != nil { + permissions, err := NormalizePermissions(*input.Permissions) + if err != nil { + return err + } + setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex)) + args = append(args, permissions) + argIndex++ + } if input.Enabled != nil { setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex)) args = append(args, *input.Enabled) @@ -292,6 +309,14 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update return err } + if input.Role != nil || + input.Enabled != nil || + input.LibraryIDs != nil || + input.MaxPlaybackQuality != nil || + input.Permissions != nil { + setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1") + } + // Always bump updated_at. setClauses = append(setClauses, "updated_at = NOW()") diff --git a/internal/database/testdata/migrations/001_create_users.up.sql b/internal/database/testdata/migrations/001_create_users.up.sql index 193ad2b4..f02facd7 100644 --- a/internal/database/testdata/migrations/001_create_users.up.sql +++ b/internal/database/testdata/migrations/001_create_users.up.sql @@ -1,5 +1,7 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, + role TEXT, + permissions TEXT[] DEFAULT '{}'::TEXT[] NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); diff --git a/internal/models/user.go b/internal/models/user.go index f16b835f..9da9ab88 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -10,6 +10,7 @@ type User struct { PasswordHash string LocalPasswordLoginEnabled bool Role string + Permissions []string Enabled bool LibraryIDs []int // nullable in PG (nil = all libraries) MaxPlaybackQuality string @@ -30,6 +31,7 @@ type CreateUserInput struct { Password string // plaintext, will be bcrypt-hashed LocalPasswordLoginEnabled *bool Role string // e.g. "admin", "user" + Permissions []string LibraryIDs []int MaxPlaybackQuality string MaxStreams *int // nil = use DB default (6) @@ -47,6 +49,7 @@ type UpdateUserInput struct { Password *string // plaintext, will be bcrypt-hashed if provided LocalPasswordLoginEnabled *bool Role *string + Permissions *[]string Enabled *bool LibraryIDs *[]int MaxPlaybackQuality *string diff --git a/migrations/001_schema.up.sql b/migrations/001_schema.up.sql index f05d74cd..2bdf889e 100644 --- a/migrations/001_schema.up.sql +++ b/migrations/001_schema.up.sql @@ -1058,6 +1058,7 @@ CREATE TABLE public.users ( username text, password_hash text, role text, + permissions text[] DEFAULT '{}'::text[] NOT NULL, enabled boolean DEFAULT true, library_ids integer[], max_streams integer DEFAULT 6, diff --git a/migrations/140_user_permissions.down.sql b/migrations/140_user_permissions.down.sql new file mode 100644 index 00000000..12011ed4 --- /dev/null +++ b/migrations/140_user_permissions.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users + DROP COLUMN IF EXISTS permissions; diff --git a/migrations/140_user_permissions.up.sql b/migrations/140_user_permissions.up.sql new file mode 100644 index 00000000..0921ec97 --- /dev/null +++ b/migrations/140_user_permissions.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'::text[]; + +UPDATE public.users +SET permissions = '{}'::text[] +WHERE permissions IS NULL;