feat(auth): add assignable user permissions
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN IF EXISTS permissions;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user