Compare commits

...
Author SHA1 Message Date
Claude Code 6a50097f27 fix(api): use shared UserLoader for transcription quota exemption
Adapts the rebased subtitle ASR quota feature to the group-based admin
model: quotaExempt checks admin status server-side via isAdminRequest
instead of the removed claims role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:25:44 +02:00
Claude Code 5c950beefe fix(auth): revalidate websocket viewers and enforce enabled on compat sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 767a7ccf24 refactor: consolidate user loaders, group scans, and policy formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 20ed9ecd05 fix(auth): prevent disabling or deleting the last enabled administrator
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 43beb4693a docs: align spec with implemented hydration and response shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5f9cfd213b docs(jellycompat): update stale users.library_ids comment to group-derived access
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 40c4d6bcc4 chore(api): drop validation for orphaned defaults.* settings keys
Nothing reads defaults.max_playback_quality or defaults.max_profiles
anymore; new-user policy comes from groups and the only remaining
defaults.* consumer is defaults.card_overlays. Also extract role and
issuer-kind string constants flagged by goconst (presentation role
strings stay distinct from auth Permission values on purpose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 076351f3d5 fix(web): omit group_ids until groups load so backend defaults apply
When creating a new user, omit group_ids from the request body if the
admin never touched the selector AND the groups query hasn't successfully
resolved (still loading or errored). This lets the backend's
default-group provisioning apply rather than receiving an empty array
and producing a locked-out zero-group user. When groups have loaded and
the preselection is untouched, the explicit selection is still sent.
The Groups tab now shows skeleton rows while loading and a muted notice
on error instead of a silent empty selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 3034db6dc8 feat(web): group membership controls on user admin pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 4430eda7a2 test(auth): make tampered-token test deterministic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 373939126e fix(auth): unlimited (0) group limits win the policy union
Enforcement treats 0 as unlimited for max_streams, max_transcodes
(playback session manager), and max_profiles (profile creation), so a
plain max() across groups inverted the most-permissive union: a user in
{max_streams: 5} and {max_streams: 0} resolved to 5. Fold limits with
mergeLimit instead, seeding from the first group so an explicit 0 sticks.

Zero-group lockout stays enforced via the empty library list; MaxProfiles
is floored at 1 there because 0 would grant unlimited profile creation.
Admins keep merged group limits, matching the pre-group behavior where
admins had no stream-limit bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 49e14d3459 feat(web): admin groups management page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code c26835cd6a feat(web): group types and admin query hooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 83a9a1c102 fix(auth): qualify group columns to fix ambiguous created_at in membership joins
GroupsForUser/GroupsForUsers join user_groups, which also has a created_at
column; the unqualified column list made every group hydration (and thus every
login) fail against a real Postgres with SQLSTATE 42702. Caught by live E2E
verification; unit tests use fakes and missed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code e40e2437ef fix(api): group member endpoints return correct errors for unknown user and group
AddMember now detects FK violations on user_id and returns ErrUnknownUser,
mapped to 404 unknown_user in writeGroupError. HandleListMembers calls
GetByID first so a nonexistent group yields 404 instead of 200 with empty
members. Adds handler tests for both cases plus a PATCH permissions
semantics table test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5d513c2737 feat(api): admin group management endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 6701da1829 fix(jellycompat): load fresh user for policy; enforce download permission
Remove the session policy bridge (IsAdmin/DownloadAllowed/LibraryIDs cached
on Session): the fields were never persisted to jellycompat_sessions, so any
restart zeroed them (admins rendered as non-admin, restricted users as
unrestricted), and group edits never refreshed them. Policy is now always
read from a freshly loaded user.

- userDTO loads the user via a userLoader (hydrated effective policy) and
  fails the request on load error instead of falling back to permissive
  defaults; login, /Users/Me, /Users, /Users/{id} are cold paths.
- Rename the apiKeyUserLoader interface/Dependencies field to userLoader/
  UserLoader since it now also backs user DTOs and download enforcement.
- HandleDownload enforces the group-derived DownloadAllowed policy with a
  fresh user load, returning 403 for restricted users and failing closed on
  load errors; allowed-user behavior is unchanged (Infuse depends on it).
- Pin the EnabledFolders GUID encoding in TestUserPolicyFromEffectivePolicy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
rxwatcherandClaude Code fcc782fe19 feat(jellycompat): derive UserPolicy from group-based effective policy
Replace hardcoded IsAdministrator/EnableContentDownloading/EnableAllFolders
literals in userDTO with a buildUserPolicy helper that maps models.User's
group-derived effective policy (IsAdmin, DownloadAllowed, LibraryIDs) onto
the Jellyfin UserPolicy shape. Library IDs are encoded as Jellyfin folder
GUIDs via the existing EncodeNumericID(EncodedIDLibrary, ...) helper. Session
policy fields are populated at login and API-key session synthesis so no
per-request DB round-trip is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code 18bc4b3f16 test(api): cover RequireAdmin middleware denial and stash paths
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code ac280ab7f3 feat(auth): server-side admin checks; remove role from JWT claims
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code f499d0901b fix(auth): atomic user update and setup, default-group fallback, unknown-group mapping
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 2bf4bd127e feat(auth): users derive effective policy from groups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code f2c9468122 refactor(auth): normalize before admin guard, dedupe group scan
In Update, call NormalizePermissions once up-front and run the
administrators admin-permission guard against the normalized slice so
whitespace-padded inputs like [" admin "] are no longer wrongly
rejected with ErrAdminPermRequired.

In GroupsForUsers, replace the hand-rolled 15-field Scan with the new
scanGroupFieldsPre helper (mirrors scanGroupFields but prepends extra
dest args before the group fields) so the scan list is no longer
duplicated in the codebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4b70679815 feat(auth): group repository with guards and revision bumps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0b7156ac0a fix(auth): restore accurate column defaults and quality flatten in migration down path
- permissions column default on down path corrected to ARRAY['marker_edit']::text[]
  (matching the schema set by 183_marker_edit_audit.sql, not the bare '{}' default)
- max_playback_quality now flattened on down path via rank-prefixed MAX + split_part,
  mirroring the most-permissive-wins logic in internal/access/quality.go
- spec doc: align default_group_slugs → users.default_group_slugs to match the
  server_settings key used in the migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 9c0172a706 feat(auth): group-based permissions schema migration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0733e3fc51 feat(auth): effective policy union resolver
Implements ApplyEffectivePolicy to compute a user's effective authorization
policy as the most-permissive union of their assigned groups. Handles special
cases for admin permissions, unrestricted library access, quality ceilings,
and download settings via table tests following TDD order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code ecc08b7569 feat(auth): group model, admin permission, IsAdmin derivation
- Add internal/models/group.go with Group, CreateGroupInput, UpdateGroupInput
  and built-in slug constants (administrators, users)
- Add IsAdmin bool and GroupIDs []int to models.User (derived fields, not DB columns)
- Add PermissionAdmin to assignablePermissions so groups can carry it
- Replace user.Role == "admin" checks in HasEffectivePermission / EffectivePermissions
  with user.IsAdmin (interim: scanUser/scanUsers set IsAdmin = Role == "admin")
- Update permissions_test.go: add IsAdmin: true alongside Role: "admin" in existing
  test, add TestAdminPermissionIsAssignable and TestIsAdminGrantsAllPermissions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0638a514b7 feat(access): add MaxQuality for most-permissive quality merge 2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code e067041b05 docs: group-based permissions implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code d934bb8924 docs: fix group migration to not erase outlier restrictions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4d98005a23 docs: group-based tiered security model design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
86 changed files with 7817 additions and 1659 deletions
+1 -1
View File
@@ -1954,7 +1954,7 @@ func main() {
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.APIKeyUserLoader = userRepo
compatDeps.UserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
# Tiered Security Model: Group-Based Permissions — Design
Date: 2026-06-10
Status: Approved design, pending implementation plan
Commands and paths in this document assume the repository root is the cwd.
## Overview
Replace Silo's per-user authorization fields with a group-based model:
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
- Groups are the **only** source of policy. Per-user policy columns are removed.
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
## Current state (what this replaces)
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
## Data model
### `groups`
```sql
CREATE TABLE groups (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
name text NOT NULL, -- display name, renamable
description text NOT NULL DEFAULT '',
built_in boolean NOT NULL DEFAULT false,
permissions text[] NOT NULL DEFAULT '{}',
library_ids integer[], -- NULL = all libraries
max_streams integer NOT NULL DEFAULT 6,
max_transcodes integer NOT NULL DEFAULT 2,
max_profiles integer NOT NULL DEFAULT 5,
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
download_allowed boolean NOT NULL DEFAULT true,
download_transcode_allowed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
### `user_groups`
```sql
CREATE TABLE user_groups (
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
```
### Admin as a permission
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
### Built-in groups
| Slug | Permissions | Policy | Protections |
|---|---|---|---|
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
### `users` table changes
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
### Default group setting
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
## Resolution semantics
A user's **effective policy** is the most-permissive union of their groups:
| Field | Rule |
|---|---|
| `permissions` | set union; `admin` present → every permission granted |
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
| `download_allowed`, `download_transcode_allowed` | OR |
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
**Invalidation:** unchanged mechanism, new triggers:
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
- Membership add/remove → bump the affected user's revision.
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
## Enforcement changes
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
## API surface
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
- `POST /api/admin/groups` — create; slug auto-derived from name
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
Changed endpoints:
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
Default-group setting rides the existing server-settings endpoints.
## Admin UI (`web/src/pages/admin`)
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
## Migration
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
## Testing
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
## Risks & follow-ups
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
+5
View File
@@ -5,4 +5,9 @@ import "errors"
var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileUnverified = errors.New("profile unverified")
// ErrUserDisabled is returned when the viewer's account is disabled.
// Scope resolution re-loads the user on every request, so disabling an
// account denies content access on the next request for every surface
// that resolves viewer scope (native API and Jellyfin compat alike).
ErrUserDisabled = errors.New("user account disabled")
)
+14
View File
@@ -79,3 +79,17 @@ func MinQuality(a, b string) string {
return b
}
}
// MaxQuality returns the more permissive quality ceiling. An empty string
// means unrestricted and beats any concrete ceiling.
func MaxQuality(a, b string) string {
a = NormalizePlaybackQuality(a)
b = NormalizePlaybackQuality(b)
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
return ""
}
if CompareQuality(a, b) >= 0 {
return a
}
return b
}
+19
View File
@@ -54,3 +54,22 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
t.Fatal("expected invalid preset to return ok=false")
}
}
func TestMaxQuality(t *testing.T) {
cases := []struct {
a, b, want string
}{
{"", "", ""},
{"", "1080p", ""}, // unrestricted beats any ceiling
{"2160p", "", ""},
{"1080p", "2160p", "2160p"},
{"2160p", "1080p", "2160p"},
{"1080p", "1080p", "1080p"},
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
}
for _, tc := range cases {
if got := MaxQuality(tc.a, tc.b); got != tc.want {
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
}
}
}
+3
View File
@@ -46,6 +46,9 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
if err != nil {
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
}
if !user.Enabled {
return Scope{}, ErrUserDisabled
}
scope := Scope{
UserID: user.ID,
+20 -7
View File
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[3,5]"},
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[2,4]"},
@@ -388,9 +388,22 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
}
}
func TestResolver_DisabledUserIsDenied(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
}
}
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
settings: map[string]string{"disabled_library_ids": "[7]"},
}},
+105 -206
View File
@@ -1,7 +1,6 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -103,11 +102,17 @@ func NewAdminHandler(
pool *pgxpool.Pool,
storeProv userstore.UserStoreProvider,
) *AdminHandler {
var groupResolver auth.GroupResolver
if pool != nil {
groupResolver = auth.NewGroupRepository(pool)
}
return &AdminHandler{
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
// No settings dependency at construction time: nil settings makes the
// provisioner fall back to the built-in users group for defaults.
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
}
}
@@ -115,115 +120,53 @@ func NewAdminHandler(
// createUserRequest represents the JSON body for POST /admin/users.
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Permissions createStringSliceField `json:"permissions"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
type createStringSliceField struct {
Set bool
Value []string
}
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
type updateLibraryIDsField struct {
Set bool
Value []int
}
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateLibraryIDsField) Ptr() *[]int {
if !f.Set {
return nil
}
value := append([]int(nil), f.Value...)
return &value
}
type updateStringSliceField struct {
Set bool
Value []string
}
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateStringSliceField) Ptr() *[]string {
if !f.Set {
return nil
}
value := append([]string(nil), f.Value...)
return &value
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
// GroupIDs are the group memberships to assign. Omitted (nil) means the
// configured default groups are used.
GroupIDs []int `json:"group_ids"`
}
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
type updateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *string `json:"role,omitempty"`
Permissions updateStringSliceField `json:"permissions,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
GroupIDs *[]int `json:"group_ids,omitempty"`
}
// adminUserResponse represents a user in admin JSON responses.
// adminGroupRef is a lightweight group reference in admin user responses.
type adminGroupRef struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
// adminUserResponse represents a user in admin JSON responses. The policy
// fields are the effective values derived from the user's groups.
type adminUserResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
Groups []adminGroupRef `json:"groups"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
type adminPlaybackHistoryRow struct {
@@ -269,15 +212,21 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
return ""
}
// toAdminUserResponse converts a User model to an admin API response.
// toAdminUserResponse converts a User model (with its group memberships
// loaded into u.Groups at load time) to an admin API response.
func toAdminUserResponse(u *models.User) adminUserResponse {
refs := make([]adminGroupRef, 0, len(u.Groups))
for _, g := range u.Groups {
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
}
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Role: auth.RoleForUser(u),
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
Groups: refs,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
MaxStreams: u.MaxStreams,
@@ -340,10 +289,13 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
return
}
resp := make([]adminUserResponse, 0, len(users))
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
resp := make([]adminUserResponse, 0, len(users))
for _, u := range users {
resp = append(resp, toAdminUserResponse(u))
}
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
@@ -393,44 +345,17 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
return
}
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
permissions := auth.DefaultUserPermissions()
if req.Permissions.Set {
permissions = req.Permissions.Value
}
permissions, err := auth.NormalizePermissions(permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
return
}
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
User: models.CreateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
GroupIDs: req.GroupIDs,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: req.CreateDefaultProfile,
@@ -438,6 +363,10 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
},
})
if err != nil {
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
return
}
@@ -461,43 +390,12 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
return
}
var maxPlaybackQuality *string
if req.MaxPlaybackQuality != nil {
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
maxPlaybackQuality = &normalized
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
var permissions *[]string
if req.Permissions.Set {
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
permissions = &normalized
}
updateInput := models.UpdateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
Enabled: req.Enabled,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
Enabled: req.Enabled,
GroupIDs: req.GroupIDs,
}
var currentUser *models.User
@@ -519,6 +417,14 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
return
}
@@ -549,6 +455,10 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
err = h.userRepo.Delete(r.Context(), id)
if err != nil {
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
return
}
@@ -792,10 +702,8 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
return input.Password != nil ||
input.Role != nil ||
input.Enabled != nil ||
input.Permissions != nil ||
input.MaxPlaybackQuality != nil
input.GroupIDs != nil
}
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
@@ -805,22 +713,28 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
if current == nil {
return updateMayRequireSessionRevocation(input)
}
if input.Role != nil && *input.Role != current.Role {
return true
}
if input.Enabled != nil && *input.Enabled != current.Enabled {
return true
}
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
return true
}
if input.MaxPlaybackQuality != nil &&
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
// Group membership changes alter the effective policy (permissions,
// quality ceilings, ...), so a real change revokes sessions.
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
return true
}
return false
}
// equalIntSets reports whether two int slices contain the same set of values,
// ignoring order and duplicates.
func equalIntSets(a, b []int) bool {
normalize := func(values []int) []int {
out := append([]int(nil), values...)
sort.Ints(out)
return slices.Compact(out)
}
return slices.Equal(normalize(a), normalize(b))
}
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
if h.pool == nil {
return nil
@@ -1021,7 +935,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
@@ -2080,21 +1994,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
if key == "defaults.max_playback_quality" {
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
req.Value = normalized
}
if key == "defaults.max_profiles" {
n, err := strconv.Atoi(req.Value)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
return
}
}
switch key {
case markers.SettingMode, markers.SettingLazyPlayback:
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
+29
View File
@@ -0,0 +1,29 @@
package handlers
import (
"net/http"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
// isAdminRequest reports whether the request's authenticated user currently
// holds the admin permission. Admin authority is always derived from the
// freshly loaded user (group-derived policy), never from token contents.
// It first consults the user stashed by the RequireAdmin middleware and
// falls back to loading via the given loader.
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
ctx := r.Context()
if user := apimw.GetUser(ctx); user != nil {
return user.Enabled && user.IsAdmin
}
claims := apimw.GetClaims(ctx)
if claims == nil || loader == nil {
return false
}
user, err := loader.GetByID(ctx, claims.UserID)
if err != nil || user == nil {
return false
}
return user.Enabled && user.IsAdmin
}
@@ -27,7 +27,6 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
+379
View File
@@ -0,0 +1,379 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// GroupStore defines the group repository operations the AdminGroupsHandler
// needs, so tests can substitute a fake.
type GroupStore interface {
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
GetByID(ctx context.Context, id int) (*models.Group, error)
MemberCount(ctx context.Context, groupID int) (int, error)
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
Delete(ctx context.Context, id int) error
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
AddMember(ctx context.Context, groupID, userID int) error
RemoveMember(ctx context.Context, groupID, userID int) error
}
// AdminGroupsHandler handles admin-only group management endpoints.
type AdminGroupsHandler struct {
store GroupStore
}
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
// group store.
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
return &AdminGroupsHandler{store: store}
}
// --- Request/Response types ---
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
type createAdminGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
MaxStreams *int `json:"max_streams"`
MaxTranscodes *int `json:"max_transcodes"`
MaxProfiles *int `json:"max_profiles"`
MaxPlaybackQuality *string `json:"max_playback_quality"`
DownloadAllowed *bool `json:"download_allowed"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
}
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
// Absent fields are left unchanged.
type updateAdminGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions optionalStringSliceField `json:"permissions,omitempty"`
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
// adminGroupResponse represents a group in admin JSON responses.
type adminGroupResponse struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null = all libraries
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
MaxPlaybackQuality string `json:"max_playback_quality"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type adminGroupListResponse struct {
Groups []adminGroupResponse `json:"groups"`
}
type adminGroupMemberRow struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Enabled bool `json:"enabled"`
}
type adminGroupMembersResponse struct {
Members []adminGroupMemberRow `json:"members"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
return adminGroupResponse{
ID: g.ID,
Slug: g.Slug,
Name: g.Name,
Description: g.Description,
BuiltIn: g.BuiltIn,
Permissions: append([]string{}, g.Permissions...),
LibraryIDs: g.LibraryIDs,
MaxStreams: g.MaxStreams,
MaxTranscodes: g.MaxTranscodes,
MaxProfiles: g.MaxProfiles,
MaxPlaybackQuality: g.MaxPlaybackQuality,
DownloadAllowed: g.DownloadAllowed,
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
MemberCount: memberCount,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
}
// --- Helpers ---
// writeGroupError maps group repository sentinel errors to API responses.
func writeGroupError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, auth.ErrGroupNotFound):
writeError(w, http.StatusNotFound, "not_found", "Group not found")
case errors.Is(err, auth.ErrUnknownUser):
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
case errors.Is(err, auth.ErrBuiltInGroup):
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
case errors.Is(err, auth.ErrAdminPermRequired):
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
case errors.Is(err, auth.ErrLastAdministrator):
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
case errors.Is(err, auth.ErrDuplicate):
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
}
}
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
id, err := strconv.Atoi(chi.URLParam(r, name))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
return 0, false
}
return id, true
}
// --- Handler methods ---
// HandleList handles GET /admin/groups.
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
groups, err := h.store.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
return
}
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
for i := range groups {
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreate handles POST /admin/groups.
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req createAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
return
}
permissions, err := auth.NormalizePermissions(req.Permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
Name: name,
Description: req.Description,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
})
if err != nil {
writeGroupError(w, err)
return
}
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
}
// HandleGet handles GET /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
group, err := h.store.GetByID(r.Context(), id)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleUpdate handles PATCH /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
var req updateAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
return
}
input := models.UpdateGroupInput{
Name: req.Name,
Description: req.Description,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
}
if req.Permissions.Set {
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.Permissions = &permissions
}
group, err := h.store.Update(r.Context(), id, input)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleDelete handles DELETE /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListMembers handles GET /admin/groups/{id}/members.
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.store.GetByID(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
q := r.URL.Query()
offset := 0
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
if err != nil {
writeGroupError(w, err)
return
}
resp := adminGroupMembersResponse{
Members: make([]adminGroupMemberRow, 0, len(members)),
Total: total,
Offset: offset,
Limit: limit,
}
for _, m := range members {
resp.Members = append(resp.Members, adminGroupMemberRow{
UserID: m.UserID,
Username: m.Username,
Email: m.Email,
Enabled: m.Enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+550
View File
@@ -0,0 +1,550 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeGroupStore is a configurable in-memory GroupStore that records the
// inputs handlers pass to it.
type fakeGroupStore struct {
listGroups []auth.GroupWithMemberCount
listErr error
getGroup *models.Group
getErr error
memberCount int
countErr error
createInput *models.CreateGroupInput
createGroup *models.Group
createErr error
updateID int
updateInput *models.UpdateGroupInput
updateGroup *models.Group
updateErr error
deleteID int
deleteErr error
membersGroupID int
membersOffset int
membersLimit int
members []auth.GroupMember
membersTotal int
membersErr error
addGroupID int
addUserID int
addErr error
removeGroupID int
removeUserID int
removeErr error
}
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
return f.listGroups, f.listErr
}
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
return f.getGroup, f.getErr
}
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
return f.memberCount, f.countErr
}
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
f.createInput = &input
return f.createGroup, f.createErr
}
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
f.updateID = id
f.updateInput = &input
return f.updateGroup, f.updateErr
}
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
f.deleteID = id
return f.deleteErr
}
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
f.membersGroupID = groupID
f.membersOffset = offset
f.membersLimit = limit
return f.members, f.membersTotal, f.membersErr
}
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
f.addGroupID = groupID
f.addUserID = userID
return f.addErr
}
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
f.removeGroupID = groupID
f.removeUserID = userID
return f.removeErr
}
// newGroupsTestRouter mounts the handler routes the same way the API router
// does so tests exercise real chi URL params.
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
h := NewAdminGroupsHandler(store)
r := chi.NewRouter()
r.Get("/groups", h.HandleList)
r.Post("/groups", h.HandleCreate)
r.Get("/groups/{id}", h.HandleGet)
r.Patch("/groups/{id}", h.HandleUpdate)
r.Delete("/groups/{id}", h.HandleDelete)
r.Get("/groups/{id}/members", h.HandleListMembers)
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
return r
}
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
reader = bytes.NewReader(payload)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, target, reader)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
}
return out
}
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["error"] != code {
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
}
}
func testGroup() *models.Group {
return &models.Group{
ID: 7,
Slug: "film-club",
Name: "Film Club",
Description: "Movie nights",
Permissions: []string{"marker_edit"},
LibraryIDs: nil,
MaxStreams: 2,
MaxTranscodes: 1,
MaxProfiles: 4,
MaxPlaybackQuality: "1080p",
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
}
func TestAdminGroupsList(t *testing.T) {
store := &fakeGroupStore{
listGroups: []auth.GroupWithMemberCount{
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
},
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
groups, ok := body["groups"].([]any)
if !ok || len(groups) != 2 {
t.Fatalf("groups = %v, want 2 entries", body["groups"])
}
first := groups[0].(map[string]any)
if first["member_count"] != float64(1) {
t.Fatalf("member_count = %v, want 1", first["member_count"])
}
if first["library_ids"] != nil {
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
}
second := groups[1].(map[string]any)
if second["member_count"] != float64(12) {
t.Fatalf("member_count = %v, want 12", second["member_count"])
}
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
}
}
func TestAdminGroupsCreate(t *testing.T) {
store := &fakeGroupStore{createGroup: testGroup()}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " Film Club ",
"description": "Movie nights",
"permissions": []string{"marker_edit"},
"library_ids": []int{1, 2},
"max_streams": 2,
})
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
}
if store.createInput == nil {
t.Fatal("store.Create was not called")
}
if store.createInput.Name != "Film Club" {
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
}
if len(store.createInput.LibraryIDs) != 2 {
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
}
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
}
if store.createInput.MaxTranscodes != nil {
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
}
body := decodeGroupsJSON(t, rec)
if body["slug"] != "film-club" {
t.Fatalf("slug = %v, want film-club", body["slug"])
}
if body["member_count"] != float64(0) {
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
}
}
func TestAdminGroupsCreateEmptyName(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " ",
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for empty name")
}
}
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for unknown permission")
}
}
func TestAdminGroupsCreateDuplicate(t *testing.T) {
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
})
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
}
func TestAdminGroupsGet(t *testing.T) {
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["id"] != float64(7) {
t.Fatalf("id = %v, want 7", body["id"])
}
if body["member_count"] != float64(5) {
t.Fatalf("member_count = %v, want 5", body["member_count"])
}
}
func TestAdminGroupsGetNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsGetInvalidID(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
}
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
"permissions": []string{"marker_edit"},
})
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
}
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.updateInput != nil {
t.Fatal("store.Update should not be called for unknown permission")
}
}
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "absent means no change",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs != nil {
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
}
},
},
{
name: "null means all libraries",
body: `{"library_ids":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
}
if *input.LibraryIDs != nil {
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
}
},
},
{
name: "empty array means no libraries",
body: `{"library_ids":[]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
}
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
}
},
},
{
name: "explicit ids",
body: `{"library_ids":[1,2]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
}
ids := *input.LibraryIDs
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
}
func TestAdminGroupsDelete(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.deleteID != 7 {
t.Fatalf("deleted id = %d, want 7", store.deleteID)
}
}
func TestAdminGroupsListMembersPagination(t *testing.T) {
store := &fakeGroupStore{
members: []auth.GroupMember{
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
},
membersTotal: 42,
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
store.membersGroupID, store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
body["total"], body["offset"], body["limit"])
}
members, ok := body["members"].([]any)
if !ok || len(members) != 1 {
t.Fatalf("members = %v, want 1 entry", body["members"])
}
member := members[0].(map[string]any)
if member["user_id"] != float64(3) || member["username"] != "alice" ||
member["email"] != "alice@example.com" || member["enabled"] != true {
t.Fatalf("member = %v, want alice row", member)
}
}
func TestAdminGroupsListMembersDefaults(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersOffset != 0 || store.membersLimit != 50 {
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
t.Fatalf("members = %v, want empty array (not null)", body["members"])
}
}
func TestAdminGroupsAddMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.addGroupID != 7 || store.addUserID != 12 {
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
}
}
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminGroupsRemoveMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.removeGroupID != 7 || store.removeUserID != 12 {
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
}
}
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
}
func TestAdminGroupsListMembersNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "explicit permissions reach store as pointer to slice",
body: `{"permissions":["marker_edit"]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
}
perms := *input.Permissions
if len(perms) != 1 || perms[0] != "marker_edit" {
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
}
},
},
{
name: "null permissions reaches store as pointer to empty slice",
body: `{"permissions":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to empty slice")
}
if len(*input.Permissions) != 0 {
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
}
},
},
{
name: "absent permissions reaches store as nil (no change)",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions != nil {
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
+14 -12
View File
@@ -37,6 +37,7 @@ type AdminJobsHandler struct {
store AdminJobArtifactStore
CancelRegistry *adminjob.CancelRegistry
RealtimeHub *notifications.Hub
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
}
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
@@ -122,13 +123,14 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
claims := apimw.GetClaims(r.Context())
if !canReadAdminJob(claims, job) {
viewerUserID := apimw.GetUserID(r.Context())
viewerIsAdmin := isAdminRequest(r, h.Users)
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
}
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
@@ -264,19 +266,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
return resp
}
func adminJobToResponseForClaims(
func adminJobToResponseForViewer(
r *http.Request,
job *models.AdminJob,
store AdminJobArtifactStore,
claims *auth.Claims,
viewerIsAdmin bool,
) adminJobResponse {
response := adminJobToResponse(r, job, store)
sanitizeAdminJobResponseForClaims(&response, claims)
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
return response
}
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
if response == nil || (claims != nil && claims.Role == "admin") {
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
if response == nil || viewerIsAdmin {
return
}
response.RequestPayload = json.RawMessage(`{}`)
@@ -384,12 +386,12 @@ func currentAdminUserID(r *http.Request) int {
return claims.UserID
}
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
if claims == nil || job == nil {
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
if viewerUserID == 0 || job == nil {
return false
}
if claims.Role == "admin" {
if viewerIsAdmin {
return true
}
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
}
+6 -12
View File
@@ -13,46 +13,40 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
claims := &auth.Claims{UserID: 1, Role: "admin"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(1, true, job) {
t.Fatal("admin should be allowed to read any job")
}
}
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(2, false, job) {
t.Fatal("creator should be allowed to read own item refresh job")
}
}
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if canReadAdminJob(claims, job) {
if canReadAdminJob(2, false, job) {
t.Fatal("non-admin should not read non-item-refresh jobs")
}
}
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 3, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if canReadAdminJob(claims, job) {
if canReadAdminJob(3, false, job) {
t.Fatal("non-admin should not read another user's item refresh job")
}
}
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
job := &models.AdminJob{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: 2,
@@ -66,7 +60,7 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
PublicURL: "https://example.test/public",
}
resp := adminJobToResponseForClaims(nil, job, nil, claims)
resp := adminJobToResponseForViewer(nil, job, nil, false)
if string(resp.RequestPayload) != `{}` {
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
if result.DispatchErr != nil {
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
command.DeadlineMS = int(deadline / time.Millisecond)
+78 -63
View File
@@ -1,34 +1,82 @@
package handlers
import (
"context"
"net/http"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
// tests.
type fakeAdminUserRepo struct {
user *models.User
updateErr error
deleteErr error
}
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
return f.updateErr
}
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
if f.user == nil {
return nil, auth.ErrNotFound
}
return f.user, nil
}
// newAdminUsersTestRouter mounts the user-management routes the same way the
// API router does so tests exercise real chi URL params.
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
h := NewAdminHandler(repo, nil, nil)
r := chi.NewRouter()
r.Put("/admin/users/{id}", h.HandleUpdateUser)
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
return r
}
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{
user: &models.User{ID: 2, Enabled: true},
updateErr: auth.ErrLastAdministrator,
}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
"/admin/users/2", map[string]any{"enabled": false})
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
"/admin/users/2", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestUpdateRequiresSessionRevocation(t *testing.T) {
role := "admin"
sameRole := "user"
enabled := true
disabled := false
libraryIDs := []int{1, 2}
sameLibraryIDs := []int{1}
emptyLibraryIDs := []int{}
var allLibraryIDs []int
maxPlaybackQuality := "1080p"
sameMaxPlaybackQuality := "original"
password := "new-password"
username := "renamed"
maxStreams := 4
permissions := []string{"metadata_curation"}
samePermissions := []string{"download"}
newGroups := []int{1, 2}
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
emptyGroups := []int{}
current := &models.User{
Role: "user",
Permissions: []string{"download"},
Enabled: false,
LibraryIDs: []int{1},
MaxPlaybackQuality: "original",
Enabled: false,
GroupIDs: []int{1, 2},
}
tests := []struct {
@@ -36,26 +84,6 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
in models.UpdateUserInput
want bool
}{
{
name: "permissions set",
in: models.UpdateUserInput{Permissions: &permissions},
want: true,
},
{
name: "permissions unchanged",
in: models.UpdateUserInput{Permissions: &samePermissions},
want: false,
},
{
name: "role",
in: models.UpdateUserInput{Role: &role},
want: true,
},
{
name: "role unchanged",
in: models.UpdateUserInput{Role: &sameRole},
want: false,
},
{
name: "enabled",
in: models.UpdateUserInput{Enabled: &enabled},
@@ -67,30 +95,15 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
want: false,
},
{
name: "library ids does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
name: "groups unchanged",
in: models.UpdateUserInput{GroupIDs: &sameGroups},
want: false,
},
{
name: "library ids unchanged",
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
want: false,
},
{
name: "library ids nil does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
want: false,
},
{
name: "max playback quality",
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
name: "groups cleared",
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
want: true,
},
{
name: "max playback quality unchanged",
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
want: false,
},
{
name: "password",
in: models.UpdateUserInput{Password: &password},
@@ -98,7 +111,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
},
{
name: "non access fields",
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
in: models.UpdateUserInput{Username: &username},
want: false,
},
{
@@ -116,16 +129,18 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
})
}
unrestrictedCurrent := *current
unrestrictedCurrent.LibraryIDs = nil
t.Run("library ids empty does not revoke session", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
memberless := &models.User{Enabled: true, GroupIDs: nil}
t.Run("groups assigned to memberless user", func(t *testing.T) {
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
})
t.Run("library ids nil unchanged", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
}
})
+2 -2
View File
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Role: auth.RoleForUser(user),
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
@@ -24,7 +24,6 @@ func newEbookReaderAuthRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
@@ -421,7 +420,6 @@ func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
@@ -524,7 +522,6 @@ func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
@@ -608,7 +605,6 @@ func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
+91 -14
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -31,6 +32,10 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
}
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
// user to confirm the account is still enabled and admin status is unchanged.
const viewerRevalidateInterval = 60 * time.Second
type EventsHandler struct {
hub *evt.Hub
jobs *AdminJobsHandler
@@ -39,6 +44,11 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
users auth.UserLoader
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
// means the default.
revalidateInterval time.Duration
}
func NewEventsHandler(
@@ -49,6 +59,7 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
users auth.UserLoader,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -58,6 +69,7 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
users: users,
}
}
@@ -73,6 +85,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
}
// Admin status is checked server-side once per connection; it never comes
// from token contents.
viewerIsAdmin := isAdminRequest(r, h.users)
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -89,7 +105,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return writeWebSocketControl(conn, websocket.PingMessage, nil)
})
allowedChannels := allowedChannelsForRole(claims.Role)
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
@@ -121,6 +137,17 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
// Channel authorization is decided once at upgrade, so a long-lived
// connection must periodically re-load its user: a disabled account or a
// changed admin role would otherwise keep its channel set until the
// client disconnects on its own.
revalidateEvery := h.revalidateInterval
if revalidateEvery <= 0 {
revalidateEvery = viewerRevalidateInterval
}
revalidate := time.NewTicker(revalidateEvery)
defer revalidate.Stop()
subscriptions := make(map[evt.EventChannel]struct{})
subscribedOnce := false
@@ -130,6 +157,33 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
case <-readDone:
return
case <-revalidate.C:
if h.users == nil {
// No loader wired: the viewer was never granted admin
// channels (isAdminRequest fails closed), so there is no
// stale privilege to revoke.
continue
}
user, err := h.users.GetByID(ctx, claims.UserID)
if err != nil && !auth.IsNotFound(err) {
// Transient lookup failure: keep the connection and retry on
// the next tick rather than dropping every viewer on a DB
// blip. A definitive miss (ErrNotFound) leaves user nil and
// closes below.
slog.Warn("events: viewer revalidation failed; retrying next tick",
"user_id", claims.UserID,
"error", err,
)
continue
}
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
)
return
}
case <-deadline.C:
if subscribedOnce {
continue
@@ -142,7 +196,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
)
return
case data := <-readMessages:
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
if !ok {
return
}
@@ -164,10 +218,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if _, subscribed := subscriptions[env.Channel]; !subscribed {
continue
}
if !allowsEventForClaims(claims, env) {
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
continue
}
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
return
}
}
@@ -178,6 +232,7 @@ func (h *EventsHandler) handleEventsClientMessage(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
data []byte,
allowed []evt.EventChannel,
) (map[evt.EventChannel]struct{}, bool, bool) {
@@ -262,7 +317,7 @@ func (h *EventsHandler) handleEventsClientMessage(
}
for _, channel := range accepted {
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
return nil, false, false
}
}
@@ -270,13 +325,32 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
func allowedChannelsForRole(role string) []evt.EventChannel {
// revalidateViewer decides whether an open events WebSocket must be closed
// after its user is re-loaded. The connection closes when the account is gone
// or disabled, and when admin status changed in either direction — the
// allowed channel set is fixed at upgrade, so the client must reconnect to
// receive the correct set; closing on change is simpler and safer than
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
// bumps from group/library edits) deliberately keep the connection: event
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
// on admin status and user ID, never on per-user library or rating policy.
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
if user == nil || !user.Enabled {
return true, "account disabled"
}
if user.IsAdmin != wasAdmin {
return true, "permissions changed"
}
return false, ""
}
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
}
if role == "admin" {
if viewerIsAdmin {
channels = append(channels,
evt.ChannelJobs,
evt.ChannelSessions,
@@ -287,14 +361,14 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
return channels
}
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if claims == nil {
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
if viewerUserID == 0 {
return false
}
if env.AdminOnly && claims.Role != "admin" {
if env.AdminOnly && !viewerIsAdmin {
return false
}
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
return false
}
return true
@@ -314,6 +388,7 @@ func marshalJSON(value any) json.RawMessage {
func (h *EventsHandler) snapshotForChannel(
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) (json.RawMessage, error) {
switch channel {
@@ -366,7 +441,7 @@ func (h *EventsHandler) snapshotForChannel(
if h == nil || h.historyImports == nil {
return json.RawMessage("[]"), nil
}
if claims != nil && claims.Role == "admin" {
if viewerIsAdmin {
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
if err != nil {
return nil, err
@@ -387,9 +462,10 @@ func (h *EventsHandler) writeSnapshotFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) error {
data, err := h.snapshotForChannel(r, claims, channel)
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
if err != nil {
slog.Error(
"events: failed to build initial snapshot",
@@ -415,11 +491,12 @@ func (h *EventsHandler) writeEventFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
env evt.Envelope,
) error {
data := env.Data
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
snapshot, err := h.snapshotForChannel(r, claims, viewerIsAdmin, env.Channel)
if err != nil {
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
return err
+170
View File
@@ -0,0 +1,170 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRevalidateViewer(t *testing.T) {
cases := []struct {
name string
user *models.User
wasAdmin bool
wantClose bool
}{
{
name: "user deleted",
user: nil,
wasAdmin: false,
wantClose: true,
},
{
name: "user disabled",
user: &models.User{ID: 1, Enabled: false},
wasAdmin: false,
wantClose: true,
},
{
name: "admin disabled keeps no access even with admin flag",
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
wasAdmin: true,
wantClose: true,
},
{
name: "admin demoted",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: true,
wantClose: true,
},
{
name: "viewer promoted to admin",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: false,
wantClose: true,
},
{
name: "enabled non-admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: false,
wantClose: false,
},
{
name: "enabled admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: true,
wantClose: false,
},
{
// Policy revision bumps (group/library edits) do not affect event
// authorization, which depends only on admin status and user ID.
name: "policy revision change without admin change",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
wasAdmin: false,
wantClose: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
if closeConn != tc.wantClose {
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
}
if closeConn && reason == "" {
t.Fatal("expected a close reason")
}
if !closeConn && reason != "" {
t.Fatalf("expected empty reason for kept connection, got %q", reason)
}
})
}
}
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
type mutableUserLoader struct {
mu sync.Mutex
user *models.User
err error
}
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.err != nil {
return nil, l.err
}
if l.user == nil || l.user.ID != id {
return nil, auth.ErrNotFound
}
userCopy := *l.user
return &userCopy, nil
}
func (l *mutableUserLoader) set(user *models.User, err error) {
l.mu.Lock()
l.user = user
l.err = err
l.mu.Unlock()
}
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
// revalidation ticker end to end: an open connection is closed by the server
// once the periodic user re-load reports the account disabled.
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
handler.revalidateInterval = 20 * time.Millisecond
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("Dial websocket: %v", err)
}
defer conn.Close()
// Complete the handshake: hello, subscribe, subscribed + snapshot.
var hello evt.EventsHelloMessage
if err := conn.ReadJSON(&hello); err != nil {
t.Fatalf("read hello: %v", err)
}
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
Channels: []evt.EventChannel{evt.ChannelCatalog},
}); err != nil {
t.Fatalf("write subscribe: %v", err)
}
// The account is disabled while the connection is open; the next
// revalidation tick must close it with a normal close frame.
loader.set(&models.User{ID: 1, Enabled: false}, nil)
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
for {
var frame json.RawMessage
if err := conn.ReadJSON(&frame); err != nil {
var closeErr *websocket.CloseError
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return // server closed the connection as required
}
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
}
// Drain subscribed/snapshot frames until the close arrives.
}
}
-3
View File
@@ -1800,9 +1800,6 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
if claims == nil {
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.UserRepo == nil {
return false
}
+62
View File
@@ -0,0 +1,62 @@
package handlers
// This file holds tri-state JSON field wrappers for PATCH-style request
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
// value); these wrappers track three:
//
// - absent: the key was not present in the JSON body (Set = false) —
// the caller should leave the current value unchanged
// - null: the key was present with an explicit JSON null (Set = true)
// - value: the key was present with a concrete value (Set = true)
//
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
// to an empty slice.
import (
"bytes"
"encoding/json"
)
// optionalIntSliceField distinguishes an absent JSON field from an explicit
// null or array value. Absent = don't update; null = nil slice ("all
// libraries"); [] = empty slice ("none").
type optionalIntSliceField struct {
Set bool
Value []int
}
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
// Ptr returns nil when the field was absent, otherwise a pointer to the
// decoded slice (which may itself be nil for JSON null).
func (f optionalIntSliceField) Ptr() *[]int {
if !f.Set {
return nil
}
value := f.Value
return &value
}
// optionalStringSliceField distinguishes an absent JSON field from an
// explicit null or array value. JSON null decodes to an empty slice.
type optionalStringSliceField struct {
Set bool
Value []string
}
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
-3
View File
@@ -288,9 +288,6 @@ func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.Users == nil {
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
return false
+15 -9
View File
@@ -90,7 +90,6 @@ func markerItemPutRequest(body string) *http.Request {
func withMarkerAdminClaims(req *http.Request) *http.Request {
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
SessionID: "session-1",
}))
@@ -98,7 +97,12 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
h.Users = fakeMarkerUsers{
1: {ID: 1, Enabled: true, IsAdmin: true},
7: {ID: 7, Enabled: true, IsAdmin: true},
}
return h
}
func TestSetFileMarkersWritesManual(t *testing.T) {
@@ -131,6 +135,7 @@ func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
episodeFiles: []*models.MediaFile{file},
}
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
@@ -209,13 +214,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
h := newMarkersHandler(nil)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
}
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("fileId", "5")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleGetFileMarkers(rec, req)
@@ -229,10 +234,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -249,10 +254,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -274,7 +279,6 @@ func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
ctx = apimw.SetClaims(ctx, &auth.Claims{
UserID: 7,
Role: "admin",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKeyID,
})
@@ -382,6 +386,7 @@ func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
called := make(chan markers.ContributeOptions, 1)
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
@@ -403,6 +408,7 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
called := make(chan markers.ContributeOptions, 1)
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
+1 -1
View File
@@ -162,7 +162,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
func newAuthorizedPlaybackContext() context.Context {
ctx := context.Background()
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
return apimw.SetProfileID(ctx, "profile-1")
}
+8 -2
View File
@@ -124,6 +124,12 @@ type verifyPINResponse struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
// isAdminProfileRequest reports whether the request user currently holds the
// admin permission, checked server-side against the loaded user.
func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
return isAdminRequest(r, h.UserRepo)
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
@@ -133,7 +139,7 @@ type verifyPINResponse struct {
// bypass the profile lock by sending only X-Profile-Id.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
ctx := r.Context()
if apimw.IsAdmin(ctx) {
if h.isAdminProfileRequest(r) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
@@ -311,7 +317,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
// Access-policy fields only make sense when set by a manager on a managed
// profile. On bootstrap the caller is becoming primary themselves, so non-
// admin bootstrap creations must leave those fields at their defaults.
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
if isBootstrap && !h.isAdminProfileRequest(r) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
+1 -2
View File
@@ -45,7 +45,6 @@ func newAuthorizedProfileRequestWithSession(
req := httptest.NewRequest(method, path, strings.NewReader(body))
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: role,
SessionID: sessionID,
TokenType: auth.TokenTypeAccess,
})
@@ -99,7 +98,7 @@ func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
handler.UserRepo = testProfileUserRepo{
user: &models.User{ID: 1, MaxProfiles: 1},
user: &models.User{ID: 1, MaxProfiles: 1, Enabled: true, IsAdmin: true},
}
req := newAuthorizedProfileRequestWithRole(
+32 -30
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
@@ -50,6 +51,7 @@ type RequestService interface {
type RequestsHandler struct {
service RequestService
Users auth.UserLoader // for server-side admin checks
}
func NewRequestsHandler(service RequestService) *RequestsHandler {
@@ -57,7 +59,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
}
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -80,7 +82,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -95,7 +97,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -112,7 +114,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
}
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -127,7 +129,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -142,7 +144,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -157,7 +159,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
}
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -176,7 +178,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -195,7 +197,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
}
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -215,7 +217,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -234,7 +236,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -252,7 +254,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -267,7 +269,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -280,7 +282,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -295,7 +297,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -308,7 +310,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -330,7 +332,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -352,7 +354,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -365,7 +367,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -378,7 +380,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -391,7 +393,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -409,7 +411,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
}
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -424,7 +426,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
}
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -442,7 +444,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -461,7 +463,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -473,7 +475,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -496,7 +498,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
}
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -513,7 +515,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -535,7 +537,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, updated)
}
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.UserID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -549,7 +551,7 @@ func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool)
return mediarequests.Viewer{
UserID: claims.UserID,
ProfileID: profileID,
IsAdmin: claims.Role == "admin",
IsAdmin: isAdminRequest(r, h.Users),
}, true
}
-1
View File
@@ -147,7 +147,6 @@ func authedRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
+2 -2
View File
@@ -891,8 +891,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
allowCustom = v == "true"
}
isAdmin := false
if claims := apimw.GetClaims(r.Context()); claims != nil {
isAdmin = claims.Role == "admin"
if h.UserRepo != nil {
isAdmin = isAdminRequest(r, h.UserRepo)
}
for _, o := range req.Overrides {
+4 -4
View File
@@ -452,7 +452,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Authenticate as a non-admin profile.
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -471,7 +471,7 @@ func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -495,7 +495,7 @@ func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -515,7 +515,7 @@ func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
+1 -1
View File
@@ -118,7 +118,7 @@ func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest)
if command.Reason == "" {
command.Reason = "server_restart_requested"
}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
return command, 0, nil, nil
})
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
router := chi.NewRouter()
router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
})
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -26,6 +27,9 @@ type SubtitleAIHandler struct {
// StoreProvider resolves household profiles for the transcription quota
// exemption check; when nil the whole admin account is exempt.
StoreProvider userstore.UserStoreProvider
// Users loads the account server-side for the quota exemption's admin
// check; admin status is never read from token contents.
Users auth.UserLoader
}
// NewSubtitleAIHandler creates a handler backed by the given service.
@@ -154,7 +158,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
// budgeting gate, not a security boundary; the quota itself is account-scoped.
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
ctx := r.Context()
if !apimw.IsAdmin(ctx) {
if !isAdminRequest(r, h.Users) {
return false
}
profileID := apimw.GetProfileID(ctx)
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"strings"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/go-chi/chi/v5"
)
@@ -63,6 +64,7 @@ type SubtitleSearchHandler struct {
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
FileAuthorizer *MediaFileAuthorizer
Users auth.UserLoader // for server-side admin checks
}
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
@@ -334,8 +336,8 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
}
claims := apimw.GetClaims(r.Context())
isAdmin := claims != nil && claims.Role == "admin"
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
isAdmin := !isOwner && isAdminRequest(r, h.Users)
if !isAdmin && !isOwner {
writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle")
return
@@ -56,7 +56,6 @@ func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
req := httptest.NewRequest(method, path, body)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
+28 -23
View File
@@ -19,6 +19,9 @@ type contextKey string
// claimsKey is the context key for storing JWT claims.
const claimsKey contextKey = "claims"
// userKey is the context key for the loaded authenticated user.
const userKey contextKey = "user"
// SessionValidator checks whether a session is still valid (not revoked/expired).
type SessionValidator interface {
IsValid(ctx context.Context, sessionID string) (bool, error)
@@ -35,28 +38,25 @@ type APIKeyValidator interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyUserLoader loads a user by ID for API key authentication.
type APIKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
// session validity caching.
type AuthMiddleware struct {
tokenValidator TokenValidator
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader auth.UserLoader // nil if API keys not configured
userLoader auth.UserLoader // nil if no user store; RequireAdmin then denies
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul auth.UserLoader, ul auth.UserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
userLoader: ul,
}
}
@@ -105,7 +105,6 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
claims = &auth.Claims{
UserID: user.ID,
Role: user.Role,
SessionID: "",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKey.ID,
@@ -144,23 +143,28 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
})
}
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
// user has the "admin" role. It expects RequireAuth to have already placed
// claims in the request context.
func RequireAdmin(next http.Handler) http.Handler {
// RequireAdmin enforces that the authenticated user currently holds the
// admin permission. The check is server-side against group-derived policy —
// never against token contents — so revoking admin takes effect on the next
// request. The loaded user is stashed in the context for handlers.
func (am *AuthMiddleware) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
if am.userLoader == nil {
writeForbidden(w, "Admin access required")
return
}
next.ServeHTTP(w, r)
user, err := am.userLoader.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled || !user.IsAdmin {
writeForbidden(w, "Admin access required")
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -181,12 +185,13 @@ func GetClaims(ctx context.Context) *auth.Claims {
return claims
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
// GetUser returns the user loaded by RequireAdmin, or nil.
func GetUser(ctx context.Context) *models.User {
user, ok := ctx.Value(userKey).(*models.User)
if !ok {
return nil
}
return user
}
// GetUserID retrieves the user ID from the JWT claims in the context.
+125
View File
@@ -0,0 +1,125 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeAdminUserLoader implements auth.UserLoader for unit tests.
type fakeAdminUserLoader struct {
user *models.User
err error
}
func (f *fakeAdminUserLoader) GetByID(_ context.Context, _ int) (*models.User, error) {
return f.user, f.err
}
// newTestAuthMiddleware returns an AuthMiddleware wired only with a userLoader;
// the JWT/session fields are unused by RequireAdmin.
func newTestAuthMiddleware(ul auth.UserLoader) *AuthMiddleware {
return NewAuthMiddleware(nil, nil, nil, nil, ul)
}
// adminClaims injects a minimal Claims into the given request context.
func adminClaims(r *http.Request, userID int) *http.Request {
ctx := SetClaims(r.Context(), &auth.Claims{UserID: userID, TokenType: auth.TokenTypeAccess})
return r.WithContext(ctx)
}
func TestRequireAdmin(t *testing.T) {
tests := []struct {
name string
setupReq func(*http.Request) *http.Request
loader auth.UserLoader
wantStatus int
checkUser bool // if true, assert GetUser is non-nil inside the next handler
}{
{
name: "no claims in context returns 401",
setupReq: func(r *http.Request) *http.Request { return r }, // no claims injected
loader: &fakeAdminUserLoader{user: &models.User{ID: 1, Enabled: true, IsAdmin: true}},
wantStatus: http.StatusUnauthorized,
},
{
name: "nil user loader returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: nil,
wantStatus: http.StatusForbidden,
},
{
name: "loader returns error returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: &fakeAdminUserLoader{err: errors.New("db error")},
wantStatus: http.StatusForbidden,
},
{
name: "loader returns nil user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: &fakeAdminUserLoader{user: nil, err: nil},
wantStatus: http.StatusForbidden,
},
{
name: "disabled admin user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 2) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 2, Enabled: false, IsAdmin: true},
},
wantStatus: http.StatusForbidden,
},
{
name: "enabled non-admin user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 3) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 3, Enabled: true, IsAdmin: false},
},
wantStatus: http.StatusForbidden,
},
{
name: "enabled admin reaches next handler and user is stashed in context",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 4) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 4, Enabled: true, IsAdmin: true},
},
wantStatus: http.StatusNoContent,
checkUser: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
am := newTestAuthMiddleware(tc.loader)
var gotUser *models.User
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser = GetUser(r.Context())
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/admin/something", nil)
req = tc.setupReq(req)
rec := httptest.NewRecorder()
am.RequireAdmin(next).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if tc.checkUser {
if gotUser == nil {
t.Fatal("GetUser returned nil inside next handler, want a non-nil user")
}
if gotUser.ID != tc.loader.(*fakeAdminUserLoader).user.ID {
t.Fatalf("stashed user ID = %d, want %d", gotUser.ID, tc.loader.(*fakeAdminUserLoader).user.ID)
}
}
})
}
}
+44 -12
View File
@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/auth"
)
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
@@ -20,11 +22,12 @@ type DemoSettingsReader interface {
// Blocked: API key management, downloads, history imports, subtitle downloads.
type DemoGuard struct {
settings DemoSettingsReader
users auth.UserLoader // nil means no admin bypass
}
// NewDemoGuard creates a new DemoGuard.
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
return &DemoGuard{settings: settings}
func NewDemoGuard(settings DemoSettingsReader, users auth.UserLoader) *DemoGuard {
return &DemoGuard{settings: settings, users: users}
}
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
@@ -59,14 +62,8 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
return
}
// Admins bypass all demo restrictions.
claims := GetClaims(r.Context())
if claims != nil && claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
// Check if this request matches a blocked route.
blocked := false
path := r.URL.Path
for _, br := range demoBlockedRoutes {
if !strings.HasPrefix(path, br.prefix) {
@@ -74,16 +71,51 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
}
for _, m := range br.methods {
if r.Method == m {
writeDemoBlocked(w)
return
blocked = true
break
}
}
if blocked {
break
}
}
if !blocked {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
// Admins bypass all demo restrictions. Admin status is checked against
// the loaded user (group-derived), never against token contents.
if dg.isAdmin(r) {
next.ServeHTTP(w, r)
return
}
writeDemoBlocked(w)
})
}
// isAdmin reports whether the request's authenticated user currently holds
// the admin permission. It first consults the user stashed in the context by
// RequireAdmin and falls back to a fresh load.
func (dg *DemoGuard) isAdmin(r *http.Request) bool {
if user := GetUser(r.Context()); user != nil {
return user.Enabled && user.IsAdmin
}
if dg.users == nil {
return false
}
claims := GetClaims(r.Context())
if claims == nil {
return false
}
user, err := dg.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil {
return false
}
return user.Enabled && user.IsAdmin
}
func writeDemoBlocked(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
+11 -9
View File
@@ -40,26 +40,28 @@ func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler)
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
if m == nil || m.users == nil || m.libraries == nil {
writeForbidden(w, "Metadata curation permission required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
// Admins bypass the per-library restriction checks below.
if user.IsAdmin {
next.ServeHTTP(w, r)
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
writeForbidden(w, "Metadata curation permission required")
return
+15 -15
View File
@@ -30,16 +30,16 @@ func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Conte
return f.ids, f.err
}
func requestWithItemID(role string) *http.Request {
func requestWithItemID() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess})
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", "item-1")
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
return req.WithContext(ctx)
}
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
mw := NewPermissionMiddleware(
fakePermissionUserLoader{user: user},
fakeTargetLibraryResolver{ids: libraryIDs},
@@ -48,52 +48,52 @@ func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role str
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
next.ServeHTTP(rec, requestWithItemID(role))
next.ServeHTTP(rec, requestWithItemID())
return rec.Code
}
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
code := runMetadataCurationMiddleware(nil, nil, "admin")
code := runMetadataCurationMiddleware(&models.User{ID: 7, Enabled: true, IsAdmin: true}, nil)
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1})
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 3}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 3})
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil)
if code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
}
+6
View File
@@ -46,6 +46,12 @@ func (m *ViewerAccessMiddleware) RequireViewerAccess(next http.Handler) http.Han
scope, err := m.resolver.Resolve(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, access.ErrUserDisabled):
// Disabling a user also revokes their sessions, so RequireAuth
// normally rejects first; this covers any session that is
// still live (e.g. the account was disabled out of band).
writeUnauthorized(w, "User account is disabled")
return
case errors.Is(err, access.ErrProfileUnverified):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
+51 -21
View File
@@ -277,7 +277,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo)
authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo, userRepo)
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
if deps.UserStoreProvider != nil {
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
@@ -306,7 +306,11 @@ func NewRouter(deps Dependencies) chi.Router {
// Build demo guard middleware if server settings are available.
var demoGuard *apimw.DemoGuard
if settingsRepo != nil {
demoGuard = apimw.NewDemoGuard(settingsRepo)
var demoUsers auth.UserLoader
if userRepo != nil {
demoUsers = userRepo
}
demoGuard = apimw.NewDemoGuard(settingsRepo, demoUsers)
}
// Build library handler if folder repo is available.
@@ -479,6 +483,9 @@ func NewRouter(deps Dependencies) chi.Router {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
}
requestHandler = handlers.NewRequestsHandler(requestSvc)
if userRepo != nil {
requestHandler.Users = userRepo
}
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil {
@@ -739,8 +746,12 @@ func NewRouter(deps Dependencies) chi.Router {
// Build admin handler if we have a user repo.
var adminHandler *handlers.AdminHandler
var adminGroupsHandler *handlers.AdminGroupsHandler
var catalogSeedHandler *handlers.CatalogSeedHandler
var adminJobsHandler *handlers.AdminJobsHandler
if deps.DB != nil {
adminGroupsHandler = handlers.NewAdminGroupsHandler(auth.NewGroupRepository(deps.DB))
}
if userRepo != nil {
adminHandler = handlers.NewAdminHandler(userRepo, deps.DB, deps.UserStoreProvider)
adminHandler.SessionsLoader = playbackSessionsLoader
@@ -774,6 +785,9 @@ func NewRouter(deps Dependencies) chi.Router {
adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore)
adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry
adminJobsHandler.RealtimeHub = deps.RealtimeHub
if userRepo != nil {
adminJobsHandler.Users = userRepo
}
if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil {
adminHandler.JobRepo = jobRepo
adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver(
@@ -903,6 +917,9 @@ func NewRouter(deps Dependencies) chi.Router {
mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB}
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
if userRepo != nil {
subtitleSearchHandler.Users = userRepo
}
}
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
@@ -977,6 +994,7 @@ func NewRouter(deps Dependencies) chi.Router {
aiService.Recover()
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
subtitleAIHandler.Users = userRepo
}
// Metadata AI translation (descriptions into the localization tables).
@@ -1313,7 +1331,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(authMiddleware.RequireAuth)
r.Get("/theme/catalog", themeHandler.HandleCatalog)
r.Get("/theme/download", themeHandler.HandleDownload)
r.With(apimw.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
r.With(authMiddleware.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
})
}
}
@@ -1340,7 +1358,7 @@ func NewRouter(deps Dependencies) chi.Router {
http.NotFound(w, r)
return
}
authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo)
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo)
deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath)
})
}
@@ -1471,6 +1489,7 @@ func NewRouter(deps Dependencies) chi.Router {
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
userRepo,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
@@ -1493,7 +1512,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Library management routes (admin-only).
if libraryHandler != nil {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
r.Use(authMiddleware.RequireAdmin)
r.Route("/libraries", func(r chi.Router) {
r.Get("/", libraryHandler.HandleListLibraries)
@@ -2005,7 +2024,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Admin routes.
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
metadataItemAccess := apimw.RequireAdmin
metadataItemAccess := authMiddleware.RequireAdmin
if permissionMiddleware != nil {
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
}
@@ -2032,7 +2051,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
r.Use(authMiddleware.RequireAdmin)
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
@@ -2054,6 +2073,17 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/devices", adminHandler.HandleListDevices)
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
if adminGroupsHandler != nil {
r.Get("/groups", adminGroupsHandler.HandleList)
r.Post("/groups", adminGroupsHandler.HandleCreate)
r.Get("/groups/{id}", adminGroupsHandler.HandleGet)
r.Patch("/groups/{id}", adminGroupsHandler.HandleUpdate)
r.Delete("/groups/{id}", adminGroupsHandler.HandleDelete)
r.Get("/groups/{id}/members", adminGroupsHandler.HandleListMembers)
r.Put("/groups/{id}/members/{userID}", adminGroupsHandler.HandleAddMember)
r.Delete("/groups/{id}/members/{userID}", adminGroupsHandler.HandleRemoveMember)
}
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
@@ -2453,18 +2483,11 @@ func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context,
return &meta, nil
}
func resolveOptionalPluginAccess(
r *http.Request,
jwtService *auth.JWTService,
sessionRepo *auth.SessionRepository,
) (bool, bool) {
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, nil, nil)
return authenticated, admin
}
// resolveOptionalPluginAccessUser is like resolveOptionalPluginAccess but also
// returns the authenticated user's ID, and accepts API-key bearer tokens
// (sa_*) when apiKeyRepo + userRepo are provided.
// resolveOptionalPluginAccessUser resolves the optionally authenticated user
// for plugin HTTP routes and assets. It accepts API-key bearer tokens (sa_*)
// when apiKeyRepo + userRepo are provided. Admin status is always derived from
// the freshly loaded user, never from token contents; when userRepo is nil the
// caller is treated as non-admin.
func resolveOptionalPluginAccessUser(
r *http.Request,
jwtService *auth.JWTService,
@@ -2507,7 +2530,7 @@ func resolveOptionalPluginAccessUser(
if err != nil || !user.Enabled {
return false, false, 0
}
return true, user.Role == "admin", user.ID
return true, user.IsAdmin, user.ID
}
claims, err := jwtService.ValidateToken(token)
@@ -2518,7 +2541,14 @@ func resolveOptionalPluginAccessUser(
if err != nil || !valid {
return false, false, 0
}
return true, claims.Role == "admin", claims.UserID
if userRepo == nil {
return true, false, claims.UserID
}
user, err := userRepo.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
return false, false, 0
}
return true, user.IsAdmin, user.ID
}
// NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key.
+68
View File
@@ -2,18 +2,30 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// DefaultGroupSlugsSettingKey is the server setting holding the JSON array of
// group slugs assigned to newly provisioned accounts.
const DefaultGroupSlugsSettingKey = "users.default_group_slugs"
type AccountUserRepository interface {
Create(ctx context.Context, input models.CreateUserInput) (*models.User, error)
Delete(ctx context.Context, id int) error
}
// GroupResolver resolves group slugs to groups. Implemented by GroupRepository.
type GroupResolver interface {
GetBySlug(ctx context.Context, slug string) (*models.Group, error)
}
type DefaultProfileOptions struct {
Enabled bool
Name string
@@ -27,15 +39,21 @@ type CreateAccountInput struct {
type AccountProvisioner struct {
users AccountUserRepository
storeProvider userstore.UserStoreProvider
settings SettingsGetter
groups GroupResolver
}
func NewAccountProvisioner(
users AccountUserRepository,
storeProvider userstore.UserStoreProvider,
settings SettingsGetter,
groups GroupResolver,
) *AccountProvisioner {
return &AccountProvisioner{
users: users,
storeProvider: storeProvider,
settings: settings,
groups: groups,
}
}
@@ -43,6 +61,14 @@ func (p *AccountProvisioner) CreateAccount(
ctx context.Context,
input CreateAccountInput,
) (*models.User, error) {
if input.User.GroupIDs == nil && p.groups != nil {
ids, err := p.defaultGroupIDs(ctx)
if err != nil {
return nil, err
}
input.User.GroupIDs = ids
}
user, err := p.users.Create(ctx, input.User)
if err != nil {
return nil, err
@@ -66,6 +92,48 @@ func (p *AccountProvisioner) CreateAccount(
return user, nil
}
// defaultGroupIDs resolves the configured default group slugs (falling back to
// the built-in users group) to group IDs. Unknown slugs are skipped so a stale
// setting never blocks signups; if no configured slug resolves, the built-in
// users group is used so accounts never start with an empty policy.
func (p *AccountProvisioner) defaultGroupIDs(ctx context.Context) ([]int, error) {
slugs := []string{models.GroupSlugUsers}
if p.settings != nil {
raw, err := p.settings.Get(ctx, DefaultGroupSlugsSettingKey)
if err != nil {
slog.Warn("reading default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", err)
} else if strings.TrimSpace(raw) != "" {
var configured []string
if jsonErr := json.Unmarshal([]byte(raw), &configured); jsonErr != nil {
slog.Warn("parsing default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", jsonErr)
} else if len(configured) > 0 {
slugs = configured
}
}
}
ids := make([]int, 0, len(slugs))
for _, slug := range slugs {
group, err := p.groups.GetBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
// A stale setting must not block signups.
slog.Warn("skipping unknown default group slug", "slug", slug)
continue
}
return nil, fmt.Errorf("resolving default group %q: %w", slug, err)
}
ids = append(ids, group.ID)
}
if len(ids) == 0 {
group, err := p.groups.GetBySlug(ctx, models.GroupSlugUsers)
if err != nil {
return nil, fmt.Errorf("resolving fallback group %q: %w", models.GroupSlugUsers, err)
}
ids = append(ids, group.ID)
}
return ids, nil
}
func (p *AccountProvisioner) createDefaultProfile(
ctx context.Context,
userID int,
+181
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -58,6 +59,8 @@ func TestAccountProvisionerCreateAccount_SkipsProfileByDefault(t *testing.T) {
},
},
nil,
nil,
nil,
)
user, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -90,6 +93,8 @@ func TestAccountProvisionerCreateAccount_CreatesDefaultProfile(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -125,6 +130,8 @@ func TestAccountProvisionerCreateAccount_UsesExplicitProfileName(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -158,6 +165,8 @@ func TestAccountProvisionerCreateAccount_BlankExplicitProfileNameFallsBackToUser
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -194,6 +203,8 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -209,3 +220,173 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
t.Fatalf("deletedUserID = %d, want 9", deletedUserID)
}
}
type stubGroupResolver struct {
groups map[string]*models.Group
}
func (s stubGroupResolver) GetBySlug(_ context.Context, slug string) (*models.Group, error) {
if group, ok := s.groups[slug]; ok {
return group, nil
}
return nil, ErrGroupNotFound
}
type stubSettings map[string]string
func (s stubSettings) Get(_ context.Context, key string) (string, error) {
return s[key], nil
}
func TestAccountProvisionerCreateAccount_NilGroupIDsResolvesConfiguredDefaults(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family","kids"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
"kids": {ID: 12, Slug: "kids"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
want := []int{11, 12}
if !reflect.DeepEqual(gotInput.GroupIDs, want) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, want)
}
}
func TestAccountProvisionerCreateAccount_FallsBackToBuiltInUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
nil, // no settings configured
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_ExplicitGroupIDsPassThrough(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
explicit := []int{99}
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex", GroupIDs: explicit},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, explicit) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, explicit)
}
}
func TestAccountProvisionerCreateAccount_SkipsUnknownDefaultSlugs(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{11}) {
t.Fatalf("GroupIDs = %#v, want [11]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_AllUnknownSlugsFallBackToUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","phantom"]`},
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2] (built-in users fallback)", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_FallbackGroupMissingReturnsError(t *testing.T) {
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(context.Context, models.CreateUserInput) (*models.User, error) {
t.Fatal("Create should not be called when no default group resolves")
return nil, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost"]`},
stubGroupResolver{groups: map[string]*models.Group{}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err == nil {
t.Fatal("CreateAccount returned nil error, want fallback resolution failure")
}
}
-1
View File
@@ -389,7 +389,6 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"sort"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
// ApplyEffectivePolicy populates u's policy fields with the most-permissive
// union of the given groups. Groups are the only source of policy:
//
// - permissions: set union; "admin" implies everything
// - libraries: union; any nil (= all) or admin makes it unrestricted
// - limits: 0 means unlimited at enforcement, so any group with 0
// wins; otherwise max across groups
// - quality: most permissive; "" (unrestricted) wins
// - booleans: OR
//
// Zero groups yields the empty policy: no permissions, an empty non-nil
// library list, downloads denied. Lockout is enforced by the empty library
// list (access to no content), which makes the stream/transcode limits
// irrelevant; they stay at 0. MaxProfiles is the exception: 0 means
// unlimited at the profile-creation check, which is independent of library
// access, so it is floored at 1 to keep zero-group accounts from creating
// profiles freely. The quality ceiling is moot with no accessible content
// and is left at the lowest concrete value.
func ApplyEffectivePolicy(u *models.User, groups []models.Group) {
u.GroupIDs = make([]int, 0, len(groups))
u.IsAdmin = false
u.Permissions = []string{}
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles = 0, 0, 0
u.DownloadAllowed, u.DownloadTranscodeAllowed = false, false
u.MaxPlaybackQuality = "480p"
u.LibraryIDs = []int{}
if len(groups) == 0 {
u.MaxProfiles = 1
return
}
permSet := make(map[string]struct{})
libSet := make(map[int]struct{})
allLibraries := false
for i, g := range groups {
u.GroupIDs = append(u.GroupIDs, g.ID)
for _, p := range g.Permissions {
permSet[p] = struct{}{}
}
if g.LibraryIDs == nil {
allLibraries = true
} else {
for _, id := range g.LibraryIDs {
libSet[id] = struct{}{}
}
}
if i == 0 {
u.MaxStreams = g.MaxStreams
u.MaxTranscodes = g.MaxTranscodes
u.MaxProfiles = g.MaxProfiles
u.MaxPlaybackQuality = g.MaxPlaybackQuality
} else {
u.MaxStreams = mergeLimit(u.MaxStreams, g.MaxStreams)
u.MaxTranscodes = mergeLimit(u.MaxTranscodes, g.MaxTranscodes)
u.MaxProfiles = mergeLimit(u.MaxProfiles, g.MaxProfiles)
u.MaxPlaybackQuality = access.MaxQuality(u.MaxPlaybackQuality, g.MaxPlaybackQuality)
}
u.DownloadAllowed = u.DownloadAllowed || g.DownloadAllowed
u.DownloadTranscodeAllowed = u.DownloadTranscodeAllowed || g.DownloadTranscodeAllowed
}
// Admin implies unrestricted libraries, quality, and downloads. Stream,
// transcode, and profile limits intentionally keep the merged group
// values: before groups, admins were subject to their per-user limits
// (default 6 streams) with no bypass, and the playback session manager
// still enforces whatever it is handed.
if _, ok := permSet[string(PermissionAdmin)]; ok {
u.IsAdmin = true
allLibraries = true
u.MaxPlaybackQuality = ""
u.DownloadAllowed = true
}
u.Permissions = make([]string, 0, len(permSet))
for p := range permSet {
u.Permissions = append(u.Permissions, p)
}
sort.Strings(u.Permissions)
if allLibraries {
u.LibraryIDs = nil
} else {
u.LibraryIDs = make([]int, 0, len(libSet))
for id := range libSet {
u.LibraryIDs = append(u.LibraryIDs, id)
}
sort.Ints(u.LibraryIDs)
}
}
// mergeLimit folds group limits where 0 means unlimited at enforcement
// (max_streams, max_transcodes, max_profiles), so any unlimited group wins
// the most-permissive union; otherwise the higher cap wins.
func mergeLimit(current, next int) int {
if current == 0 || next == 0 {
return 0
}
if next > current {
return next
}
return current
}
+158
View File
@@ -0,0 +1,158 @@
package auth
import (
"reflect"
"sort"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestApplyEffectivePolicyZeroGroups(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, nil)
if u.IsAdmin {
t.Error("zero groups must not grant admin")
}
if len(u.Permissions) != 0 {
t.Errorf("permissions = %v, want empty", u.Permissions)
}
if u.LibraryIDs == nil || len(u.LibraryIDs) != 0 {
t.Errorf("library ids = %v, want empty non-nil (access to none)", u.LibraryIDs)
}
// Lockout comes from the empty library list; stream/transcode limits are
// irrelevant and stay 0. MaxProfiles is floored at 1 because 0 means
// unlimited at the profile-creation check.
if u.MaxStreams != 0 || u.MaxTranscodes != 0 {
t.Errorf("stream limits = %d/%d, want 0/0", u.MaxStreams, u.MaxTranscodes)
}
if u.MaxProfiles != 1 {
t.Errorf("max profiles = %d, want 1 (0 would mean unlimited)", u.MaxProfiles)
}
if u.DownloadAllowed || u.DownloadTranscodeAllowed {
t.Error("downloads must be denied for zero groups")
}
}
func TestApplyEffectivePolicyUnion(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, []models.Group{
{ID: 10, Permissions: []string{"marker_edit"}, LibraryIDs: []int{1, 2},
MaxStreams: 2, MaxTranscodes: 1, MaxProfiles: 3,
MaxPlaybackQuality: "1080p", DownloadAllowed: false},
{ID: 20, Permissions: []string{"metadata_curation"}, LibraryIDs: []int{2, 3},
MaxStreams: 4, MaxTranscodes: 2, MaxProfiles: 1,
MaxPlaybackQuality: "2160p", DownloadAllowed: true},
})
wantPerms := []string{"marker_edit", "metadata_curation"}
sort.Strings(u.Permissions)
if !reflect.DeepEqual(u.Permissions, wantPerms) {
t.Errorf("permissions = %v, want %v", u.Permissions, wantPerms)
}
wantLibs := []int{1, 2, 3}
sort.Ints(u.LibraryIDs)
if !reflect.DeepEqual(u.LibraryIDs, wantLibs) {
t.Errorf("libraries = %v, want %v", u.LibraryIDs, wantLibs)
}
if u.MaxStreams != 4 || u.MaxTranscodes != 2 || u.MaxProfiles != 3 {
t.Errorf("limits = %d/%d/%d, want 4/2/3", u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
if u.MaxPlaybackQuality != "2160p" {
t.Errorf("quality = %q, want 2160p", u.MaxPlaybackQuality)
}
if !u.DownloadAllowed {
t.Error("downloads must be OR-ed")
}
wantGroups := []int{10, 20}
sort.Ints(u.GroupIDs)
if !reflect.DeepEqual(u.GroupIDs, wantGroups) {
t.Errorf("group ids = %v, want %v", u.GroupIDs, wantGroups)
}
if u.IsAdmin {
t.Error("no group granted admin")
}
}
func TestApplyEffectivePolicyUnlimitedLimitWins(t *testing.T) {
// 0 means unlimited at enforcement for all three limits, so a group with
// 0 must win the most-permissive union over any finite cap.
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
// Order must not matter: unlimited in the first group sticks.
u = &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins regardless of order)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyFiniteLimitsTakeMax(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 2, MaxProfiles: 4},
{ID: 2, MaxStreams: 2, MaxTranscodes: 5, MaxProfiles: 1},
})
if u.MaxStreams != 5 || u.MaxTranscodes != 5 || u.MaxProfiles != 4 {
t.Errorf("limits = %d/%d/%d, want 5/5/4 (max of finite caps)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyNilLibrariesMeansAll(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, LibraryIDs: []int{1}},
{ID: 2, LibraryIDs: nil}, // all libraries
})
if u.LibraryIDs != nil {
t.Errorf("library ids = %v, want nil (unrestricted)", u.LibraryIDs)
}
}
func TestApplyEffectivePolicyAdminShortCircuit(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, Permissions: []string{"admin"}, LibraryIDs: []int{1},
MaxStreams: 1, MaxPlaybackQuality: "480p"},
})
if !u.IsAdmin {
t.Fatal("admin permission must set IsAdmin")
}
if u.LibraryIDs != nil {
t.Error("admin must be library-unrestricted")
}
if u.MaxPlaybackQuality != "" {
t.Error("admin must be quality-unrestricted")
}
// Admin does not bypass stream/transcode/profile limits (matching the
// pre-group behavior where admins were subject to per-user limits); the
// merged group values stand.
if u.MaxStreams != 1 {
t.Errorf("max streams = %d, want 1 (admin keeps merged group limits)", u.MaxStreams)
}
}
func TestApplyEffectivePolicyQualityUnrestrictedWins(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxPlaybackQuality: "1080p"},
{ID: 2, MaxPlaybackQuality: ""}, // unrestricted
})
if u.MaxPlaybackQuality != "" {
t.Errorf("quality = %q, want unrestricted", u.MaxPlaybackQuality)
}
}
+604
View File
@@ -0,0 +1,604 @@
package auth
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/text/unicode/norm"
"github.com/Silo-Server/silo-server/internal/models"
)
// Sentinel errors for group operations.
var (
ErrGroupNotFound = errors.New("group not found")
ErrBuiltInGroup = errors.New("built-in group cannot be deleted")
ErrAdminPermRequired = errors.New("administrators group must keep the admin permission")
ErrLastAdministrator = errors.New("cannot remove the last enabled administrator")
ErrUnknownGroup = errors.New("unknown group")
ErrUnknownUser = errors.New("unknown user")
)
// GroupRepository provides CRUD and membership operations for groups.
// All mutations bump access_policy_revision for affected users set-based so
// the access resolver invalidates stale scopes.
type GroupRepository struct {
pool *pgxpool.Pool
}
func NewGroupRepository(pool *pgxpool.Pool) *GroupRepository {
return &GroupRepository{pool: pool}
}
// groupColumns is table-qualified so it stays unambiguous in queries that
// join user_groups (which also has a created_at column). Qualified names are
// valid in plain SELECTs and in INSERT/UPDATE ... RETURNING alike.
const groupColumns = `groups.id, groups.slug, groups.name, groups.description,
groups.built_in, groups.permissions, groups.library_ids, groups.max_streams,
groups.max_transcodes, groups.max_profiles, groups.max_playback_quality,
groups.download_allowed, groups.download_transcode_allowed,
groups.created_at, groups.updated_at`
func scanGroupFields(row pgx.Row, g *models.Group, extra ...any) error {
dest := []any{
&g.ID, &g.Slug, &g.Name, &g.Description, &g.BuiltIn, &g.Permissions,
&g.LibraryIDs, &g.MaxStreams, &g.MaxTranscodes, &g.MaxProfiles,
&g.MaxPlaybackQuality, &g.DownloadAllowed, &g.DownloadTranscodeAllowed,
&g.CreatedAt, &g.UpdatedAt,
}
dest = append(dest, extra...)
return row.Scan(dest...)
}
func scanGroup(row pgx.Row) (*models.Group, error) {
var g models.Group
if err := scanGroupFields(row, &g); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGroupNotFound
}
return nil, fmt.Errorf("scanning group: %w", err)
}
return &g, nil
}
var slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
// slugifyGroupName derives a stable slug from a display name.
// Accented characters are decomposed via NFD and their combining marks are
// stripped so that e.g. "ï" → "i" before the ASCII-only filter is applied.
func slugifyGroupName(name string) string {
// NFD-decompose so accented letters split into base + combining mark.
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(name)))
// Drop combining (diacritic) runes; keep everything else for the regex.
var b strings.Builder
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
b.WriteRune(r)
}
s := slugStripRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
// GroupWithMemberCount pairs a group with its member count for list views.
type GroupWithMemberCount struct {
models.Group
MemberCount int
}
// List returns all groups with member counts, built-ins first.
func (r *GroupRepository) List(ctx context.Context) ([]GroupWithMemberCount, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`,
(SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = groups.id) AS member_count
FROM groups
ORDER BY built_in DESC, name ASC`)
if err != nil {
return nil, fmt.Errorf("listing groups: %w", err)
}
defer rows.Close()
var out []GroupWithMemberCount
for rows.Next() {
var g GroupWithMemberCount
if err := scanGroupFields(rows, &g.Group, &g.MemberCount); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
func (r *GroupRepository) GetByID(ctx context.Context, id int) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1`, id))
}
func (r *GroupRepository) GetBySlug(ctx context.Context, slug string) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE slug = $1`, slug))
}
// GroupsForUser returns the groups a user belongs to.
func (r *GroupRepository) GroupsForUser(ctx context.Context, userID int) ([]models.Group, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = $1
ORDER BY groups.id`, userID)
if err != nil {
return nil, fmt.Errorf("loading user groups: %w", err)
}
defer rows.Close()
var out []models.Group
for rows.Next() {
var g models.Group
if err := scanGroupFields(rows, &g); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GroupsForUsers returns group memberships for many users in one query.
func (r *GroupRepository) GroupsForUsers(ctx context.Context, userIDs []int) (map[int][]models.Group, error) {
out := make(map[int][]models.Group, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`, ug.user_id
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = ANY($1::int[])
ORDER BY groups.id`, userIDs)
if err != nil {
return nil, fmt.Errorf("loading users' groups: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int
var g models.Group
if err := scanGroupFields(rows, &g, &userID); err != nil {
return nil, fmt.Errorf("scanning user group row: %w", err)
}
out[userID] = append(out[userID], g)
}
return out, rows.Err()
}
// Create inserts a new (non-built-in) group.
func (r *GroupRepository) Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error) {
permissions, err := NormalizePermissions(input.Permissions)
if err != nil {
return nil, err
}
slug := slugifyGroupName(input.Name)
if slug == "" {
return nil, fmt.Errorf("group name must contain at least one alphanumeric character")
}
cols := []string{"slug", "name", "description", "permissions", "library_ids"}
args := []any{slug, strings.TrimSpace(input.Name), input.Description, permissions, input.LibraryIDs}
type optionalCol struct {
col string
val any
set bool
}
optional := []optionalCol{
{"max_streams", derefAny(input.MaxStreams), input.MaxStreams != nil},
{"max_transcodes", derefAny(input.MaxTranscodes), input.MaxTranscodes != nil},
{"max_profiles", derefAny(input.MaxProfiles), input.MaxProfiles != nil},
{"max_playback_quality", derefAny(input.MaxPlaybackQuality), input.MaxPlaybackQuality != nil},
{"download_allowed", derefAny(input.DownloadAllowed), input.DownloadAllowed != nil},
{"download_transcode_allowed", derefAny(input.DownloadTranscodeAllowed), input.DownloadTranscodeAllowed != nil},
}
for _, o := range optional {
if o.set {
cols = append(cols, o.col)
args = append(args, o.val)
}
}
placeholders := make([]string, len(args))
for i := range args {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO groups (%s) VALUES (%s) RETURNING %s",
strings.Join(cols, ", "), strings.Join(placeholders, ", "), groupColumns)
group, err := scanGroup(r.pool.QueryRow(ctx, query, args...))
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
}
return nil, fmt.Errorf("creating group: %w", err)
}
return group, nil
}
func derefAny[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
// Update modifies a group and bumps all members' access_policy_revision when
// a policy field changes. The administrators group cannot lose the admin
// permission.
func (r *GroupRepository) Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning group update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Lock the group row: serializes concurrent policy/membership mutations.
current, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return nil, err
}
var normalizedPerms []string
if input.Permissions != nil {
var err error
normalizedPerms, err = NormalizePermissions(*input.Permissions)
if err != nil {
return nil, err
}
if current.Slug == models.GroupSlugAdministrators {
hasAdmin := false
for _, p := range normalizedPerms {
if p == string(PermissionAdmin) {
hasAdmin = true
break
}
}
if !hasAdmin {
return nil, ErrAdminPermRequired
}
}
}
setClauses := []string{}
policyChanged := false
args := []any{}
idx := 1
add := func(col string, val any, policy bool) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
if policy {
policyChanged = true
}
}
if input.Name != nil {
add("name", strings.TrimSpace(*input.Name), false)
}
if input.Description != nil {
add("description", *input.Description, false)
}
if input.Permissions != nil {
add("permissions", normalizedPerms, true)
}
if input.LibraryIDs != nil {
add("library_ids", *input.LibraryIDs, true)
}
if input.MaxStreams != nil {
add("max_streams", *input.MaxStreams, true)
}
if input.MaxTranscodes != nil {
add("max_transcodes", *input.MaxTranscodes, true)
}
if input.MaxProfiles != nil {
add("max_profiles", *input.MaxProfiles, true)
}
if input.MaxPlaybackQuality != nil {
add("max_playback_quality", *input.MaxPlaybackQuality, true)
}
if input.DownloadAllowed != nil {
add("download_allowed", *input.DownloadAllowed, true)
}
if input.DownloadTranscodeAllowed != nil {
add("download_transcode_allowed", *input.DownloadTranscodeAllowed, true)
}
if len(setClauses) == 0 {
return current, nil
}
setClauses = append(setClauses, "updated_at = NOW()")
args = append(args, id)
query := fmt.Sprintf("UPDATE groups SET %s WHERE id = $%d RETURNING %s",
strings.Join(setClauses, ", "), idx, groupColumns)
updated, err := scanGroup(tx.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("updating group: %w", err)
}
if policyChanged {
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing group update: %w", err)
}
return updated, nil
}
// bumpGroupMemberRevisions invalidates all members' resolved scopes in one
// set-based statement (never loop per user — groups can have thousands of
// members).
func bumpGroupMemberRevisions(ctx context.Context, tx pgx.Tx, groupID int) error {
_, err := tx.Exec(ctx, `
UPDATE users u
SET access_policy_revision = access_policy_revision + 1
FROM user_groups ug
WHERE ug.user_id = u.id AND ug.group_id = $1`, groupID)
if err != nil {
return fmt.Errorf("bumping member policy revisions: %w", err)
}
return nil
}
// Delete removes a non-built-in group. Member revisions are bumped before the
// memberships cascade away.
func (r *GroupRepository) Delete(ctx context.Context, id int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group delete: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return err
}
if group.BuiltIn {
return ErrBuiltInGroup
}
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM groups WHERE id = $1`, id); err != nil {
return fmt.Errorf("deleting group: %w", err)
}
return tx.Commit(ctx)
}
// MemberCount returns the number of users in a group.
func (r *GroupRepository) MemberCount(ctx context.Context, groupID int) (int, error) {
var count int
if err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM user_groups WHERE group_id = $1`, groupID).Scan(&count); err != nil {
return 0, fmt.Errorf("counting group members: %w", err)
}
return count, nil
}
// GroupMember is one row of a paginated member listing.
type GroupMember struct {
UserID int
Username string
Email string
Enabled bool
}
// ListMembers returns one page of a group's members plus the total count.
func (r *GroupRepository) ListMembers(ctx context.Context, groupID, offset, limit int) ([]GroupMember, int, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
total, err := r.MemberCount(ctx, groupID)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT u.id, u.username, u.email, u.enabled
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1
ORDER BY u.username
OFFSET $2 LIMIT $3`, groupID, offset, limit)
if err != nil {
return nil, 0, fmt.Errorf("listing members: %w", err)
}
defer rows.Close()
var members []GroupMember
for rows.Next() {
var m GroupMember
if err := rows.Scan(&m.UserID, &m.Username, &m.Email, &m.Enabled); err != nil {
return nil, 0, fmt.Errorf("scanning member: %w", err)
}
members = append(members, m)
}
return members, total, rows.Err()
}
// AddMember adds a user to a group (idempotent) and bumps their revision.
func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning add member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, userID, groupID)
if err != nil {
if isForeignKeyError(err) {
return ErrUnknownUser
}
return fmt.Errorf("adding member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// lastEnabledAdministratorGuard returns ErrLastAdministrator when userID is an
// enabled member of the administrators group and no other enabled member
// exists, i.e. when removing, disabling, or deleting userID would leave the
// server without an enabled administrator. It locks the administrators group
// row (FOR UPDATE) so concurrent membership/disable/delete mutations
// serialize; re-locking the same row later in the same transaction is a no-op.
func lastEnabledAdministratorGuard(ctx context.Context, tx pgx.Tx, excludingUserID int) error {
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
// Only an enabled administrator can be the last enabled administrator;
// mutating a non-member or disabled member never breaks the invariant.
var enabledMember bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND ug.user_id = $2 AND u.enabled)`,
adminGroupID, excludingUserID).Scan(&enabledMember); err != nil {
return fmt.Errorf("checking administrators membership: %w", err)
}
if !enabledMember {
return nil
}
var remaining int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND u.enabled AND ug.user_id <> $2`,
adminGroupID, excludingUserID).Scan(&remaining); err != nil {
return fmt.Errorf("counting administrators: %w", err)
}
if remaining == 0 {
return ErrLastAdministrator
}
return nil
}
// RemoveMember removes a user from a group. Removing the last enabled member
// of the administrators group is rejected.
func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning remove member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID))
if err != nil {
return err
}
if group.Slug == models.GroupSlugAdministrators {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
tag, err := tx.Exec(ctx,
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
return fmt.Errorf("removing member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// ReplaceUserGroups sets a user's memberships to exactly groupIDs, applying
// the last-administrator guard if administrators membership is being removed.
func (r *GroupRepository) ReplaceUserGroups(ctx context.Context, userID int, groupIDs []int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group replacement: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := r.replaceUserGroupsTx(ctx, tx, userID, groupIDs); err != nil {
return err
}
return tx.Commit(ctx)
}
// replaceUserGroupsTx performs the membership replacement inside the caller's
// transaction so it can be composed with other writes atomically.
func (r *GroupRepository) replaceUserGroupsTx(ctx context.Context, tx pgx.Tx, userID int, groupIDs []int) error {
// Lock the administrators group row so concurrent removals serialize.
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
keepingAdmin := false
for _, id := range groupIDs {
if id == adminGroupID {
keepingAdmin = true
break
}
}
if !keepingAdmin {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM user_groups WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("clearing memberships: %w", err)
}
if len(groupIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING`, userID, groupIDs); err != nil {
if isForeignKeyError(err) {
return fmt.Errorf("%w", ErrUnknownGroup)
}
return fmt.Errorf("adding memberships: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping user revision: %w", err)
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package auth
import "testing"
func TestSlugifyGroupName(t *testing.T) {
cases := []struct{ in, want string }{
{"Family", "family"},
{"Power Users", "power-users"},
{" Trimmed ", "trimmed"},
{"Ünïcode & Symbols!", "unicode-symbols"},
{"--multi---dash--", "multi-dash"},
}
for _, tc := range cases {
if got := slugifyGroupName(tc.in); got != tc.want {
t.Errorf("slugifyGroupName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+3 -7
View File
@@ -17,7 +17,6 @@ var (
// Claims represents the custom JWT claims used for authentication.
type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
SessionID string `json:"session_id"`
TokenType string `json:"token_type"`
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
@@ -63,31 +62,28 @@ func (j *JWTService) RefreshExpiry() time.Duration {
// GenerateAccessToken creates a signed JWT access token with the configured
// access token expiry duration.
func (j *JWTService) GenerateAccessToken(userID int, role, sessionID string) (string, error) {
func (j *JWTService) GenerateAccessToken(userID int, sessionID string) (string, error) {
return j.generateAccessToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
})
}
// GenerateRefreshToken creates a signed JWT refresh token with the configured
// refresh token expiry duration.
func (j *JWTService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
func (j *JWTService) GenerateRefreshToken(userID int, sessionID string) (string, error) {
return j.generateRefreshToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
})
}
func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID string, ttl time.Duration) (string, error) {
func (j *JWTService) GeneratePluginAccessToken(userID int, sessionID string, ttl time.Duration) (string, error) {
if ttl <= 0 {
ttl = 5 * time.Minute
}
return j.generateToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
}, TokenTypePluginAccess, ttl)
}
+19 -20
View File
@@ -18,7 +18,7 @@ func newTestJWTService() *auth.JWTService {
func TestJWT_GenerateAccessToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(42, "admin", "sess-abc-123")
token, err := svc.GenerateAccessToken(42, "sess-abc-123")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -36,7 +36,7 @@ func TestJWT_GenerateAccessToken(t *testing.T) {
func TestJWT_GenerateRefreshToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateRefreshToken(42, "user", "sess-def-456")
token, err := svc.GenerateRefreshToken(42, "sess-def-456")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -48,7 +48,7 @@ func TestJWT_GenerateRefreshToken(t *testing.T) {
func TestJWT_ValidateAccessToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(1, "user", "sess-uuid-1")
token, err := svc.GenerateAccessToken(1, "sess-uuid-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -61,9 +61,6 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
if claims.UserID != 1 {
t.Errorf("UserID = %d, want 1", claims.UserID)
}
if claims.Role != "user" {
t.Errorf("Role = %q, want %q", claims.Role, "user")
}
if claims.SessionID != "sess-uuid-1" {
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-1")
}
@@ -91,7 +88,7 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
func TestJWT_ValidateRefreshToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateRefreshToken(99, "admin", "sess-uuid-2")
token, err := svc.GenerateRefreshToken(99, "sess-uuid-2")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -104,9 +101,6 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
if claims.UserID != 99 {
t.Errorf("UserID = %d, want 99", claims.UserID)
}
if claims.Role != "admin" {
t.Errorf("Role = %q, want %q", claims.Role, "admin")
}
if claims.SessionID != "sess-uuid-2" {
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-2")
}
@@ -118,12 +112,12 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
func TestJWT_AccessTokenExpiry(t *testing.T) {
svc := newTestJWTService()
accessToken, err := svc.GenerateAccessToken(1, "user", "sess-1")
accessToken, err := svc.GenerateAccessToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
refreshToken, err := svc.GenerateRefreshToken(1, "user", "sess-1")
refreshToken, err := svc.GenerateRefreshToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -162,7 +156,7 @@ func TestJWT_ExpiredToken(t *testing.T) {
// Create a service with a negative expiry so tokens are immediately expired.
svc := auth.NewJWTService(testSecret, -1*time.Second, -1*time.Second)
token, err := svc.GenerateAccessToken(1, "user", "sess-expired")
token, err := svc.GenerateAccessToken(1, "sess-expired")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -176,13 +170,19 @@ func TestJWT_ExpiredToken(t *testing.T) {
func TestJWT_TamperedToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(1, "user", "sess-tamper")
token, err := svc.GenerateAccessToken(1, "sess-tamper")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
// Tamper with the token by modifying the last character of the signature.
tampered := token[:len(token)-1] + "X"
// Tamper with the token by replacing the last character of the signature
// with a different one (a fixed substitute would be a no-op whenever the
// real signature already ends in that character).
replacement := "X"
if token[len(token)-1] == 'X' {
replacement = "Y"
}
tampered := token[:len(token)-1] + replacement
_, err = svc.ValidateToken(tampered)
if err == nil {
@@ -194,7 +194,7 @@ func TestJWT_WrongSecret(t *testing.T) {
svc1 := auth.NewJWTService("secret-one", 15*time.Minute, 7*24*time.Hour)
svc2 := auth.NewJWTService("secret-two", 15*time.Minute, 7*24*time.Hour)
token, err := svc1.GenerateAccessToken(1, "user", "sess-wrong-secret")
token, err := svc1.GenerateAccessToken(1, "sess-wrong-secret")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -210,7 +210,6 @@ func TestJWT_WrongSigningMethod(t *testing.T) {
// We construct a token with alg=none to ensure the validator rejects it.
claims := jwt.MapClaims{
"user_id": 1,
"role": "admin",
"session_id": "sess-none",
"token_type": auth.TokenTypeAccess,
"exp": time.Now().Add(1 * time.Hour).Unix(),
@@ -250,12 +249,12 @@ func TestJWT_GarbageToken(t *testing.T) {
func TestJWT_DifferentUsersGetDifferentTokens(t *testing.T) {
svc := newTestJWTService()
token1, err := svc.GenerateAccessToken(1, "user", "sess-1")
token1, err := svc.GenerateAccessToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken(1) error: %v", err)
}
token2, err := svc.GenerateAccessToken(2, "admin", "sess-2")
token2, err := svc.GenerateAccessToken(2, "sess-2")
if err != nil {
t.Fatalf("GenerateAccessToken(2) error: %v", err)
}
+4 -6
View File
@@ -11,11 +11,13 @@ import (
type Permission string
const (
PermissionAdmin Permission = "admin"
PermissionMarkerEdit Permission = "marker_edit"
PermissionMetadataCuration Permission = "metadata_curation"
)
var assignablePermissions = map[Permission]struct{}{
PermissionAdmin: {},
PermissionMarkerEdit: {},
PermissionMetadataCuration: {},
}
@@ -60,10 +62,6 @@ func NormalizePermissions(values []string) ([]string, error) {
return out, nil
}
func DefaultUserPermissions() []string {
return []string{string(PermissionMarkerEdit)}
}
func HasAssignedPermission(user *models.User, permission Permission) bool {
if user == nil {
return false
@@ -80,7 +78,7 @@ func HasEffectivePermission(user *models.User, permission Permission) bool {
if user == nil || !user.Enabled {
return false
}
if user.Role == "admin" {
if user.IsAdmin {
return isAssignablePermission(permission)
}
return HasAssignedPermission(user, permission)
@@ -90,7 +88,7 @@ func EffectivePermissions(user *models.User) []string {
if user == nil || !user.Enabled {
return []string{}
}
if user.Role == "admin" {
if user.IsAdmin {
return assignablePermissionList()
}
permissions, err := NormalizePermissions(user.Permissions)
+19 -7
View File
@@ -30,7 +30,7 @@ func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
}
func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T) {
user := &models.User{Role: "admin", Enabled: true}
user := &models.User{IsAdmin: true, Enabled: true}
if !HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("admin should have metadata curation")
}
@@ -40,7 +40,7 @@ func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T)
}
func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
user := &models.User{Role: "user", Enabled: true}
user := &models.User{Enabled: true}
if HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("plain user should not have metadata curation")
}
@@ -50,10 +50,22 @@ func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
}
}
func TestDefaultUserPermissionsIncludesMarkerEditOnly(t *testing.T) {
got := DefaultUserPermissions()
want := []string{"marker_edit"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("default permissions = %#v, want %#v", got, want)
func TestAdminPermissionIsAssignable(t *testing.T) {
normalized, err := NormalizePermissions([]string{"admin"})
if err != nil {
t.Fatalf("admin must be an assignable permission: %v", err)
}
if len(normalized) != 1 || normalized[0] != "admin" {
t.Fatalf("got %v, want [admin]", normalized)
}
}
func TestIsAdminGrantsAllPermissions(t *testing.T) {
u := &models.User{Enabled: true, IsAdmin: true}
if !HasEffectivePermission(u, PermissionMarkerEdit) {
t.Error("admin should hold marker_edit")
}
if !HasEffectivePermission(u, PermissionMetadataCuration) {
t.Error("admin should hold metadata_curation")
}
}
+1 -2
View File
@@ -54,7 +54,7 @@ func NewPluginProviderWithClientFactory(
sessions: sessions,
users: users,
identityPool: pool,
accounts: NewAccountProvisioner(users, nil),
accounts: NewAccountProvisioner(users, nil, nil, groupResolverFor(users)),
}
}
@@ -264,7 +264,6 @@ func (p *PluginProvider) autoProvisionUser(
Username: username,
Password: password,
LocalPasswordLoginEnabled: &localPasswordLoginEnabled,
Role: "user",
},
})
if err == nil {
+187 -178
View File
@@ -30,6 +30,11 @@ func IsDuplicate(err error) bool {
return errors.Is(err, ErrDuplicate)
}
// UserLoader loads a user by ID with effective policy hydrated.
type UserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// CheckPassword verifies a plaintext password against the user's bcrypt hash.
// This is a standalone function, not a repository method.
func CheckPassword(user *models.User, password string) bool {
@@ -37,22 +42,25 @@ func CheckPassword(user *models.User, password string) bool {
return err == nil
}
// UserRepository provides CRUD operations for the users table.
// UserRepository provides CRUD operations for the users table. Effective
// access policy is derived from group memberships when users are loaded.
type UserRepository struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
groups *GroupRepository
}
// NewUserRepository creates a new UserRepository backed by the given pool.
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
return &UserRepository{pool: pool, groups: NewGroupRepository(pool)}
}
// Groups exposes the group repository sharing this repository's pool.
func (r *UserRepository) Groups() *GroupRepository { return r.groups }
// 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, 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`
const allColumns = `id, email, username, password_hash, local_password_login_enabled, enabled,
access_policy_revision, created_at, updated_at`
// scanUser scans a single row into a *models.User.
func scanUser(row pgx.Row) (*models.User, error) {
@@ -63,17 +71,8 @@ func scanUser(row pgx.Row) (*models.User, error) {
&u.Username,
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
&u.AccessPolicyRevision,
&u.MaxStreams,
&u.MaxTranscodes,
&u.MaxProfiles,
&u.DownloadAllowed,
&u.DownloadTranscodeAllowed,
&u.CreatedAt,
&u.UpdatedAt,
)
@@ -90,31 +89,11 @@ func scanUser(row pgx.Row) (*models.User, error) {
func scanUsers(rows pgx.Rows) ([]*models.User, error) {
var users []*models.User
for rows.Next() {
var u models.User
err := rows.Scan(
&u.ID,
&u.Email,
&u.Username,
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
&u.AccessPolicyRevision,
&u.MaxStreams,
&u.MaxTranscodes,
&u.MaxProfiles,
&u.DownloadAllowed,
&u.DownloadTranscodeAllowed,
&u.CreatedAt,
&u.UpdatedAt,
)
u, err := scanUser(rows)
if err != nil {
return nil, fmt.Errorf("scanning user row: %w", err)
}
users = append(users, &u)
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating user rows: %w", err)
@@ -122,77 +101,46 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
return users, nil
}
// Create inserts a new user with a bcrypt-hashed password and returns the created user.
// hydrate loads the user's group memberships and applies the effective policy.
func (r *UserRepository) hydrate(ctx context.Context, u *models.User) error {
groups, err := r.groups.GroupsForUser(ctx, u.ID)
if err != nil {
return err
}
ApplyEffectivePolicy(u, groups)
u.Groups = groups
return nil
}
// Create inserts a new user with a bcrypt-hashed password and the given group
// memberships, then returns the created user with its effective policy.
func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInput) (*models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
// Base columns that are always included.
localPasswordLoginEnabled := true
if input.LocalPasswordLoginEnabled != nil {
localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled
}
permissions := append([]string(nil), input.Permissions...)
if input.Permissions == nil && input.Role != "admin" {
permissions = DefaultUserPermissions()
}
permissions, err = NormalizePermissions(permissions)
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("beginning user create: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
args := []any{
var id int
err = tx.QueryRow(ctx, `
INSERT INTO users (email, username, password_hash, local_password_login_enabled)
VALUES ($1, $2, $3, $4)
RETURNING id`,
NormalizeEmail(input.Email),
NormalizeUsername(input.Username),
string(hash),
localPasswordLoginEnabled,
input.Role,
permissions,
input.LibraryIDs,
input.MaxPlaybackQuality,
}
// Optional columns: nil means use DB default.
if input.MaxStreams != nil {
cols = append(cols, "max_streams")
args = append(args, *input.MaxStreams)
}
if input.MaxTranscodes != nil {
cols = append(cols, "max_transcodes")
args = append(args, *input.MaxTranscodes)
}
if input.MaxProfiles != nil {
cols = append(cols, "max_profiles")
args = append(args, *input.MaxProfiles)
}
if input.DownloadAllowed != nil {
cols = append(cols, "download_allowed")
args = append(args, *input.DownloadAllowed)
}
if input.DownloadTranscodeAllowed != nil {
cols = append(cols, "download_transcode_allowed")
args = append(args, *input.DownloadTranscodeAllowed)
}
// Build placeholders: $1, $2, ..., $N
placeholders := make([]string, len(args))
for i := range args {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO users (%s) VALUES (%s) RETURNING %s",
strings.Join(cols, ", "),
strings.Join(placeholders, ", "),
allColumns,
)
row := r.pool.QueryRow(ctx, query, args...)
user, err := scanUser(row)
).Scan(&id)
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
@@ -200,29 +148,67 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
return nil, fmt.Errorf("creating user: %w", err)
}
return user, nil
if len(input.GroupIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING`, id, input.GroupIDs); err != nil {
if isForeignKeyError(err) {
return nil, fmt.Errorf("%w", ErrUnknownGroup)
}
return nil, fmt.Errorf("adding memberships: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing user create: %w", err)
}
return r.GetByID(ctx, id)
}
// GetByID retrieves a user by their numeric ID.
func (r *UserRepository) GetByID(ctx context.Context, id int) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE id = $1`
return scanUser(r.pool.QueryRow(ctx, query, id))
user, err := scanUser(r.pool.QueryRow(ctx, query, id))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetByUsername retrieves a user by their username (case-insensitive).
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE username = $1`
return scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetByEmail retrieves a user by their email address (case-insensitive).
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE email = $1`
return scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Update modifies a user's fields. Only non-nil fields in the input are updated.
// If the input contains a Password, it is bcrypt-hashed before storage.
// If the input contains a Password, it is bcrypt-hashed before storage. A
// non-nil GroupIDs replaces the user's group memberships.
func (r *UserRepository) Update(ctx context.Context, id int, input models.UpdateUserInput) error {
setClauses := []string{}
accessPolicyPredicates := []string{}
@@ -253,113 +239,107 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
args = append(args, *input.LocalPasswordLoginEnabled)
argIndex++
}
if input.Role != nil {
setClauses = append(setClauses, fmt.Sprintf("role = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("role IS DISTINCT FROM $%d", argIndex))
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))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("permissions IS DISTINCT FROM $%d", argIndex))
args = append(args, permissions)
argIndex++
}
if input.Enabled != nil {
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("enabled IS DISTINCT FROM $%d", argIndex))
args = append(args, *input.Enabled)
argIndex++
}
if input.LibraryIDs != nil {
setClauses = append(setClauses, fmt.Sprintf("library_ids = $%d", argIndex))
// Library scope is resolved from users.library_ids on each request, so
// changing it must not invalidate durable profile/session tokens.
args = append(args, *input.LibraryIDs)
argIndex++
// Identity and membership writes share one transaction so a failure in
// either (e.g. ErrLastAdministrator) leaves the user fully unchanged.
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning user update: %w", err)
}
if input.MaxPlaybackQuality != nil {
setClauses = append(setClauses, fmt.Sprintf("max_playback_quality = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("max_playback_quality IS DISTINCT FROM $%d", argIndex))
args = append(args, *input.MaxPlaybackQuality)
argIndex++
}
if input.MaxStreams != nil {
setClauses = append(setClauses, fmt.Sprintf("max_streams = $%d", argIndex))
args = append(args, *input.MaxStreams)
argIndex++
}
if input.MaxTranscodes != nil {
setClauses = append(setClauses, fmt.Sprintf("max_transcodes = $%d", argIndex))
args = append(args, *input.MaxTranscodes)
argIndex++
}
if input.MaxProfiles != nil {
setClauses = append(setClauses, fmt.Sprintf("max_profiles = $%d", argIndex))
args = append(args, *input.MaxProfiles)
argIndex++
}
if input.DownloadAllowed != nil {
setClauses = append(setClauses, fmt.Sprintf("download_allowed = $%d", argIndex))
args = append(args, *input.DownloadAllowed)
argIndex++
}
if input.DownloadTranscodeAllowed != nil {
setClauses = append(setClauses, fmt.Sprintf("download_transcode_allowed = $%d", argIndex))
args = append(args, *input.DownloadTranscodeAllowed)
argIndex++
defer func() { _ = tx.Rollback(ctx) }()
// Disabling the last enabled administrator would brick admin access just
// like removing their membership, so the same transactional guard applies.
// It runs before the identity UPDATE so the administrators group row is
// always locked before the user row (replaceUserGroupsTx re-locks the same
// group row later in this transaction, which is a no-op).
if input.Enabled != nil && !*input.Enabled {
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
return err
}
}
if len(setClauses) == 0 {
// Nothing to update; still verify the user exists.
_, err := r.GetByID(ctx, id)
return err
}
if len(accessPolicyPredicates) > 0 {
setClauses = append(setClauses, fmt.Sprintf(
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
strings.Join(accessPolicyPredicates, " OR "),
))
}
// Always bump updated_at.
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), argIndex)
args = append(args, id)
tag, err := r.pool.Exec(ctx, query, args...)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
// Nothing to update on the users row; still verify the user exists.
var exists int
if err := tx.QueryRow(ctx, `SELECT 1 FROM users WHERE id = $1`, id).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("checking user exists: %w", err)
}
} else {
if len(accessPolicyPredicates) > 0 {
setClauses = append(setClauses, fmt.Sprintf(
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
strings.Join(accessPolicyPredicates, " OR "),
))
}
// Always bump updated_at.
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), argIndex)
args = append(args, id)
tag, err := tx.Exec(ctx, query, args...)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
}
return fmt.Errorf("updating user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return fmt.Errorf("updating user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
if input.GroupIDs != nil {
if err := r.groups.replaceUserGroupsTx(ctx, tx, id, *input.GroupIDs); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing user update: %w", err)
}
return nil
}
// Delete removes a user by their ID.
// Delete removes a user by their ID. Deleting the last enabled administrator
// is rejected with ErrLastAdministrator; the guard and the delete share one
// transaction so concurrent admin mutations serialize on the administrators
// group row.
func (r *UserRepository) Delete(ctx context.Context, id int) error {
tag, err := r.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning user delete: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
return err
}
tag, err := tx.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
if err != nil {
return fmt.Errorf("deleting user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing user delete: %w", err)
}
return nil
}
@@ -372,7 +352,27 @@ func (r *UserRepository) List(ctx context.Context) ([]*models.User, error) {
}
defer rows.Close()
return scanUsers(rows)
users, err := scanUsers(rows)
if err != nil {
return nil, err
}
// Bulk-hydrate effective policy with a single membership query.
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
groupsByUser, err := r.groups.GroupsForUsers(ctx, userIDs)
if err != nil {
return nil, err
}
for _, u := range users {
groups := groupsByUser[u.ID]
ApplyEffectivePolicy(u, groups)
u.Groups = groups
}
return users, nil
}
// Count returns the number of users in the database.
@@ -393,6 +393,15 @@ func isDuplicateKeyError(err error) bool {
return false
}
// isForeignKeyError checks if the error is a PostgreSQL foreign_key_violation (code 23503).
func isForeignKeyError(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23503"
}
return false
}
// extractConstraint extracts the constraint name from a PgError for diagnostic messages.
func extractConstraint(err error) string {
var pgErr *pgconn.PgError
+43 -11
View File
@@ -35,6 +35,33 @@ type SettingsGetter interface {
Get(ctx context.Context, key string) (string, error)
}
// Legacy role strings exposed in API response bodies. Presentation-only;
// intentionally distinct from Permission values even though "admin" overlaps
// textually.
const (
roleAdmin = "admin"
roleUser = "user"
)
// RoleForUser derives the legacy role string exposed in API response bodies
// from the user's group-derived admin flag. It is never used for
// authorization decisions and is no longer carried in JWT claims.
func RoleForUser(u *models.User) string {
if u != nil && u.IsAdmin {
return roleAdmin
}
return roleUser
}
// groupResolverFor returns the user repository's group repository as a
// GroupResolver, avoiding a typed-nil interface when users is nil.
func groupResolverFor(users *UserRepository) GroupResolver {
if users == nil {
return nil
}
return users.Groups()
}
type claimsContextKey struct{}
// WithClaims stores auth claims on the context for auth-owned flows.
@@ -100,7 +127,7 @@ func NewService(
settings: settings,
providers: map[string]AuthProvider{},
metadata: map[string]LoginProviderInfo{},
accounts: NewAccountProvisioner(users, storeProvider),
accounts: NewAccountProvisioner(users, storeProvider, settings, groupResolverFor(users)),
}
if provider != nil {
service.RegisterProvider(LoginProviderInfo{
@@ -209,7 +236,6 @@ func (s *Service) CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*
}
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
@@ -271,7 +297,6 @@ func (s *Service) loginWithProvider(
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
@@ -306,12 +331,20 @@ func (s *Service) SetupInitialUser(
return nil, nil, ErrSetupAlreadyComplete
}
adminGroup, err := s.users.Groups().GetBySlug(ctx, models.GroupSlugAdministrators)
if err != nil {
return nil, nil, fmt.Errorf("loading administrators group: %w", err)
}
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "admin",
// Administrators membership is written inside the same transaction
// as the user row, so a failure leaves setup fully retryable. The
// explicit list also keeps configured signup defaults from applying.
GroupIDs: []int{adminGroup.ID},
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -322,6 +355,7 @@ func (s *Service) SetupInitialUser(
}
// Reuse the standard login flow so setup creates a normal session pair.
// Login reloads the user, so the returned user carries the admin policy.
return s.Login(ctx, username, password, deviceName, ip)
}
@@ -352,13 +386,13 @@ func (s *Service) Signup(
return nil, nil, err
}
// Create the user with standard role and access to all libraries.
// Create the user; nil GroupIDs lets the provisioner resolve the
// configured default groups.
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "user",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -412,7 +446,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
}
return nil, nil, nil, fmt.Errorf("getting admin user: %w", err)
}
if admin.Role != "admin" || !admin.Enabled {
if !admin.IsAdmin || !admin.Enabled {
return nil, nil, nil, ErrImpersonationNotAllowed
}
if adminUserID == targetUserID {
@@ -423,7 +457,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
if err != nil {
return nil, nil, nil, fmt.Errorf("getting target user: %w", err)
}
if !target.Enabled || target.Role == "admin" {
if !target.Enabled || target.IsAdmin {
return nil, nil, nil, ErrImpersonationNotAllowed
}
@@ -446,7 +480,6 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
pair, err := s.generateTokenPair(Claims{
UserID: target.ID,
Role: target.Role,
SessionID: sessionID,
ImpersonatorUserID: &impersonatorUserID,
})
@@ -519,7 +552,6 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair,
return s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: session.ID,
ImpersonatorUserID: session.ImpersonatorUserID,
})
@@ -537,7 +569,7 @@ func (s *Service) validateImpersonator(ctx context.Context, impersonatorUserID *
}
return fmt.Errorf("getting impersonator user: %w", err)
}
if !impersonator.Enabled || impersonator.Role != "admin" {
if !impersonator.Enabled || !impersonator.IsAdmin {
return ErrSessionRevoked
}
return nil
+4 -4
View File
@@ -15,10 +15,10 @@ type ScopeResolver interface {
}
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
// access scope resolver, so account-level library restrictions
// (users.library_ids), profile library restrictions, user-disabled
// libraries, content-rating ceilings, and playback-quality ceilings all
// apply to the Jellyfin-compat API exactly as they do to the native API.
// access scope resolver, so account-level library restrictions (derived from
// group membership), profile library restrictions, user-disabled libraries,
// content-rating ceilings, and playback-quality ceilings all apply to the
// Jellyfin-compat API exactly as they do to the native API.
//
// PIN verification is skipped because compat login already verified the
// profile PIN (password#pin convention). Resolution failures fail closed:
+55 -8
View File
@@ -26,12 +26,15 @@ const (
type Authenticator struct {
sessions *SessionStore
authService *auth.Service
users auth.UserLoader
now func() time.Time
}
// NewAuthenticator creates a new compat authenticator.
func NewAuthenticator(sessions *SessionStore, authService *auth.Service) *Authenticator {
return &Authenticator{sessions: sessions, authService: authService, now: time.Now}
// NewAuthenticator creates a new compat authenticator. users re-validates the
// session's account on every request (see compatUserEnabled); a nil loader
// fails closed.
func NewAuthenticator(sessions *SessionStore, authService *auth.Service, users auth.UserLoader) *Authenticator {
return &Authenticator{sessions: sessions, authService: authService, users: users, now: time.Now}
}
// ExtractToken extracts a compat token from Jellyfin-style request auth.
@@ -94,6 +97,19 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler {
return
}
// Compat sessions cache only identity, never account status: the user
// is re-loaded on EVERY request so disabling (or deleting) an account
// cuts off Jellyfin clients on the next request rather than at
// compat-session expiry — matching the native API and the admin
// API-key path. The session itself is left in place: transient lookup
// failures also deny (fail closed), and deleting here would force a
// re-login after a recovered DB blip. Session eviction on disable is
// handled separately (OnUserSessionsRevoked).
if !compatUserEnabled(r.Context(), a.users, session.StreamAppUserID) {
writeError(w, http.StatusUnauthorized, "Unauthorized", "User account is disabled")
return
}
// Refresh underlying Silo tokens if they're about to expire.
// Use a detached context so a client aborting the request mid-refresh
// (common on flaky mobile networks) doesn't revoke the compat session.
@@ -148,15 +164,46 @@ func serveWithSession(next http.Handler, w http.ResponseWriter, r *http.Request,
next.ServeHTTP(w, r.WithContext(ctx))
}
// compatUserEnabled reports whether a compat session's account still exists
// and is enabled. The user is loaded fresh on every request — never cached on
// the session — so disabling an account takes effect on the next request.
// Every failure mode fails closed: a missing user is a definitive deny, and
// an unexpected lookup error denies rather than serving content for an
// account whose status cannot be verified. A nil loader also fails closed;
// production wiring always provides one alongside the database.
func compatUserEnabled(ctx context.Context, users auth.UserLoader, userID int) bool {
if users == nil {
return false
}
user, err := users.GetByID(ctx, userID)
if err != nil {
if !auth.IsNotFound(err) {
slog.Warn("jellycompat auth: user status check failed; denying request",
"user_id", userID,
"error", err,
)
}
return false
}
return user != nil && user.Enabled
}
// resolveCompatToken resolves a token to a compat session: a session-store token
// (normal login) or, matching Jellyfin, an sa_ admin API key (synthesized
// session bound to the key user's primary profile). Returns false when the token
// matches neither. keyAuth may be nil (resolveSession handles a nil receiver).
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, token string) (*Session, bool) {
// matches neither or when the session's account is no longer enabled. keyAuth
// may be nil (resolveSession handles a nil receiver).
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader, token string) (*Session, bool) {
if token == "" {
return nil, false
}
if session, ok := sessions.Get(token); ok {
// sa_-key sessions below re-validate the owning user inside
// resolveSession; session-store sessions must do it here so a
// disabled account cannot keep streaming until session expiry.
if !compatUserEnabled(ctx, users, session.StreamAppUserID) {
return nil, false
}
return session, true
}
if strings.HasPrefix(token, "sa_") {
@@ -170,13 +217,13 @@ func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *Ad
// PlaybackSessionAuth creates middleware that falls back to playback session
// authentication for media stream endpoints where external players (e.g. libmpv)
// don't forward auth headers or query parameters.
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator) func(next http.Handler) http.Handler {
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try standard token auth first — a compat session token or an sa_
// admin key (synthesized session).
if token, ok := ExtractToken(r); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, token); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, token); ok {
serveWithSession(next, w, r, session)
return
}
@@ -193,7 +240,7 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
// miss it and 401 the stream — forcing a needless transcode fallback.
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
if playSession, found := playbackStore.Get(playSessionID); found {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
return
}
+9 -7
View File
@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -22,13 +23,9 @@ type apiKeyValidator interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
type apiKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
type AdminAPIKeyAuthenticator struct {
keys apiKeyValidator
users apiKeyUserLoader
users auth.UserLoader
provider userstore.UserStoreProvider
now func() time.Time
@@ -53,7 +50,12 @@ type adminAPIKeyAuthResult struct {
// when present, an admin key can synthesize a compat session (see
// resolveSession); when nil, only the admin-bool path (RequireAdminAPIKey /
// RequireSessionOrAdminAPIKey) is available.
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
//
// users loads Silo users by ID with their group-derived effective policy
// hydrated (IsAdmin, DownloadAllowed, LibraryIDs). Policy is always read from
// a freshly loaded user — never cached on sessions — so group edits and role
// changes take effect on the next request.
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users auth.UserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
if keys == nil || users == nil {
return nil
}
@@ -172,7 +174,7 @@ func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (
if err != nil || user == nil || !user.Enabled {
return nil, nil, unauthorized
}
if user.Role != "admin" {
if !user.IsAdmin {
return nil, nil, adminAPIKeyAuthResult{
status: http.StatusForbidden,
code: "Forbidden",
@@ -48,7 +48,7 @@ func (p *fakeUserStoreProvider) Close() error { return nil }
func adminKeyAuthWithPrimary(t *testing.T, clock func() time.Time) (*AdminAPIKeyAuthenticator, *fakeUserStoreProvider, *fakeAPIKeyValidator) {
t.Helper()
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}}
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{
{ID: "kids", Name: "Kids", IsPrimary: false},
{ID: "p1", Name: "Parent", IsPrimary: true},
@@ -115,7 +115,7 @@ func TestRequireSessionOrAPIKeySession_RejectsNonAdminKey(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Enabled: true}}
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{{ID: "p1", IsPrimary: true}}}}
keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock)
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
@@ -165,7 +165,7 @@ func TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable(t *t
"nil authenticator": nil,
"nil provider": NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
nil, clock,
),
}
@@ -254,7 +254,7 @@ func TestPlaybackSessionAuth_APIKeyViaPlaySessionId(t *testing.T) {
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
var got *Session
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = SessionFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
@@ -283,7 +283,7 @@ func TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId(t *testing.T) {
playback := NewPlaybackSessionStore(time.Hour, clock)
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a revoked key")
}))
req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil)
@@ -315,8 +315,9 @@ func TestValidatePseudoUser(t *testing.T) {
}
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
h := NewAuthHandler(&config.Config{}, nil, nil)
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
h := NewAuthHandler(&config.Config{}, nil, nil,
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}})
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), StreamAppUserID: 2, Username: "admin"}
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
@@ -342,7 +343,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
}
func TestHandleUsers_RequiresSession(t *testing.T) {
h := NewAuthHandler(&config.Config{}, nil, nil)
h := NewAuthHandler(&config.Config{}, nil, nil, nil)
rec := httptest.NewRecorder()
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
if rec.Code != http.StatusUnauthorized {
+117 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
@@ -27,7 +28,7 @@ func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
StreamAppTokenExpiry: now.Add(3 * time.Minute),
})
authn := &Authenticator{sessions: store, authService: nil}
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "valid-tok")
rec := httptest.NewRecorder()
@@ -62,7 +63,7 @@ func TestRequireSession_PassesThroughNonExpiringToken(t *testing.T) {
StreamAppTokenExpiry: now.Add(30 * time.Minute),
})
authn := &Authenticator{sessions: store}
authn := &Authenticator{sessions: store, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "fresh-tok")
rec := httptest.NewRecorder()
@@ -97,7 +98,7 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
StreamAppTokenExpiry: now.Add(-1 * time.Hour), // already expired
})
authn := &Authenticator{sessions: store, authService: nil}
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "expired-tok")
rec := httptest.NewRecorder()
@@ -119,6 +120,75 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
}
}
// enabledUserLoader returns a loader serving a single enabled, non-admin user.
func enabledUserLoader(id int) auth.UserLoader {
return &fakeAPIKeyUserLoader{user: &models.User{ID: id, Enabled: true}}
}
func TestRequireSession_RejectsDisabledUser(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
store := NewSessionStore(30*24*time.Hour, clock)
_ = store.Put(Session{Token: "tok", StreamAppUserID: 1})
cases := map[string]auth.UserLoader{
"disabled account": &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}},
"deleted account": &fakeAPIKeyUserLoader{}, // loader returns auth.ErrNotFound
"no loader wired": nil, // fail closed
}
for name, users := range cases {
t.Run(name, func(t *testing.T) {
authn := &Authenticator{sessions: store, users: users}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "tok")
rec := httptest.NewRecorder()
authn.RequireSession(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a disabled/missing account")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
// The compat session must survive: a transient lookup failure also
// denies, and deleting here would force a re-login after recovery.
if _, ok := store.Get("tok"); !ok {
t.Fatal("compat session must not be deleted by a per-request denial")
}
})
}
}
func TestPlaybackSessionAuth_RejectsDisabledUser(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
disabled := &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}}
mw := PlaybackSessionAuth(sessions, playbackStore, nil, disabled)
// Both the direct-token path and the PlaySessionId fallback must deny.
for name, target := range map[string]string{
"token": "/Videos/itm/stream?api_key=compat-tok",
"play session": "/Videos/itm/stream?PlaySessionId=ps-abc",
"lowercase psi": "/Videos/itm/stream?playSessionId=ps-abc",
} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest("GET", target, nil)
rec := httptest.NewRecorder()
mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a disabled account")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
})
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
@@ -127,7 +197,7 @@ func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
mw := PlaybackSessionAuth(sessions, playbackStore, nil, enabledUserLoader(1))
cases := []struct {
name string
@@ -173,10 +243,48 @@ func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
}
}
func TestUserPolicyFromEffectivePolicy(t *testing.T) {
u := &models.User{
ID: 7, Enabled: true, IsAdmin: false,
LibraryIDs: []int{3, 5},
DownloadAllowed: false,
}
policy := buildUserPolicy(u)
if policy.IsAdministrator {
t.Error("non-admin must not be administrator")
}
if policy.EnableAllFolders {
t.Error("restricted user must not see all folders")
}
if policy.EnableContentDownloading {
t.Error("downloads disabled must map through")
}
// Pin the Jellyfin folder GUID encoding: EncodeNumericID packs the
// EncodedIDLibrary type byte (0x01) into byte 0 and the library ID
// big-endian into bytes 8-15. Clients persist these GUIDs, so the
// encoding must stay stable.
wantFolders := []string{
"01000000-0000-0000-0000-000000000003",
"01000000-0000-0000-0000-000000000005",
}
if !slices.Equal(policy.EnabledFolders, wantFolders) {
t.Errorf("enabled folders = %v, want %v", policy.EnabledFolders, wantFolders)
}
admin := &models.User{ID: 1, Enabled: true, IsAdmin: true, LibraryIDs: nil, DownloadAllowed: true}
adminPolicy := buildUserPolicy(admin)
if !adminPolicy.IsAdministrator || !adminPolicy.EnableAllFolders || !adminPolicy.EnableContentDownloading {
t.Error("admin policy must be unrestricted")
}
if len(adminPolicy.EnabledFolders) != 0 {
t.Errorf("unrestricted policy must not enumerate folders, got %v", adminPolicy.EnabledFolders)
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -197,7 +305,7 @@ func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Enabled: true}},
)
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -215,7 +323,7 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -241,7 +349,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
return nil
},
},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -266,7 +374,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
// newAdminAPIKeyAuthForTest builds an authenticator without a UserStoreProvider,
// exercising the admin-bool path only (no session synthesis).
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users auth.UserLoader) *AdminAPIKeyAuthenticator {
return NewAdminAPIKeyAuthenticator(keys, users, nil, nil)
}
+53 -10
View File
@@ -42,18 +42,10 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
codec: codec,
content: &stubContentService{detail: detail},
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: filePath}},
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: true}},
}
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
rec := serveDownloadRequest(handler, codec, contentID)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
@@ -66,6 +58,57 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
}
}
// TestHandleDownload_ForbiddenWhenDownloadsDisabled verifies the download
// route enforces the group-derived DownloadAllowed policy from a fresh user
// load and returns 403 for restricted users.
func TestHandleDownload_ForbiddenWhenDownloadsDisabled(t *testing.T) {
codec := NewResourceIDCodec()
handler := &PlaybackHandler{
codec: codec,
content: &stubContentService{},
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: false}},
}
rec := serveDownloadRequest(handler, codec, "movie-1")
if rec.Code != 403 {
t.Fatalf("expected status 403; got %d, body=%s", rec.Code, rec.Body.String())
}
}
// TestHandleDownload_FailsClosedWhenUserLoadFails verifies a user-load error
// never falls back to permissive defaults.
func TestHandleDownload_FailsClosedWhenUserLoadFails(t *testing.T) {
codec := NewResourceIDCodec()
handler := &PlaybackHandler{
codec: codec,
content: &stubContentService{},
users: &fakeAPIKeyUserLoader{}, // GetByID errors for every ID
}
rec := serveDownloadRequest(handler, codec, "movie-1")
if rec.Code != 500 {
t.Fatalf("expected status 500; got %d, body=%s", rec.Code, rec.Body.String())
}
}
// serveDownloadRequest issues GET /Items/{id}/Download for user 1 against
// the handler and returns the recorded response.
func serveDownloadRequest(handler *PlaybackHandler, codec *ResourceIDCodec, contentID string) *httptest.ResponseRecorder {
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
return rec
}
// TestItemDetail_AdvertisesCanDownload guards against regressing the
// CanDownload flag: Infuse refuses Direct Play (Static=true streaming) of
// items it believes it cannot download, so playable items must advertise it.
+106 -43
View File
@@ -3,13 +3,18 @@ package jellycompat
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
type authenticateByNameRequest struct {
@@ -118,14 +123,17 @@ type AuthHandler struct {
cfg *config.Config
loginResolver loginResolver
authenticator *Authenticator
users auth.UserLoader
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
// NewAuthHandler creates a new auth handler. users supplies the freshly
// loaded effective policy for user DTOs; user routes fail when it is absent.
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator, users auth.UserLoader) *AuthHandler {
return &AuthHandler{
cfg: cfg,
loginResolver: loginResolver,
authenticator: authenticator,
users: users,
}
}
@@ -158,10 +166,17 @@ func (h *AuthHandler) HandleAuthenticateByName(w http.ResponseWriter, r *http.Re
return
}
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat login user dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, authenticateByNameResponse{
AccessToken: session.Token,
ServerID: h.cfg.JellyfinCompat.ServerID,
User: h.userDTO(session),
User: dto,
SessionInfo: h.sessionInfo(session),
})
}
@@ -173,7 +188,13 @@ func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat current user dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, dto)
}
// HandleUsers serves GET /Users.
@@ -190,7 +211,13 @@ func (h *AuthHandler) HandleUsers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, []userDTOResponse{h.userDTO(session)})
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat users list dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, []userDTOResponse{dto})
}
// HandleUserByID serves GET /Users/{id}.
@@ -205,7 +232,13 @@ func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "NotFound", "User not found")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat user by id dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, dto)
}
// HandleLogout serves POST /Sessions/Logout.
@@ -219,50 +252,33 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
name := session.Username
// userDTO builds the Jellyfin user DTO for a compat session. The user is
// loaded fresh on every call so the policy always reflects the current
// group-derived effective policy: compat sessions are long-lived (and
// persisted without policy), so anything cached at login would go stale the
// moment group membership changes. Callers must fail the request on error
// rather than fall back to permissive defaults. These are cold paths (login,
// /Users/Me, /Users listings), so the extra query is fine.
func (h *AuthHandler) userDTO(ctx context.Context, session *Session) (userDTOResponse, error) {
if h.users == nil {
return userDTOResponse{}, errors.New("user loader not configured")
}
user, err := h.users.GetByID(ctx, session.StreamAppUserID)
if err != nil {
return userDTOResponse{}, fmt.Errorf("load user %d: %w", session.StreamAppUserID, err)
}
if user == nil {
return userDTOResponse{}, fmt.Errorf("user %d not found", session.StreamAppUserID)
}
return userDTOResponse{
ID: session.PseudoUserID.String(),
Name: name,
Name: session.Username,
ServerID: h.cfg.JellyfinCompat.ServerID,
HasPassword: true,
HasConfiguredPassword: true,
HasConfiguredEasyPassword: false,
Policy: userPolicyResponse{
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTVManagement: false,
EnableLiveTVAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDownloading: true,
EnableSyncTranscoding: false,
EnableMediaConversion: false,
EnableAllDevices: true,
EnableAllChannels: false,
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: 0,
MaxActiveSessions: 0,
EnablePublicSharing: false,
RemoteClientBitrateLimit: 0,
AuthenticationProviderID: "silo-local",
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
},
Policy: buildUserPolicy(user),
Configuration: userConfigurationResponse{
PlayDefaultAudioTrack: true,
DisplayMissingEpisodes: false,
@@ -278,7 +294,54 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
},
}, nil
}
// buildUserPolicy maps Silo's group-derived effective policy onto the
// Jellyfin UserPolicy shape.
func buildUserPolicy(u *models.User) userPolicyResponse {
policy := userPolicyResponse{
IsAdministrator: u.IsAdmin,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTVManagement: false,
EnableLiveTVAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDownloading: u.DownloadAllowed,
EnableSyncTranscoding: false,
EnableMediaConversion: false,
EnableAllDevices: true,
EnableAllChannels: false,
EnableAllFolders: u.LibraryIDs == nil,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: 0,
MaxActiveSessions: 0,
EnablePublicSharing: false,
RemoteClientBitrateLimit: 0,
AuthenticationProviderID: "silo-local",
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
}
if u.LibraryIDs != nil {
folders := make([]string, len(u.LibraryIDs))
for i, id := range u.LibraryIDs {
folders[i] = EncodeNumericID(EncodedIDLibrary, uint64(id)).String()
}
policy.EnabledFolders = folders
}
return policy
}
func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
+9 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
@@ -32,6 +33,10 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// audioSpatialFormatNone is the Jellyfin AudioSpatialFormat value for streams
// without spatial audio metadata.
const audioSpatialFormatNone = "None"
type playbackInfoRequest struct {
UserID string `json:"UserId"`
MediaSourceID string `json:"MediaSourceId"`
@@ -119,6 +124,7 @@ type PlaybackHandler struct {
sessionMgr SessionManagerInterface
fileResolver FilePathResolver
storeProvider userstore.UserStoreProvider
users auth.UserLoader // fresh effective-policy loads for download enforcement
NodePlanner nodepool.SessionPlanner
JWTSecret string
profileStaler profileStaler
@@ -664,7 +670,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
PixelFormat: track.PixelFormat,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
AverageFrameRate: parseCompatFrameRate(track.FrameRate),
RealFrameRate: parseCompatFrameRate(track.FrameRate),
ReferenceFrameRate: parseCompatFrameRate(track.FrameRate),
@@ -694,7 +700,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: false,
IsTextSubtitleStream: false,
SupportsExternalStream: false,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
Channels: track.Channels,
BitRate: track.Bitrate,
})
@@ -721,7 +727,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: track.HearingImpaired,
IsTextSubtitleStream: true,
SupportsExternalStream: track.External,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
DeliveryURL: subtitleDeliveryURL(routeItemID, mediaSourceID, streamIndex, format, compatToken, playSessionID),
DeliveryMethod: subtitleDeliveryMethod(track.External),
Path: subtitlePath(track, routeItemID, mediaSourceID, streamIndex, format),
+5 -4
View File
@@ -66,7 +66,7 @@ func NewRouter(deps Dependencies) chi.Router {
if err != nil {
slog.Error("jellycompat web bundle unavailable", "error", err)
}
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator)
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator, deps.UserLoader)
nextUpRepo := catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
var subtitleRepo subtitles.Repository
if deps.SubtitleRepo != nil {
@@ -82,7 +82,7 @@ func NewRouter(deps Dependencies) chi.Router {
itemsHandler.posterPresigner = deps.PosterPresigner
itemsHandler.presignTTL = deps.PresignTTL
autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader, deps.UserStoreProvider, deps.Now)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.UserLoader, deps.UserStoreProvider, deps.Now)
autoscanVirtualFoldersRegistered := false
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
@@ -93,6 +93,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
playbackHandler.users = deps.UserLoader
if deps.DB != nil {
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
}
@@ -216,7 +217,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Stream routes: use playback-session auth fallback for media players
// (e.g. libmpv) that don't forward auth headers or query parameters.
r.Group(func(r chi.Router) {
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth))
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth, deps.UserLoader))
r.Method(http.MethodHead, "/Items/{id}/Download", http.HandlerFunc(playbackHandler.HandleDownload))
r.Get("/Items/{id}/Download", playbackHandler.HandleDownload)
r.Method(http.MethodHead, "/Videos/{id}/stream", http.HandlerFunc(playbackHandler.HandleVideoStream))
@@ -334,7 +335,7 @@ func withDefaults(deps Dependencies) Dependencies {
}
if deps.Authenticator == nil && deps.SessionStore != nil {
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService)
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService, deps.UserLoader)
}
return deps
}
+5 -3
View File
@@ -45,9 +45,11 @@ type Dependencies struct {
AuthService *auth.Service
// Autoscan / admin compatibility support.
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
ScanQueue scantrigger.Queuer
APIKeyValidator apiKeyValidator
// UserLoader loads users with their effective policy hydrated. Required
// for API-key auth, user DTO policy, and download permission enforcement.
UserLoader auth.UserLoader
ScanQueue scantrigger.Queuer
// Catalog repos (for ContentService construction)
BrowseRepo *catalog.BrowseRepository
+19
View File
@@ -122,6 +122,10 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
// load-bearing for Infuse: it refuses Direct Play (Static=true streaming)
// for items it believes it cannot download, so the flag must stay true and
// this route must exist.
//
// The requesting user's group-derived DownloadAllowed policy is enforced
// here from a fresh user load (never a session-cached value), failing closed
// on any load error. For allowed users the route behavior is unchanged.
func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
@@ -129,6 +133,21 @@ func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request)
return
}
if h.users == nil {
writeError(w, http.StatusInternalServerError, "ServerError", "User loader not available")
return
}
user, err := h.users.GetByID(r.Context(), session.StreamAppUserID)
if err != nil || user == nil {
slog.Error("jellycompat download user load failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to resolve user")
return
}
if !user.DownloadAllowed {
writeError(w, http.StatusForbidden, "Forbidden", "Downloads are not permitted for this user")
return
}
contentID, err := decodeContentID(h.codec, chiURLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
+64
View File
@@ -0,0 +1,64 @@
package models
import "time"
// Built-in group slugs. Built-in groups cannot be deleted; the
// administrators group cannot lose the admin permission or its last
// enabled member.
const (
GroupSlugAdministrators = "administrators"
GroupSlugUsers = "users"
)
// Group represents a row in the groups table. Groups are the only source of
// authorization policy: a user's effective policy is the most-permissive
// union of their groups.
type Group struct {
ID int
Slug string
Name string
Description string
BuiltIn bool
Permissions []string
LibraryIDs []int // nil = all libraries
MaxStreams int
MaxTranscodes int
MaxProfiles int
MaxPlaybackQuality string // "" = unrestricted
DownloadAllowed bool
DownloadTranscodeAllowed bool
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateGroupInput contains the fields to create a new group. The slug is
// derived from Name. Nil pointer fields use the DB defaults.
type CreateGroupInput struct {
Name string // required
Description string
Permissions []string
LibraryIDs []int // nil = all libraries
MaxStreams *int
MaxTranscodes *int
MaxProfiles *int
MaxPlaybackQuality *string
DownloadAllowed *bool
DownloadTranscodeAllowed *bool
}
// UpdateGroupInput contains optional fields for updating a group.
// Nil means "don't update". LibraryIDs follows the same convention
// models.UpdateUserInput uses: a non-nil pointer to a nil slice means
// "all libraries"; a pointer to an empty slice means "none".
type UpdateGroupInput struct {
Name *string
Description *string
Permissions *[]string
LibraryIDs *[]int // nil = don't update; *nil = all; *[] = none
MaxStreams *int
MaxTranscodes *int
MaxProfiles *int
MaxPlaybackQuality *string
DownloadAllowed *bool
DownloadTranscodeAllowed *bool
}
+26 -32
View File
@@ -2,43 +2,44 @@ package models
import "time"
// User represents a row in the users table.
// User represents a row in the users table plus the effective access policy
// derived from the user's group memberships at load time.
type User struct {
ID int
Email string
Username string
PasswordHash string
LocalPasswordLoginEnabled bool
Role string
Permissions []string
Enabled bool
LibraryIDs []int // nullable in PG (nil = all libraries)
MaxPlaybackQuality string
AccessPolicyRevision int64
MaxStreams int
MaxTranscodes int
MaxProfiles int
DownloadAllowed bool
DownloadTranscodeAllowed bool
CreatedAt time.Time
UpdatedAt time.Time
// IsAdmin, GroupIDs, and the policy fields below are derived from group
// membership at load time (see auth.ApplyEffectivePolicy).
IsAdmin bool
GroupIDs []int
Permissions []string
Enabled bool
LibraryIDs []int // effective; nil = all libraries
MaxPlaybackQuality string
AccessPolicyRevision int64
MaxStreams int
MaxTranscodes int
MaxProfiles int
DownloadAllowed bool
DownloadTranscodeAllowed bool
// Groups carries the loaded group memberships (set at load time, not a column).
Groups []Group
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateUserInput contains the fields required to create a new user.
// Access policy comes from group memberships, not per-user columns.
type CreateUserInput struct {
Email string // required
Username string // required
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)
MaxTranscodes *int // nil = use DB default (2)
MaxProfiles *int // nil = use DB default (5); minimum 1
DownloadAllowed *bool // nil = use DB default (true)
DownloadTranscodeAllowed *bool // nil = use DB default (false)
// GroupIDs are the group memberships to create. nil means the caller
// resolves defaults via the AccountProvisioner.
GroupIDs []int
}
// UpdateUserInput contains optional fields for updating a user.
@@ -48,14 +49,7 @@ type UpdateUserInput struct {
Username *string
Password *string // plaintext, will be bcrypt-hashed if provided
LocalPasswordLoginEnabled *bool
Role *string
Permissions *[]string
Enabled *bool
LibraryIDs *[]int
MaxPlaybackQuality *string
MaxStreams *int
MaxTranscodes *int
MaxProfiles *int
DownloadAllowed *bool
DownloadTranscodeAllowed *bool
// GroupIDs replaces the user's group memberships. nil = don't update.
GroupIDs *[]int
}
+3
View File
@@ -388,6 +388,9 @@ type CommandIssuedBy struct {
Kind string `json:"kind"`
}
// IssuedByKindAdmin marks commands issued by an administrator action.
const IssuedByKindAdmin = "admin"
// NewCommandEnvelope creates a validated command envelope.
func NewCommandEnvelope(sessionID, commandID string, name CommandName, payload json.RawMessage) (CommandEnvelope, error) {
normalizedPayload, err := normalizeJSONPayload(payload)
@@ -0,0 +1,207 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE groups (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
built_in boolean NOT NULL DEFAULT false,
permissions text[] NOT NULL DEFAULT '{}',
library_ids integer[],
max_streams integer NOT NULL DEFAULT 6,
max_transcodes integer NOT NULL DEFAULT 2,
max_profiles integer NOT NULL DEFAULT 5,
max_playback_quality text NOT NULL DEFAULT '',
download_allowed boolean NOT NULL DEFAULT true,
download_transcode_allowed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE user_groups (
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
INSERT INTO groups (slug, name, description, built_in, permissions)
VALUES
('administrators', 'Administrators',
'Full access to everything, including server administration.', true,
ARRAY['admin']),
('users', 'Users',
'Default group for new accounts.', true,
ARRAY['marker_edit']);
-- Admins -> administrators (admin implies unrestricted policy).
INSERT INTO user_groups (user_id, group_id)
SELECT u.id, g.id
FROM users u
JOIN groups g ON g.slug = 'administrators'
WHERE u.role = 'admin';
-- Non-admin users whose policy deviates from the defaults: bucket by
-- distinct policy tuple, one group per tuple, members join ONLY that group
-- (joining 'users' too would erase restrictions via the permissive union).
WITH outliers AS (
SELECT u.*
FROM users u
WHERE u.role IS DISTINCT FROM 'admin'
AND (
COALESCE(u.permissions, '{}') IS DISTINCT FROM ARRAY['marker_edit']::text[]
OR u.library_ids IS NOT NULL
OR u.max_streams IS DISTINCT FROM 6
OR u.max_transcodes IS DISTINCT FROM 2
OR u.max_profiles IS DISTINCT FROM 5
OR COALESCE(u.max_playback_quality, '') IS DISTINCT FROM ''
OR u.download_allowed IS DISTINCT FROM true
OR u.download_transcode_allowed IS DISTINCT FROM false
)
),
buckets AS (
SELECT o.id AS user_id,
dense_rank() OVER (
ORDER BY o.permissions, o.library_ids, o.max_streams,
o.max_transcodes, o.max_profiles,
o.max_playback_quality, o.download_allowed,
o.download_transcode_allowed
) AS n,
o.permissions, o.library_ids, o.max_streams, o.max_transcodes,
o.max_profiles, o.max_playback_quality, o.download_allowed,
o.download_transcode_allowed
FROM outliers o
),
distinct_buckets AS (
SELECT DISTINCT ON (n) n, permissions, library_ids, max_streams,
max_transcodes, max_profiles, max_playback_quality,
download_allowed, download_transcode_allowed
FROM buckets
),
created AS (
INSERT INTO groups (slug, name, description, built_in, permissions,
library_ids, max_streams, max_transcodes,
max_profiles, max_playback_quality, download_allowed,
download_transcode_allowed)
SELECT 'migrated-policy-' || n,
'Migrated policy ' || n,
'Auto-created during the group migration to preserve a pre-existing per-user policy.',
false,
array_remove(COALESCE(permissions, '{}'), 'admin'),
library_ids,
COALESCE(max_streams, 6),
COALESCE(max_transcodes, 2),
COALESCE(max_profiles, 5),
COALESCE(max_playback_quality, ''),
COALESCE(download_allowed, true),
COALESCE(download_transcode_allowed, false)
FROM distinct_buckets
RETURNING id, slug
)
INSERT INTO user_groups (user_id, group_id)
SELECT b.user_id, c.id
FROM buckets b
JOIN created c ON c.slug = 'migrated-policy-' || b.n;
-- Everyone not yet in any group (non-admin, default policy) -> users.
INSERT INTO user_groups (user_id, group_id)
SELECT u.id, g.id
FROM users u
JOIN groups g ON g.slug = 'users'
WHERE NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id = u.id);
ALTER TABLE users
DROP COLUMN role,
DROP COLUMN permissions,
DROP COLUMN library_ids,
DROP COLUMN max_streams,
DROP COLUMN max_transcodes,
DROP COLUMN max_profiles,
DROP COLUMN max_playback_quality,
DROP COLUMN download_allowed,
DROP COLUMN download_transcode_allowed;
UPDATE users SET access_policy_revision = access_policy_revision + 1;
INSERT INTO server_settings (key, value)
VALUES ('users.default_group_slugs', '["users"]')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN role text,
ADD COLUMN permissions text[] NOT NULL DEFAULT ARRAY['marker_edit']::text[],
ADD COLUMN library_ids integer[],
ADD COLUMN max_streams integer NOT NULL DEFAULT 6,
ADD COLUMN max_transcodes integer NOT NULL DEFAULT 2,
ADD COLUMN max_profiles integer NOT NULL DEFAULT 5,
ADD COLUMN max_playback_quality text NOT NULL DEFAULT '',
ADD COLUMN download_allowed boolean NOT NULL DEFAULT true,
ADD COLUMN download_transcode_allowed boolean NOT NULL DEFAULT false;
-- Best-effort flattening of effective policy back onto users.
UPDATE users u SET
role = CASE WHEN EXISTS (
SELECT 1 FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id AND 'admin' = ANY(g.permissions)
) THEN 'admin' ELSE 'user' END,
permissions = COALESCE((
SELECT array_agg(DISTINCT p ORDER BY p)
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id, unnest(g.permissions) AS p
WHERE ug.user_id = u.id AND p <> 'admin'
), '{}'),
library_ids = CASE WHEN EXISTS (
SELECT 1 FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id
AND (g.library_ids IS NULL OR 'admin' = ANY(g.permissions))
) THEN NULL ELSE (
SELECT array_agg(DISTINCT lid ORDER BY lid)
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id, unnest(g.library_ids) AS lid
WHERE ug.user_id = u.id
) END,
max_streams = COALESCE((
SELECT MAX(g.max_streams) FROM user_groups ug
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 6),
max_transcodes = COALESCE((
SELECT MAX(g.max_transcodes) FROM user_groups ug
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 2),
max_profiles = COALESCE((
SELECT MAX(g.max_profiles) FROM user_groups ug
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 5),
download_allowed = EXISTS (
SELECT 1 FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id AND g.download_allowed),
download_transcode_allowed = EXISTS (
SELECT 1 FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id AND g.download_transcode_allowed),
-- Flatten max_playback_quality: rank-order mirrors internal/access/quality.go
-- ('' = unrestricted is most permissive; most permissive group wins).
max_playback_quality = COALESCE((
SELECT CASE WHEN bool_or(g.max_playback_quality = '') THEN ''
ELSE split_part(MAX(CASE g.max_playback_quality
WHEN '4320p' THEN '5:4320p'
WHEN '2160p' THEN '4:2160p'
WHEN '1080p' THEN '3:1080p'
WHEN '720p' THEN '2:720p'
WHEN '480p' THEN '1:480p'
ELSE '0:' END), ':', 2)
END
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id), '');
DELETE FROM server_settings WHERE key = 'users.default_group_slugs';
DROP TABLE user_groups;
DROP TABLE groups;
UPDATE users SET access_policy_revision = access_policy_revision + 1;
-- +goose StatementEnd
+2
View File
@@ -42,6 +42,7 @@ import AdminDashboard from "@/pages/AdminDashboard";
import AdminActivity from "@/pages/AdminActivity";
import AdminLogs from "@/pages/AdminLogs";
import AdminUsers from "@/pages/AdminUsers";
import AdminGroups from "@/pages/AdminGroups";
import AdminRequests from "@/pages/AdminRequests";
import AdminAutoscan from "@/pages/AdminAutoscan";
import AdminDevices from "@/pages/AdminDevices";
@@ -392,6 +393,7 @@ function AppRoutes() {
<Route path="history-import" element={<AdminHistoryImport />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
<Route path="groups" element={<AdminGroups />} />
<Route path="devices" element={<AdminDevices />} />
<Route path="devices/:userId/:deviceId" element={<AdminDevices />} />
<Route path="nodes" element={<AdminNodes />} />
+63 -18
View File
@@ -2019,6 +2019,64 @@ export interface RequestListParams {
}
// Admin
export interface AdminGroupRef {
id: number;
slug: string;
name: string;
}
export interface AdminGroup {
id: number;
slug: string;
name: string;
description: string;
built_in: boolean;
permissions: string[];
/** null = access to all libraries */
library_ids: number[] | null;
max_streams: number;
max_transcodes: number;
max_profiles: number;
max_playback_quality: string;
download_allowed: boolean;
download_transcode_allowed: boolean;
member_count: number;
created_at: string;
updated_at: string;
}
export interface CreateGroupRequest {
name: string;
description?: string;
permissions?: string[];
/** null/omitted = access to all libraries */
library_ids?: number[] | null;
max_streams?: number;
max_transcodes?: number;
max_profiles?: number;
max_playback_quality?: string;
download_allowed?: boolean;
download_transcode_allowed?: boolean;
}
export type UpdateGroupRequest = Partial<CreateGroupRequest>;
export interface GroupMember {
user_id: number;
username: string;
email: string;
enabled: boolean;
}
export interface GroupMembersPage {
members: GroupMember[];
total: number;
offset: number;
limit: number;
}
// AdminUser policy fields (role, permissions, library_ids, max_*, download_*)
// are READ-ONLY effective values derived from the user's groups.
export interface AdminUser {
id: number;
username: string;
@@ -2026,6 +2084,7 @@ export interface AdminUser {
role: string;
permissions: string[];
enabled: boolean;
groups: AdminGroupRef[];
library_ids: number[] | null;
max_playback_quality: string;
max_streams: number;
@@ -2042,33 +2101,19 @@ export interface CreateUserRequest {
username: string;
email: string;
password: string;
role: string;
permissions?: string[];
create_default_profile?: boolean;
default_profile_name?: string;
library_ids?: number[] | null;
max_playback_quality?: string;
max_streams?: number;
max_transcodes?: number;
max_profiles?: number;
download_allowed?: boolean;
download_transcode_allowed?: boolean;
/** Group memberships to assign. Omitted = configured default groups. */
group_ids?: number[];
}
export interface UpdateUserRequest {
username?: string;
email?: string;
password?: string;
role?: string;
permissions?: string[];
enabled?: boolean;
library_ids?: number[] | null;
max_playback_quality?: string;
max_streams?: number;
max_transcodes?: number;
max_profiles?: number;
download_allowed?: boolean;
download_transcode_allowed?: boolean;
/** Replaces the user's group memberships. Omitted = unchanged. */
group_ids?: number[];
}
export interface AdminStats {
+2
View File
@@ -6,6 +6,7 @@ import {
LayoutPanelTop,
PanelsTopLeft,
Users,
Shield,
MonitorSmartphone,
History,
Captions,
@@ -116,6 +117,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
label: "Users",
items: [
{ label: "Users", icon: Users, href: "/admin/users" },
{ label: "Groups", icon: Shield, href: "/admin/groups" },
{ label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" },
{ label: "Playback History", icon: History, href: "/admin/history" },
{ label: "History Import", icon: Download, href: "/admin/history-import" },
+240
View File
@@ -0,0 +1,240 @@
import { useId, useMemo, useState } from "react";
import type { FormEvent } from "react";
import type { AdminGroup, AdminUser, CreateUserRequest, UpdateUserRequest } from "@/api/types";
import { useAdminGroups } from "@/hooks/queries/admin/groups";
import { Skeleton } from "@/components/ui/skeleton";
import { useCreateUser, useUpdateUser } from "@/hooks/queries/admin/users";
import { EffectiveAccessSummary } from "@/components/admin/EffectiveAccessSummary";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatLibraryAccess, formatLimit } from "@/lib/group-policy";
import { formatPlaybackQualityPreset } from "@/lib/playback-quality";
import { permissionLabel } from "@/lib/permissions";
/** Slug of the built-in group preselected for new users. */
const USERS_GROUP_SLUG = "users";
const GROUP_UNION_HINT =
"Users in multiple groups get the most permissive combination of their groups.";
function groupPolicySummary(group: AdminGroup): string {
const parts: string[] = [];
parts.push(
group.permissions.length === 0
? "No permissions"
: group.permissions.map(permissionLabel).join(", "),
);
parts.push(formatLibraryAccess(group.library_ids));
parts.push(`${formatLimit(group.max_streams)} streams`);
parts.push(`${formatPlaybackQualityPreset(group.max_playback_quality)} quality`);
parts.push(group.download_allowed ? "Downloads" : "No downloads");
return parts.join(" · ");
}
function GroupMembershipSelector({
groups,
selectedIds,
onChange,
}: {
groups: AdminGroup[];
selectedIds: number[];
onChange: (ids: number[]) => void;
}) {
if (groups.length === 0) {
return <p className="text-muted-foreground text-sm">No groups available.</p>;
}
return (
<div className="space-y-2">
{groups.map((group) => (
<div
key={group.id}
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{group.name}</span>
{group.built_in && <Badge variant="secondary">Built-in</Badge>}
</div>
<p className="text-muted-foreground text-xs">{groupPolicySummary(group)}</p>
</div>
<Switch
checked={selectedIds.includes(group.id)}
aria-label={`Member of ${group.name}`}
onCheckedChange={(checked) =>
onChange(
checked ? [...selectedIds, group.id] : selectedIds.filter((id) => id !== group.id),
)
}
/>
</div>
))}
</div>
);
}
/**
* Create/edit form for admin users. Identity fields are edited directly;
* permissions, library access, and limits are managed via group membership.
*/
export function AdminUserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) {
const { data: groups, isLoading: groupsLoading, isError: groupsError } = useAdminGroups();
const groupsLoaded = !groupsLoading && !groupsError && groups !== undefined;
const [username, setUsername] = useState(user?.username ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [password, setPassword] = useState("");
const [enabled, setEnabled] = useState(user?.enabled ?? true);
// null = untouched; new users fall back to the built-in default group once
// the groups list loads.
const [groupIds, setGroupIds] = useState<number[] | null>(
user ? user.groups.map((g) => g.id) : null,
);
const defaultGroupIds = useMemo(() => {
const usersGroup = groups?.find((g) => g.slug === USERS_GROUP_SLUG);
return usersGroup ? [usersGroup.id] : [];
}, [groups]);
const selectedGroupIds = groupIds ?? defaultGroupIds;
const usernameId = useId();
const emailId = useId();
const passwordId = useId();
const enabledId = useId();
const createMutation = useCreateUser();
const updateMutation = useUpdateUser();
const isPending = createMutation.isPending || updateMutation.isPending;
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (user) {
const body: UpdateUserRequest = {
username,
email,
enabled,
group_ids: selectedGroupIds,
};
if (password) body.password = password;
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
} else {
// Only include group_ids when the groups list has successfully loaded OR
// the admin explicitly touched the selector. If groups are still loading
// or errored and the selector was never touched, omit the field entirely
// so the backend applies its own default-group provisioning.
const includeGroupIds = groupsLoaded || groupIds !== null;
const body: CreateUserRequest = {
username,
email,
password,
create_default_profile: true,
...(includeGroupIds && { group_ids: selectedGroupIds }),
};
createMutation.mutate(body, { onSuccess: onClose });
}
}
return (
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
<Tabs defaultValue="account" className="min-h-0 flex-1">
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
<TabsTrigger value="account" className="flex-none px-1">
Account
</TabsTrigger>
<TabsTrigger value="groups" className="flex-none px-1">
Groups
</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<TabsContent value="account" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={usernameId}>Username</Label>
<Input
id={usernameId}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={emailId}>Email</Label>
<Input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={passwordId}>
Password {user && "(leave blank to keep current)"}
</Label>
<Input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!user}
/>
</div>
</div>
{user && (
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Account status</div>
<div className="text-muted-foreground text-xs">
Disable access without deleting the user.
</div>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={enabledId} className="text-xs">
Enabled
</Label>
<Switch id={enabledId} checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
)}
</TabsContent>
<TabsContent value="groups" className="mt-0 space-y-4">
{groupsLoading ? (
<div className="space-y-2">
<Skeleton className="h-[52px] w-full rounded-md" />
<Skeleton className="h-[52px] w-full rounded-md" />
<Skeleton className="h-[52px] w-full rounded-md" />
</div>
) : groupsError ? (
<p className="text-muted-foreground text-sm">
{"Couldn't load groups — the user will be created in the default group."}
</p>
) : (
<GroupMembershipSelector
groups={groups ?? []}
selectedIds={selectedGroupIds}
onChange={setGroupIds}
/>
)}
{!groupsError && <p className="text-muted-foreground text-xs">{GROUP_UNION_HINT}</p>}
{user && (
<div className="space-y-2">
<Label>Effective access</Label>
<div className="border-border rounded-md border">
<EffectiveAccessSummary user={user} />
</div>
</div>
)}
</TabsContent>
</div>
</Tabs>
<div className="border-border mt-4 border-t pt-4">
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import type { AdminUser } from "@/api/types";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { Badge } from "@/components/ui/badge";
import { formatPlaybackQualityPreset } from "@/lib/playback-quality";
import { PERMISSION_ADMIN, permissionLabel } from "@/lib/permissions";
function formatUnlimited(value: number) {
return value === 0 ? "Unlimited" : String(value);
}
/**
* Read-only summary of a user's effective access (permissions, library
* access, and limits) derived from group membership.
*/
export function EffectiveAccessSummary({ user }: { user: AdminUser }) {
const { data: libraries = [] } = useAdminLibraries();
const libraryNames =
user.library_ids === null
? "All libraries"
: user.library_ids.length === 0
? "None"
: user.library_ids
.map((id) => libraries.find((l) => l.id === id)?.name ?? `#${id}`)
.join(", ");
return (
<div>
<div className="divide-border divide-y">
<SummaryRow label="Permissions">
{user.permissions.length === 0 ? (
<span className="text-muted-foreground text-sm">None</span>
) : (
<div className="flex flex-wrap justify-end gap-1">
{user.permissions.map((permission) => (
<Badge
key={permission}
variant={permission === PERMISSION_ADMIN ? "default" : "outline"}
>
{permissionLabel(permission)}
</Badge>
))}
</div>
)}
</SummaryRow>
<SummaryRow label="Library Access">{libraryNames}</SummaryRow>
<SummaryRow label="Max Playback Quality">
{formatPlaybackQualityPreset(user.max_playback_quality)}
</SummaryRow>
<SummaryRow label="Max Streams">{formatUnlimited(user.max_streams)}</SummaryRow>
<SummaryRow label="Max Transcodes">{formatUnlimited(user.max_transcodes)}</SummaryRow>
<SummaryRow label="Max Profiles">{formatUnlimited(user.max_profiles)}</SummaryRow>
<SummaryRow label="Downloads">
{user.download_allowed ? "Allowed" : "Not allowed"}
</SummaryRow>
<SummaryRow label="Download Transcode">
{user.download_transcode_allowed ? "Allowed" : "Not allowed"}
</SummaryRow>
</div>
<p className="text-muted-foreground px-4 py-2.5 text-xs">Derived from group membership.</p>
</div>
);
}
function SummaryRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<span className="text-muted-foreground shrink-0 text-sm">{label}</span>
{typeof children === "string" ? (
<span className="text-right text-sm font-medium">{children}</span>
) : (
children
)}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type {
AdminGroup,
CreateGroupRequest,
GroupMembersPage,
UpdateGroupRequest,
} from "@/api/types";
import { adminKeys } from "../keys";
import { toast } from "sonner";
const ADMIN_STALE_TIME = 30_000;
interface AdminGroupsResponse {
groups: AdminGroup[];
}
// Group mutations also invalidate the admin users caches: membership and
// policy changes alter the effective fields on admin user rows.
function invalidateGroupCaches(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
}
export function useAdminGroups() {
return useQuery({
queryKey: adminKeys.groups(),
queryFn: () => api<AdminGroupsResponse>("/admin/groups").then((d) => d?.groups ?? []),
staleTime: ADMIN_STALE_TIME,
});
}
export function useAdminGroup(id: number) {
return useQuery({
queryKey: adminKeys.groupDetail(id),
queryFn: () => api<AdminGroup>(`/admin/groups/${id}`),
enabled: id > 0,
staleTime: ADMIN_STALE_TIME,
});
}
export function useGroupMembers(id: number, offset = 0, limit = 50) {
return useQuery({
queryKey: adminKeys.groupMembers(id, offset, limit),
queryFn: () =>
api<GroupMembersPage>(`/admin/groups/${id}/members?offset=${offset}&limit=${limit}`),
enabled: id > 0,
staleTime: ADMIN_STALE_TIME,
});
}
export function useCreateGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateGroupRequest) =>
api<AdminGroup>("/admin/groups", {
method: "POST",
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success("Group created");
invalidateGroupCaches(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save");
},
});
}
export function useUpdateGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateGroupRequest }) =>
api<AdminGroup>(`/admin/groups/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success("Group updated");
invalidateGroupCaches(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save");
},
});
}
export function useDeleteGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/admin/groups/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Group deleted");
invalidateGroupCaches(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete");
},
});
}
export function useAddGroupMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ groupId, userId }: { groupId: number; userId: number }) =>
api(`/admin/groups/${groupId}/members/${userId}`, { method: "PUT" }),
onSuccess: () => {
toast.success("Member added");
invalidateGroupCaches(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to add member");
},
});
}
export function useRemoveGroupMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ groupId, userId }: { groupId: number; userId: number }) =>
api(`/admin/groups/${groupId}/members/${userId}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Member removed");
invalidateGroupCaches(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to remove member");
},
});
}
+8 -1
View File
@@ -46,11 +46,12 @@ function invalidateAdminDeviceCaches(
queryClient.invalidateQueries({ queryKey: adminKeys.devices() });
}
export function useAdminUsers() {
export function useAdminUsers(options?: { enabled?: boolean }) {
return useQuery({
queryKey: adminKeys.users(),
queryFn: () => api<AdminUser[]>("/admin/users").then((d) => d ?? []),
staleTime: ADMIN_STALE_TIME,
enabled: options?.enabled ?? true,
});
}
@@ -73,6 +74,8 @@ export function useCreateUser() {
onSuccess: () => {
toast.success("User created");
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
// Membership changes alter group member counts.
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save");
@@ -93,6 +96,8 @@ export function useUpdateUser() {
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
queryClient.invalidateQueries({ queryKey: adminKeys.userDetail(variables.id) });
queryClient.invalidateQueries({ queryKey: adminKeys.userProfiles(variables.id) });
// group_ids changes alter group member counts.
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save");
@@ -107,6 +112,8 @@ export function useDeleteUser() {
onSuccess: () => {
toast.success("User deleted");
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
// Deleting a user removes their group memberships.
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete");
+4
View File
@@ -325,6 +325,10 @@ export const adminKeys = {
["admin", "users", userId, "deviceSettings", key] as const,
userSubtitleDeviceSettings: (userId: number) =>
["admin", "users", userId, "deviceSettings", "subtitle_appearance"] as const,
groups: () => ["admin", "groups"] as const,
groupDetail: (groupId: number) => ["admin", "groups", groupId] as const,
groupMembers: (groupId: number, offset: number, limit: number) =>
["admin", "groups", groupId, "members", offset, limit] as const,
devices: () => ["admin", "devices"] as const,
deviceDetail: (userId: number, deviceId: string) =>
["admin", "devices", userId, deviceId] as const,
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared formatters for group access-policy values in admin views.
*/
/** Formats a numeric limit where 0 means unlimited. */
export function formatLimit(value: number): string {
return value === 0 ? "∞" : String(value);
}
/** Formats library access where null means unrestricted. */
export function formatLibraryAccess(ids: number[] | null): string {
if (ids === null) return "All libraries";
return `${ids.length} ${ids.length === 1 ? "library" : "libraries"}`;
}
+11
View File
@@ -1,8 +1,19 @@
import type { User } from "@/api/types";
export const PERMISSION_ADMIN = "admin";
export const PERMISSION_METADATA_CURATION = "metadata_curation";
export const PERMISSION_MARKER_EDIT = "marker_edit";
const PERMISSION_LABELS: Record<string, string> = {
[PERMISSION_ADMIN]: "Administrator",
[PERMISSION_MARKER_EDIT]: "Marker Editing",
[PERMISSION_METADATA_CURATION]: "Metadata Curation",
};
export function permissionLabel(permission: string) {
return PERMISSION_LABELS[permission] ?? permission;
}
export function hasPermission(
user: Pick<User, "role" | "permissions"> | null | undefined,
permission: string,
+678
View File
@@ -0,0 +1,678 @@
import { useMemo, useState, useId } from "react";
import type { FormEvent } from "react";
import type { AdminGroup, CreateGroupRequest, UpdateGroupRequest } from "@/api/types";
import {
useAdminGroups,
useGroupMembers,
useCreateGroup,
useUpdateGroup,
useDeleteGroup,
useAddGroupMember,
useRemoveGroupMember,
} from "@/hooks/queries/admin/groups";
import { useAdminUsers } from "@/hooks/queries/admin/users";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Check, Minus, Pencil, Plus, Search, Trash2, UserPlus, X } from "lucide-react";
import {
PLAYBACK_QUALITY_OPTIONS,
formatPlaybackQualityPreset,
playbackQualityPresetFromValue,
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import { formatLibraryAccess, formatLimit } from "@/lib/group-policy";
import {
PERMISSION_ADMIN,
PERMISSION_MARKER_EDIT,
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
permissionLabel,
setAssignedPermission,
} from "@/lib/permissions";
const ADMINISTRATORS_SLUG = "administrators";
const MEMBERS_PAGE_SIZE = 50;
const PERMISSION_OPTIONS: Array<{ id: string; label: string; description: string }> = [
{
id: PERMISSION_ADMIN,
label: permissionLabel(PERMISSION_ADMIN),
description: "Full access to all features and settings.",
},
{
id: PERMISSION_MARKER_EDIT,
label: permissionLabel(PERMISSION_MARKER_EDIT),
description: "Edit intro, recap, credits, and preview markers within assigned libraries.",
},
{
id: PERMISSION_METADATA_CURATION,
label: permissionLabel(PERMISSION_METADATA_CURATION),
description: "Edit, refresh, and rematch metadata within assigned libraries.",
},
];
export default function AdminGroups() {
const { data: groups = [], isLoading } = useAdminGroups();
const deleteMutation = useDeleteGroup();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<AdminGroup | null>(null);
const [confirmDeleteGroup, setConfirmDeleteGroup] = useState<AdminGroup | null>(null);
if (isLoading)
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full rounded-lg" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
);
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmDeleteGroup !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteGroup(null);
}}
title="Delete group"
description={`Delete group "${confirmDeleteGroup?.name}"? Members lose this group's permissions and limits. This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={() => {
if (confirmDeleteGroup) deleteMutation.mutate(confirmDeleteGroup.id);
setConfirmDeleteGroup(null);
}}
/>
<div className="page-header">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Groups</h1>
<p className="page-subtitle text-sm sm:text-base">
Define permissions, library access, and playback limits shared by their members.
</p>
</div>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditingGroup(null);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-1 h-4 w-4" /> Add Group
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingGroup ? "Edit Group" : "Create Group"}</DialogTitle>
</DialogHeader>
<GroupForm
group={editingGroup}
onClose={() => {
setDialogOpen(false);
setEditingGroup(null);
}}
/>
</DialogContent>
</Dialog>
</div>
<TooltipProvider>
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Members</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Libraries</TableHead>
<TableHead>Streams / Transcodes</TableHead>
<TableHead>Quality</TableHead>
<TableHead>Downloads</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{group.name}</span>
{group.built_in && <Badge variant="secondary">Built-in</Badge>}
</div>
{group.description && (
<div className="text-muted-foreground text-xs">{group.description}</div>
)}
</TableCell>
<TableCell>{group.member_count}</TableCell>
<TableCell>
{group.permissions.length === 0 ? (
<span className="text-muted-foreground text-xs">None</span>
) : (
<div className="flex flex-wrap gap-1">
{group.permissions.map((permission) => (
<Badge
key={permission}
variant={permission === PERMISSION_ADMIN ? "default" : "outline"}
>
{permissionLabel(permission)}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>{formatLibraryAccess(group.library_ids)}</TableCell>
<TableCell>
{formatLimit(group.max_streams)} / {formatLimit(group.max_transcodes)}
</TableCell>
<TableCell>{formatPlaybackQualityPreset(group.max_playback_quality)}</TableCell>
<TableCell>
{group.download_allowed ? (
<Check className="h-4 w-4 text-green-500" aria-label="Downloads allowed" />
) : (
<Minus
className="text-muted-foreground h-4 w-4"
aria-label="Downloads not allowed"
/>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Edit ${group.name}`}
onClick={() => {
setEditingGroup(group);
setDialogOpen(true);
}}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</Button>
{group.built_in ? (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Delete ${group.name}`}
disabled
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Built-in groups cannot be deleted.</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Delete ${group.name}`}
onClick={() => setConfirmDeleteGroup(group)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
{groups.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground py-8 text-center">
No groups yet. Create one to assign permissions and limits.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TooltipProvider>
</div>
);
}
function GroupForm({ group, onClose }: { group: AdminGroup | null; onClose: () => void }) {
const { data: libraries = [] } = useAdminLibraries();
const isAdministrators = group?.slug === ADMINISTRATORS_SLUG;
const [name, setName] = useState(group?.name ?? "");
const [description, setDescription] = useState(group?.description ?? "");
const [permissions, setPermissions] = useState<string[]>(group?.permissions ?? []);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(group?.library_ids ?? null);
const [maxStreams, setMaxStreams] = useState<number>(group?.max_streams ?? 6);
const [maxTranscodes, setMaxTranscodes] = useState<number>(group?.max_transcodes ?? 2);
const [maxProfiles, setMaxProfiles] = useState<number>(group?.max_profiles ?? 5);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(group?.max_playback_quality),
);
const [downloadAllowed, setDownloadAllowed] = useState(group?.download_allowed ?? true);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
group?.download_transcode_allowed ?? false,
);
const nameId = useId();
const descriptionId = useId();
const maxStreamsId = useId();
const maxTranscodesId = useId();
const maxProfilesId = useId();
const maxPlaybackQualityId = useId();
const downloadAllowedId = useId();
const downloadTranscodeAllowedId = useId();
const createMutation = useCreateGroup();
const updateMutation = useUpdateGroup();
const isPending = createMutation.isPending || updateMutation.isPending;
function handleSubmit(e: FormEvent) {
e.preventDefault();
const body: CreateGroupRequest & UpdateGroupRequest = {
name,
description,
permissions,
library_ids: libraryIDs,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (group) {
updateMutation.mutate({ id: group.id, body }, { onSuccess: onClose });
} else {
createMutation.mutate(body, { onSuccess: onClose });
}
}
return (
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
<Tabs defaultValue="general" className="min-h-0 flex-1">
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
<TabsTrigger value="general" className="flex-none px-1">
General
</TabsTrigger>
<TabsTrigger value="access" className="flex-none px-1">
Access
</TabsTrigger>
<TabsTrigger value="limits" className="flex-none px-1">
Limits
</TabsTrigger>
{group && (
<TabsTrigger value="members" className="flex-none px-1">
Members ({group.member_count})
</TabsTrigger>
)}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<TabsContent value="general" className="mt-0 space-y-4">
<div className="space-y-2">
<Label htmlFor={nameId}>Name</Label>
<Input id={nameId} value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor={descriptionId}>Description</Label>
<Input
id={descriptionId}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Permissions</Label>
{PERMISSION_OPTIONS.map((option) => {
const locked = option.id === PERMISSION_ADMIN && isAdministrators;
return (
<div
key={option.id}
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div>
<div className="text-sm font-medium">{option.label}</div>
<p className="text-muted-foreground text-xs">{option.description}</p>
</div>
<Switch
checked={locked || hasAssignedPermission(permissions, option.id)}
disabled={locked}
aria-label={option.label}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, option.id, checked),
)
}
/>
</div>
);
})}
{isAdministrators && (
<p className="text-muted-foreground text-xs">
The administrators group always keeps the Administrator permission.
</p>
)}
</div>
<p className="text-muted-foreground text-xs">
Users in multiple groups get the most permissive combination of their groups.
</p>
</TabsContent>
<TabsContent value="access" className="mt-0 space-y-4">
<LibraryAccessSelector
libraries={libraries}
value={libraryIDs}
onChange={setLibraryIDs}
/>
</TabsContent>
<TabsContent value="limits" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={maxStreamsId}>Max Streams</Label>
<Input
id={maxStreamsId}
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={maxTranscodesId}>Max Transcodes</Label>
<Input
id={maxTranscodesId}
type="number"
min={0}
value={maxTranscodes}
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
<Input
id={maxProfilesId}
type="number"
min={0}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1 sm:col-span-2">
<Label htmlFor={maxPlaybackQualityId}>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) =>
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
}
>
<SelectTrigger id={maxPlaybackQualityId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{
PLAYBACK_QUALITY_OPTIONS.find(
(option) => option.value === maxPlaybackQualityPreset,
)?.description
}
</p>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
<Switch
id={downloadAllowedId}
checked={downloadAllowed}
onCheckedChange={setDownloadAllowed}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadTranscodeAllowedId}>Download Transcode Allowed</Label>
<Switch
id={downloadTranscodeAllowedId}
checked={downloadTranscodeAllowed}
onCheckedChange={setDownloadTranscodeAllowed}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
Users in multiple groups get the most permissive combination of their groups.
</p>
</TabsContent>
{group && (
<TabsContent value="members" className="mt-0 space-y-4">
<GroupMembersSection group={group} />
</TabsContent>
)}
</div>
</Tabs>
<div className="border-border mt-4 border-t pt-4">
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}
function GroupMembersSection({ group }: { group: AdminGroup }) {
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
const { data, isLoading } = useGroupMembers(group.id, offset, MEMBERS_PAGE_SIZE);
// Only fetch the user list once the admin starts typing a search.
const { data: users = [] } = useAdminUsers({ enabled: search.trim().length > 0 });
const addMutation = useAddGroupMember();
const removeMutation = useRemoveGroupMember();
const members = data?.members ?? [];
const total = data?.total ?? 0;
const candidates = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return [];
return users
.filter(
(u) =>
!u.groups.some((g) => g.id === group.id) &&
(u.username?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)),
)
.slice(0, 8);
}, [users, search, group.id]);
function handleRemove(userId: number) {
removeMutation.mutate(
{ groupId: group.id, userId },
{
onSuccess: () => {
// Step back a page when removing the last member on a later page.
if (members.length === 1 && offset > 0) {
setOffset(offset - MEMBERS_PAGE_SIZE);
}
},
},
);
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search users to add..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pr-9 pl-9"
/>
{search && (
<button
type="button"
aria-label="Clear search"
onClick={() => setSearch("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{search.trim() && (
<div className="grid gap-1.5">
{candidates.length === 0 ? (
<p className="text-muted-foreground text-xs">No matching users to add.</p>
) : (
candidates.map((u) => (
<div
key={u.id}
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-1.5"
>
<div className="min-w-0">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-muted-foreground ml-2 text-xs">{u.email}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={addMutation.isPending}
onClick={() =>
addMutation.mutate(
{ groupId: group.id, userId: u.id },
{ onSuccess: () => setSearch("") },
)
}
>
<UserPlus className="mr-1 h-3 w-3" aria-hidden="true" /> Add
</Button>
</div>
))
)}
</div>
)}
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : members.length === 0 ? (
<p className="text-muted-foreground text-sm">This group has no members.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.user_id}>
<TableCell className="font-medium">{member.username}</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
<Badge variant={member.enabled ? "outline" : "destructive"}>
{member.enabled ? "Active" : "Disabled"}
</Badge>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Remove ${member.username} from group`}
disabled={removeMutation.isPending}
onClick={() => handleRemove(member.user_id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{total > MEMBERS_PAGE_SIZE && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Showing {offset + 1}-{Math.min(offset + MEMBERS_PAGE_SIZE, total)} of {total}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOffset((o) => Math.max(0, o - MEMBERS_PAGE_SIZE))}
disabled={offset === 0}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOffset((o) => o + MEMBERS_PAGE_SIZE)}
disabled={offset + MEMBERS_PAGE_SIZE >= total}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
+21 -323
View File
@@ -1,10 +1,8 @@
import { useId, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useMemo, useState } from "react";
import { useParams, Link } from "react-router";
import {
type AdminDeviceSetting,
useAdminUser,
useUpdateUser,
useDeleteUser,
useImpersonateUser,
useAdminUserDeviceSettings,
@@ -17,21 +15,13 @@ import {
} from "@/hooks/queries/admin/users";
import { useAdminUserProfiles } from "@/hooks/queries/admin/history";
import { useAdminPlaybackHistory } from "@/hooks/queries/admin/history";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { useUserIPs } from "@/hooks/queries/admin/ips";
import type {
AdminSettingEntry,
AdminUser,
AdminUserProfile,
UpdateUserRequest,
UserIPEntry,
} from "@/api/types";
import type { AdminSettingEntry, AdminUser, AdminUserProfile, UserIPEntry } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
import { AdminUserForm } from "@/components/admin/AdminUserForm";
import { EffectiveAccessSummary } from "@/components/admin/EffectiveAccessSummary";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Table,
@@ -48,30 +38,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowUpRight, ChevronRight, Pencil, RotateCcw, Settings2, UserCircle } from "lucide-react";
import { useNavigate } from "react-router";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useAuth } from "@/hooks/useAuth";
import {
PLAYBACK_QUALITY_OPTIONS,
formatPlaybackQualityPreset,
playbackQualityPresetFromValue,
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import {
PERMISSION_MARKER_EDIT,
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
setAssignedPermission,
} from "@/lib/permissions";
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest";
import {
@@ -156,7 +126,7 @@ export default function AdminUserDetail() {
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
<EditUserForm user={user} onClose={() => setEditOpen(false)} />
<AdminUserForm user={user} onClose={() => setEditOpen(false)} />
</DialogContent>
</Dialog>
<Button
@@ -245,20 +215,6 @@ export default function AdminUserDetail() {
}
function OverviewTab({ user }: { user: AdminUser }) {
const { data: libraries = [] } = useAdminLibraries();
const libraryNames =
user.library_ids === null
? "All libraries"
: user.library_ids.length === 0
? "None"
: user.library_ids
.map((id) => {
const lib = libraries.find((l) => l.id === id);
return lib ? lib.name : `#${id}`;
})
.join(", ");
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="surface-panel overflow-hidden rounded-2xl border-0">
@@ -270,6 +226,20 @@ function OverviewTab({ user }: { user: AdminUser }) {
<DetailRow label="Email" value={user.email} />
<DetailRow label="Role" value={user.role} />
<DetailRow label="Status" value={user.enabled ? "Active" : "Disabled"} />
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<span className="text-muted-foreground text-sm">Groups</span>
{user.groups.length === 0 ? (
<span className="text-muted-foreground text-sm">None</span>
) : (
<div className="flex flex-wrap justify-end gap-1">
{user.groups.map((group) => (
<Badge key={group.id} variant="outline">
{group.name}
</Badge>
))}
</div>
)}
</div>
<DetailRow label="Created" value={formatDate(user.created_at)} />
<DetailRow label="Updated" value={formatDate(user.updated_at)} />
</div>
@@ -277,45 +247,9 @@ function OverviewTab({ user }: { user: AdminUser }) {
<div className="surface-panel overflow-hidden rounded-2xl border-0">
<div className="border-border border-b px-4 py-3">
<h3 className="text-sm font-medium">Permissions & Limits</h3>
</div>
<div className="divide-border divide-y">
<DetailRow label="Library Access" value={libraryNames} />
<DetailRow
label="Marker Editing"
value={
hasAssignedPermission(user.permissions, PERMISSION_MARKER_EDIT)
? "Allowed"
: "Not allowed"
}
/>
<DetailRow
label="Metadata Curation"
value={
hasAssignedPermission(user.permissions, PERMISSION_METADATA_CURATION)
? "Allowed"
: "Not allowed"
}
/>
<DetailRow
label="Max Playback Quality"
value={formatPlaybackQualityPreset(user.max_playback_quality)}
/>
<DetailRow
label="Max Streams"
value={user.max_streams === 0 ? "Unlimited" : String(user.max_streams)}
/>
<DetailRow
label="Max Transcodes"
value={user.max_transcodes === 0 ? "Unlimited" : String(user.max_transcodes)}
/>
<DetailRow label="Max Profiles" value={String(user.max_profiles)} />
<DetailRow label="Downloads" value={user.download_allowed ? "Allowed" : "Not allowed"} />
<DetailRow
label="Download Transcode"
value={user.download_transcode_allowed ? "Allowed" : "Not allowed"}
/>
<h3 className="text-sm font-medium">Effective Access</h3>
</div>
<EffectiveAccessSummary user={user} />
</div>
</div>
);
@@ -893,242 +827,6 @@ function DeviceOverridesTab({ userId }: { userId: number }) {
);
}
function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void }) {
const { data: libraries = [] } = useAdminLibraries();
const [username, setUsername] = useState(user.username);
const [email, setEmail] = useState(user.email);
const [password, setPassword] = useState("");
const [role, setRole] = useState(user.role);
const [enabled, setEnabled] = useState(user.enabled);
const [permissions, setPermissions] = useState<string[]>(user.permissions ?? []);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user.library_ids);
const [maxStreams, setMaxStreams] = useState(user.max_streams);
const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes);
const [maxProfiles, setMaxProfiles] = useState(user.max_profiles);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(user.max_playback_quality),
);
const [downloadAllowed, setDownloadAllowed] = useState(user.download_allowed);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
user.download_transcode_allowed,
);
const markerEditId = useId();
const metadataCurationId = useId();
const updateMutation = useUpdateUser();
function handleSubmit(e: FormEvent) {
e.preventDefault();
const body: UpdateUserRequest = {
username,
email,
role,
permissions,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (password) body.password = password;
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
}
return (
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
<Tabs defaultValue="account" className="min-h-0 flex-1">
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
<TabsTrigger value="account" className="flex-none px-1">
Account
</TabsTrigger>
<TabsTrigger value="access" className="flex-none px-1">
Access
</TabsTrigger>
<TabsTrigger value="limits" className="flex-none px-1">
Limits
</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<TabsContent value="account" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Username</Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Password (leave blank to keep current)</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Account status</div>
<div className="text-muted-foreground text-xs">
Disable access without deleting the user.
</div>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs">Enabled</Label>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
</TabsContent>
<TabsContent value="access" className="mt-0 space-y-4">
<LibraryAccessSelector
libraries={libraries}
value={libraryIDs}
onChange={setLibraryIDs}
/>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={markerEditId}>Marker Editing</Label>
<p className="text-muted-foreground text-xs">
Edit intro, recap, credits, and preview markers within assigned libraries.
</p>
</div>
<Switch
id={markerEditId}
checked={hasAssignedPermission(permissions, PERMISSION_MARKER_EDIT)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_MARKER_EDIT, checked),
)
}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
<p className="text-muted-foreground text-xs">
Edit, refresh, and rematch metadata within assigned libraries.
</p>
</div>
<Switch
id={metadataCurationId}
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
)
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label>Downloads Allowed</Label>
<Switch checked={downloadAllowed} onCheckedChange={setDownloadAllowed} />
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label>Download Transcode Allowed</Label>
<Switch
checked={downloadTranscodeAllowed}
onCheckedChange={setDownloadTranscodeAllowed}
/>
</div>
</div>
</TabsContent>
<TabsContent value="limits" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>Max Streams</Label>
<Input
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label>Max Transcodes</Label>
<Input
type="number"
min={0}
value={maxTranscodes}
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label>Max Profiles</Label>
<Input
type="number"
min={1}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) =>
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{
PLAYBACK_QUALITY_OPTIONS.find(
(option) => option.value === maxPlaybackQualityPreset,
)?.description
}
</p>
</div>
</div>
</TabsContent>
</div>
</Tabs>
<div className="border-border mt-4 border-t pt-4">
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}
function formatDuration(seconds: number | null) {
if (!seconds || Number.isNaN(seconds)) return "0m";
const rounded = Math.max(0, Math.floor(seconds));
+21 -488
View File
@@ -1,19 +1,11 @@
import { useState, useId, useMemo } from "react";
import type { FormEvent, ReactNode } from "react";
import { useState, useMemo } from "react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { AdminUser, CreateUserRequest, UpdateUserRequest } from "@/api/types";
import {
useAdminUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from "@/hooks/queries/admin/users";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { useAdminServerSettings, useUpdateServerSetting } from "@/hooks/queries/admin/settings";
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
import type { AdminUser } from "@/api/types";
import { useAdminUsers, useDeleteUser } from "@/hooks/queries/admin/users";
import { AdminUserForm } from "@/components/admin/AdminUserForm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
@@ -37,34 +29,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronDown,
ChevronUp,
History,
Plus,
Pencil,
Trash2,
Settings2,
Search,
X,
} from "lucide-react";
import { ChevronDown, ChevronUp, History, Plus, Pencil, Trash2, Search, X } from "lucide-react";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Skeleton } from "@/components/ui/skeleton";
import InviteCodesTab from "./admin-settings/InviteCodesTab";
import {
PLAYBACK_QUALITY_OPTIONS,
playbackQualityPresetFromValue,
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import {
PERMISSION_MARKER_EDIT,
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
setAssignedPermission,
} from "@/lib/permissions";
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at";
@@ -74,7 +43,6 @@ export default function AdminUsers() {
const { data: users = [], isLoading } = useAdminUsers();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [defaultsOpen, setDefaultsOpen] = useState(false);
const [confirmDeleteUser, setConfirmDeleteUser] = useState<AdminUser | null>(null);
const deleteMutation = useDeleteUser();
const [search, setSearch] = useState("");
@@ -147,19 +115,6 @@ export default function AdminUsers() {
</p>
</div>
<div className="flex gap-2">
<Dialog open={defaultsOpen} onOpenChange={setDefaultsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings2 className="mr-1 h-4 w-4" /> User Defaults
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Default New User Settings</DialogTitle>
</DialogHeader>
<UserDefaultsForm onClose={() => setDefaultsOpen(false)} />
</DialogContent>
</Dialog>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
@@ -176,7 +131,7 @@ export default function AdminUsers() {
<DialogHeader>
<DialogTitle>{editingUser ? "Edit User" : "Create User"}</DialogTitle>
</DialogHeader>
<UserForm
<AdminUserForm
user={editingUser}
onClose={() => {
setDialogOpen(false);
@@ -247,6 +202,7 @@ export default function AdminUsers() {
>
Role
</SortableUserHead>
<TableHead>Groups</TableHead>
<SortableUserHead
field="enabled"
activeField={sortField}
@@ -286,6 +242,19 @@ export default function AdminUsers() {
<TableCell>
<Badge variant={u.role === "admin" ? "default" : "secondary"}>{u.role}</Badge>
</TableCell>
<TableCell>
{u.groups.length === 0 ? (
<span className="text-muted-foreground text-xs">None</span>
) : (
<div className="flex flex-wrap gap-1">
{u.groups.map((group) => (
<Badge key={group.id} variant="outline">
{group.name}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={u.enabled ? "outline" : "destructive"}>
{u.enabled ? "Active" : "Disabled"}
@@ -521,439 +490,3 @@ function formatRelativeTime(value?: string | null, fallback = "-") {
return fallback;
}
function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) {
const { data: settings } = useAdminServerSettings();
const { data: libraries = [] } = useAdminLibraries();
const [username, setUsername] = useState(user?.username ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [password, setPassword] = useState("");
const [role, setRole] = useState(user?.role ?? "user");
const [enabled, setEnabled] = useState(user?.enabled ?? true);
const [permissions, setPermissions] = useState<string[]>(
user?.permissions ?? [PERMISSION_MARKER_EDIT],
);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user?.library_ids ?? null);
const [maxStreams, setMaxStreams] = useState<number>(
user?.max_streams ?? Number(settings?.["defaults.max_streams"] ?? "6"),
);
const [maxTranscodes, setMaxTranscodes] = useState<number>(
user?.max_transcodes ?? Number(settings?.["defaults.max_transcodes"] ?? "2"),
);
const [maxProfiles, setMaxProfiles] = useState<number>(
user?.max_profiles ?? Number(settings?.["defaults.max_profiles"] ?? "5"),
);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(
user?.max_playback_quality ?? settings?.["defaults.max_playback_quality"],
),
);
const [downloadAllowed, setDownloadAllowed] = useState(
user?.download_allowed ?? settings?.["defaults.download_allowed"] !== "false",
);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
user?.download_transcode_allowed ??
settings?.["defaults.download_transcode_allowed"] === "true",
);
const usernameId = useId();
const emailId = useId();
const passwordId = useId();
const roleId = useId();
const enabledId = useId();
const markerEditId = useId();
const metadataCurationId = useId();
const downloadAllowedId = useId();
const downloadTranscodeAllowedId = useId();
const maxStreamsId = useId();
const maxTranscodesId = useId();
const maxProfilesId = useId();
const maxPlaybackQualityId = useId();
const createMutation = useCreateUser();
const updateMutation = useUpdateUser();
const isPending = createMutation.isPending || updateMutation.isPending;
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (user) {
const body: UpdateUserRequest = {
username,
email,
role,
permissions,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (password) body.password = password;
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
} else {
const body: CreateUserRequest = {
username,
email,
password,
role,
permissions,
create_default_profile: true,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset) || undefined,
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (libraryIDs !== null) body.library_ids = libraryIDs;
createMutation.mutate(body, { onSuccess: onClose });
}
}
return (
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
<Tabs defaultValue="account" className="min-h-0 flex-1">
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
<TabsTrigger value="account" className="flex-none px-1">
Account
</TabsTrigger>
<TabsTrigger value="access" className="flex-none px-1">
Access
</TabsTrigger>
<TabsTrigger value="limits" className="flex-none px-1">
Limits
</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<TabsContent value="account" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={usernameId}>Username</Label>
<Input
id={usernameId}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={emailId}>Email</Label>
<Input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={passwordId}>
Password {user && "(leave blank to keep current)"}
</Label>
<Input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!user}
/>
</div>
<div className="space-y-2">
<Label htmlFor={roleId}>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id={roleId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{user && (
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Account status</div>
<div className="text-muted-foreground text-xs">
Disable access without deleting the user.
</div>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={enabledId} className="text-xs">
Enabled
</Label>
<Switch id={enabledId} checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
)}
</TabsContent>
<TabsContent value="access" className="mt-0 space-y-4">
<LibraryAccessSelector
libraries={libraries}
value={libraryIDs}
onChange={setLibraryIDs}
/>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={markerEditId}>Marker Editing</Label>
<p className="text-muted-foreground text-xs">
Edit intro, recap, credits, and preview markers within assigned libraries.
</p>
</div>
<Switch
id={markerEditId}
checked={hasAssignedPermission(permissions, PERMISSION_MARKER_EDIT)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_MARKER_EDIT, checked),
)
}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
<p className="text-muted-foreground text-xs">
Edit, refresh, and rematch metadata within assigned libraries.
</p>
</div>
<Switch
id={metadataCurationId}
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
)
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
<Switch
id={downloadAllowedId}
checked={downloadAllowed}
onCheckedChange={setDownloadAllowed}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadTranscodeAllowedId}>Download Transcode Allowed</Label>
<Switch
id={downloadTranscodeAllowedId}
checked={downloadTranscodeAllowed}
onCheckedChange={setDownloadTranscodeAllowed}
/>
</div>
</div>
</TabsContent>
<TabsContent value="limits" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={maxStreamsId}>Max Streams</Label>
<Input
id={maxStreamsId}
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={maxTranscodesId}>Max Transcodes</Label>
<Input
id={maxTranscodesId}
type="number"
min={0}
value={maxTranscodes}
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
<Input
id={maxProfilesId}
type="number"
min={1}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label htmlFor={maxPlaybackQualityId}>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) =>
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
}
>
<SelectTrigger id={maxPlaybackQualityId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{
PLAYBACK_QUALITY_OPTIONS.find(
(option) => option.value === maxPlaybackQualityPreset,
)?.description
}
</p>
</div>
</div>
</TabsContent>
</div>
</Tabs>
<div className="border-border mt-4 border-t pt-4">
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}
function UserDefaultsForm({ onClose }: { onClose: () => void }) {
const { data: settings } = useAdminServerSettings();
const updateSetting = useUpdateServerSetting();
const [isSaving, setIsSaving] = useState(false);
const [maxStreams, setMaxStreams] = useState(Number(settings?.["defaults.max_streams"] ?? "6"));
const [maxTranscodes, setMaxTranscodes] = useState(
Number(settings?.["defaults.max_transcodes"] ?? "2"),
);
const [maxProfiles, setMaxProfiles] = useState(
Number(settings?.["defaults.max_profiles"] ?? "5"),
);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(settings?.["defaults.max_playback_quality"]),
);
const [downloadAllowed, setDownloadAllowed] = useState(
settings?.["defaults.download_allowed"] !== "false",
);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
settings?.["defaults.download_transcode_allowed"] === "true",
);
const defMaxStreamsId = useId();
const defMaxTranscodesId = useId();
const defMaxProfilesId = useId();
const defMaxPlaybackQualityId = useId();
const defDownloadAllowedId = useId();
const defDownloadTranscodeAllowedId = useId();
function handleSave() {
setIsSaving(true);
const updates = [
{ key: "defaults.max_streams", value: String(maxStreams) },
{ key: "defaults.max_transcodes", value: String(maxTranscodes) },
{ key: "defaults.max_profiles", value: String(maxProfiles) },
{
key: "defaults.max_playback_quality",
value: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
},
{ key: "defaults.download_allowed", value: String(downloadAllowed) },
{ key: "defaults.download_transcode_allowed", value: String(downloadTranscodeAllowed) },
];
Promise.all(updates.map((u) => updateSetting.mutateAsync(u)))
.then(() => onClose())
.finally(() => setIsSaving(false));
}
return (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
These defaults will pre-fill the form when creating new users.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor={defMaxStreamsId}>Max Streams</Label>
<Input
id={defMaxStreamsId}
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={defMaxTranscodesId}>Max Transcodes</Label>
<Input
id={defMaxTranscodesId}
type="number"
min={0}
value={maxTranscodes}
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label htmlFor={defMaxProfilesId}>Max Profiles</Label>
<Input
id={defMaxProfilesId}
type="number"
min={1}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor={defMaxPlaybackQualityId}>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) => setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)}
>
<SelectTrigger id={defMaxPlaybackQualityId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{
PLAYBACK_QUALITY_OPTIONS.find((option) => option.value === maxPlaybackQualityPreset)
?.description
}
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor={defDownloadAllowedId}>Downloads Allowed</Label>
<Switch
id={defDownloadAllowedId}
checked={downloadAllowed}
onCheckedChange={setDownloadAllowed}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor={defDownloadTranscodeAllowedId}>Download Transcode Allowed</Label>
<Switch
id={defDownloadTranscodeAllowedId}
checked={downloadTranscodeAllowed}
onCheckedChange={setDownloadTranscodeAllowed}
/>
</div>
<Button onClick={handleSave} className="w-full" disabled={isSaving}>
{isSaving ? "Saving..." : "Save Defaults"}
</Button>
</div>
);
}