* fix(web): show admin chrome only on the admin account's primary profile The top-right ServerActivity indicator and the sidebar Admin section were gated on the account-level role alone, so every profile on an admin account — including child profiles — saw admin system notifications and the indicator polled four admin endpoints on their behalf. Gate both on the active profile being the household primary, matching the existing is_primary idiom in SettingsLayout and the server-side quota exemption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): resolve active profile via useCurrentProfile in admin route gates RequireAdmin/RequirePrimaryOrAdmin read the profile from useAuth(), but the admin chrome (AppSidebar, Layout) gates on useCurrentProfile(), which resolves the selected profile. Use the same source in the route gates so the redirect and the visible admin UI can never disagree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web,api): centralize acting-admin policy and enforce it server-side Address code-review findings on the primary-profile admin gate: - Add isActingAdmin to web/src/lib/permissions.ts as the single client-side definition of the policy (admin role + primary or no profile), with a useIsActingAdmin hook on top. Route gates, sidebar, Layout, and realtime channel gating all use it now, so the gate and the chrome can no longer disagree on null-profile handling. - Convert the admin-gated surfaces the original change missed (MediaItemMenu, EditMetadataDialog images tab, AddToCollectionDialog, MarkerEditor, theme CatalogBrowser, PersonDetail, SettingsLayout, ItemDetail content pages) so an admin on a non-primary profile is a regular viewer everywhere, not just in the sidebar. - Make the role-derived permission bypass (metadata curation, marker edit) follow the same policy on both client and server. - Enforce the policy server-side: RequireActingAdmin middleware refuses admin routes when the request declares a non-primary profile via X-Profile-Id, and the metadata-curation middleware holds admins on non-primary profiles to explicitly assigned permissions. - Stop spreading the profiles query result from useCurrentProfile so route gates only re-render when the resolved profile changes, and make it safe outside AuthProvider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web,api): fail closed on unresolved profiles in acting-admin policy Address review feedback on the acting-admin gate: - Server: actingAdminAllowed now denies when the declared profile cannot be resolved to one of the caller's profiles, so a bogus X-Profile-Id can no longer restore admin powers to a non-primary session. - Client: useIsActingAdmin returns false while a selected profile id has not yet resolved (e.g. hard refresh before the profiles query returns), instead of briefly treating it as "no profile selected". useCurrentProfile exposes hasSelectedProfile to make that state distinguishable. - hasPermission/canCurateMetadata/canEditMarkers now require the profile argument (resolved profile or explicit null), so a missed call site fails the typecheck instead of silently restoring the admin bypass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
182 lines
5.2 KiB
Go
182 lines
5.2 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
type PermissionUserLoader interface {
|
|
GetByID(ctx context.Context, id int) (*models.User, error)
|
|
}
|
|
|
|
type MetadataTargetLibraryResolver interface {
|
|
ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error)
|
|
}
|
|
|
|
type PermissionMiddleware struct {
|
|
users PermissionUserLoader
|
|
libraries MetadataTargetLibraryResolver
|
|
checkPrimary PrimaryProfileChecker // nil disables the acting-admin profile policy
|
|
}
|
|
|
|
func NewPermissionMiddleware(
|
|
users PermissionUserLoader,
|
|
libraries MetadataTargetLibraryResolver,
|
|
checkPrimary PrimaryProfileChecker,
|
|
) *PermissionMiddleware {
|
|
return &PermissionMiddleware{users: users, libraries: libraries, checkPrimary: checkPrimary}
|
|
}
|
|
|
|
// RequireMetadataCurationForItem allows acting admins or users with
|
|
// metadata_curation permission when every library containing the target item
|
|
// is within the user's assigned libraries. A nil user library list means
|
|
// unrestricted library access. An admin declaring a non-primary profile does
|
|
// not get the admin bypass (see actingAdminAllowed) and is held to the same
|
|
// explicitly-assigned-permission bar as everyone else.
|
|
func (m *PermissionMiddleware) RequireMetadataCurationForItem(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" {
|
|
var checkPrimary PrimaryProfileChecker
|
|
if m != nil {
|
|
checkPrimary = m.checkPrimary
|
|
}
|
|
actingAdmin, err := actingAdminAllowed(r, claims.UserID, checkPrimary)
|
|
if err != nil {
|
|
writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to verify active profile")
|
|
return
|
|
}
|
|
if actingAdmin {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
if m == nil || m.users == nil || m.libraries == nil {
|
|
writeForbidden(w, "Metadata curation permission required")
|
|
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
|
|
}
|
|
hasPermission := auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
|
|
if claims.Role == "admin" {
|
|
// Reached only when the admin bypass was refused (non-primary
|
|
// profile declared): the role-derived grant does not apply, only
|
|
// an explicitly assigned permission does.
|
|
hasPermission = auth.HasAssignedPermission(user, auth.PermissionMetadataCuration)
|
|
}
|
|
if !hasPermission {
|
|
writeForbidden(w, "Metadata curation permission required")
|
|
return
|
|
}
|
|
|
|
targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID)
|
|
if err != nil {
|
|
writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries")
|
|
return
|
|
}
|
|
if len(targetLibraries) == 0 {
|
|
writePermissionError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) {
|
|
writeForbidden(w, "Item is outside your assigned libraries")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func metadataTargetWithinUserLibraries(allowed []int, target []int) bool {
|
|
if allowed == nil {
|
|
return true
|
|
}
|
|
if len(target) == 0 {
|
|
return false
|
|
}
|
|
allowedSet := make(map[int]struct{}, len(allowed))
|
|
for _, id := range allowed {
|
|
allowedSet[id] = struct{}{}
|
|
}
|
|
for _, id := range target {
|
|
if _, ok := allowedSet[id]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type PGMetadataTargetLibraryResolver struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver {
|
|
return &PGMetadataTargetLibraryResolver{Pool: pool}
|
|
}
|
|
|
|
func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return nil, fmt.Errorf("database not configured")
|
|
}
|
|
rows, err := r.Pool.Query(ctx, `
|
|
WITH target_root AS (
|
|
SELECT mi.content_id
|
|
FROM media_items mi
|
|
WHERE mi.content_id = $1
|
|
UNION
|
|
SELECT s.series_id
|
|
FROM seasons s
|
|
WHERE s.content_id = $1
|
|
UNION
|
|
SELECT e.series_id
|
|
FROM episodes e
|
|
WHERE e.content_id = $1
|
|
)
|
|
SELECT DISTINCT mil.media_folder_id
|
|
FROM target_root tr
|
|
JOIN media_item_libraries mil ON mil.content_id = tr.content_id
|
|
ORDER BY mil.media_folder_id`, contentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []int
|
|
for rows.Next() {
|
|
var id int
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
func writePermissionError(w http.ResponseWriter, status int, code, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message})
|
|
}
|