Files
silo-server/internal/policy/viewer_resolver.go
QuickandGitHub 73488d1bfa feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
2026-07-31 15:23:30 -04:00

160 lines
5.5 KiB
Go

package policy
import (
"context"
"fmt"
"slices"
"time"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ViewerResolver resolves viewer access scopes through the policy PDP.
type ViewerResolver struct {
users access.UserRepository
storeFactory userstore.UserStoreProvider
tokens access.ProfileTokenValidator
pdp *PDP
groups access.GroupPolicyProvider
}
// NewViewerResolver creates a PDP-backed viewer scope resolver.
func NewViewerResolver(
users access.UserRepository,
storeFactory userstore.UserStoreProvider,
tokens access.ProfileTokenValidator,
pdp *PDP,
groups ...access.GroupPolicyProvider,
) *ViewerResolver {
var groupProvider access.GroupPolicyProvider
if len(groups) > 0 {
groupProvider = groups[0]
}
return &ViewerResolver{
users: users,
storeFactory: storeFactory,
tokens: tokens,
pdp: pdp,
groups: groupProvider,
}
}
// Resolve computes the effective viewer scope for the request.
func (r *ViewerResolver) Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error) {
user, err := r.users.GetByID(ctx, input.UserID)
if err != nil {
return access.Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
}
effective, err := access.EffectivePolicyForUser(ctx, user, r.groups)
if err != nil {
return access.Scope{}, fmt.Errorf("loading access group policy for user %d: %w", input.UserID, err)
}
profileVerified := input.ProfileID == ""
store, err := r.storeFactory.ForUser(ctx, input.UserID)
if err != nil {
return access.Scope{}, fmt.Errorf("opening user store for %d: %w", input.UserID, err)
}
var profile *userstore.Profile
if input.ProfileID != "" {
profile, err = store.GetProfile(ctx, input.ProfileID)
if err != nil {
return access.Scope{}, fmt.Errorf("loading profile %s: %w", input.ProfileID, err)
}
if profile == nil {
return access.Scope{}, access.ErrProfileNotFound
}
profileVerified, err = access.VerifyProfileForRequest(
profile,
input,
user.ID,
user.AccessPolicyRevision,
r.tokens,
)
if err != nil {
return access.Scope{}, err
}
}
preferences := access.ResolveViewerPreferences(ctx, store, input.ProfileID)
policyInput := ScopeInput{
SchemaVersion: 1,
UserID: user.ID,
SessionID: input.SessionID,
ProfileID: input.ProfileID,
AccountLibraryIDs: slices.Clone(effective.LibraryIDs),
AccountRestricted: effective.LibraryIDs != nil,
AccountMaxQuality: effective.MaxPlaybackQuality,
AccessPolicyRevision: user.AccessPolicyRevision,
DisabledLibraryIDs: preferences.DisabledLibraryIDs,
ProfileVerified: profileVerified,
RequestTime: time.Now().UTC().Format(time.RFC3339),
// ResolveInput cannot distinguish API keys from compat callers that
// also skip PIN verification, so v1 leaves this false.
IsAPIKey: false,
}
if profile != nil {
policyInput.ProfilePresent = true
policyInput.ProfileMaxRating = profile.MaxContentRating
policyInput.ProfileMaxQuality = profile.MaxPlaybackQuality
policyInput.ProfileLibraryLimited = profile.LibraryRestrictionsEnabled
policyInput.ProfileLibraryIDs = slices.Clone(profile.AllowedLibraryIDs)
policyInput.ProfileHasPIN = profile.PINHash != ""
// Resolved canonically (profile scope -> contract default), not read off
// the legacy profile column it migrated from. scope.rego relays this
// value unchanged as a preference; the manifest deliberately declares no
// constraint on it, since constraining a setting by a policy input fed
// from that same setting would be circular.
policyInput.ProfileMetadataLang = preferences.PreferredMetadataLanguage
}
if r.pdp == nil {
return access.Scope{}, fmt.Errorf("resolve viewer scope policy: missing PDP")
}
decision, _, err := r.pdp.ResolveViewerScope(ctx, policyInput)
if err != nil {
return access.Scope{}, fmt.Errorf("resolve viewer scope policy: %w", err)
}
// The scope contract emits a tighten-only profile_verified output: a custom
// override may revoke verification but never grant it. Enforce a revocation
// the same way legacy PIN failures surface, so the middleware returns
// 403 profile_unverified instead of silently proceeding.
if profileVerified && !decision.ProfileVerified {
return access.Scope{}, fmt.Errorf("%w: revoked by policy", access.ErrProfileUnverified)
}
var allowed []int
if !decision.Unrestricted {
allowed = slices.Clone(decision.AllowedLibraryIDs)
if allowed == nil {
allowed = []int{}
}
}
disabled := slices.Clone(decision.DisabledLibraryIDs)
if len(disabled) == 0 {
disabled = nil
}
return access.Scope{
UserID: user.ID,
ProfileID: input.ProfileID,
AllowedLibraryIDs: allowed,
DisabledLibraryIDs: disabled,
LibrariesRestricted: decision.LibrariesRestricted,
MaxContentRating: decision.MaxContentRating,
MaxPlaybackQuality: decision.MaxPlaybackQuality,
PreferredMetadataLanguage: decision.PreferredMetadataLanguage,
MetadataLanguageOverrides: preferences.MetadataLanguageOverrides,
PolicyRevision: user.AccessPolicyRevision,
// The policy output is tighten-only (merged_profile_verified), so a
// custom override may revoke verification but never grant it. ANDing
// with the Go-computed fact keeps that invariant even if a policy bug
// emitted true for an unverified profile.
ProfileVerified: profileVerified && decision.ProfileVerified,
}, nil
}