feat(auth): add assignable user permissions

This commit is contained in:
Quick
2026-05-26 18:12:20 -04:00
parent 1eeff57af1
commit f48fd41d85
8 changed files with 171 additions and 2 deletions
+83
View File
@@ -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
}
+47
View File
@@ -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")
}
}
+27 -2
View File
@@ -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()")
@@ -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()
);
+3
View File
@@ -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
+1
View File
@@ -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,
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE public.users
DROP COLUMN IF EXISTS permissions;
+6
View File
@@ -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;