328 lines
11 KiB
Go
328 lines
11 KiB
Go
package middleware
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"slices"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/policy"
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
siloDeviceIDHeader = "X-Silo-Device-Id"
|
||
|
|
policyInternalErrorCode = "internal_error"
|
||
|
|
activeProfileVerificationFailedMsg = "Failed to verify active profile"
|
||
|
|
metadataCurationRequiredMsg = "Metadata curation permission required"
|
||
|
|
markerEditRequiredMsg = "Marker editing permission required"
|
||
|
|
)
|
||
|
|
|
||
|
|
// PermissionDecider is the narrow policy decision interface used by route
|
||
|
|
// gates. *policy.PDP satisfies it.
|
||
|
|
type PermissionDecider interface {
|
||
|
|
CheckPermission(context.Context, policy.PermissionInput) (policy.PermissionDecision, policy.Meta, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewPolicyActingAdminMiddleware enforces the acting-admin gate through the
|
||
|
|
// policy PDP while preserving RequireActingAdmin's response contract.
|
||
|
|
func NewPolicyActingAdminMiddleware(pdp PermissionDecider, primaryChecker PrimaryProfileChecker) func(http.Handler) http.Handler {
|
||
|
|
return func(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" {
|
||
|
|
writeForbidden(w, "Admin access required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
declaredProfileID, actingAsPrimary, err := resolveActingAdminFacts(r, claims.UserID, primaryChecker)
|
||
|
|
if err != nil {
|
||
|
|
writeInternalError(w, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if pdp == nil {
|
||
|
|
writeInternalError(w, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
decision, _, err := pdp.CheckPermission(r.Context(), policy.PermissionInput{
|
||
|
|
SchemaVersion: 1,
|
||
|
|
UserID: claims.UserID,
|
||
|
|
Role: claims.Role,
|
||
|
|
UserEnabled: true,
|
||
|
|
Permission: policy.PermissionActingAdmin,
|
||
|
|
DeclaredProfileID: declaredProfileID,
|
||
|
|
ActingAsPrimary: actingAsPrimary,
|
||
|
|
RequestTime: policyRequestTime(),
|
||
|
|
DeviceID: policyDeviceID(r),
|
||
|
|
ClientIP: clientip.FromContext(r.Context()),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
writeInternalError(w, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !decision.Allowed {
|
||
|
|
writeForbidden(w, "Admin access requires the account's primary profile")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// PolicyPermissionMiddleware mirrors PermissionMiddleware with authorization
|
||
|
|
// decisions evaluated by the policy PDP.
|
||
|
|
type PolicyPermissionMiddleware struct {
|
||
|
|
users PermissionUserLoader
|
||
|
|
libraries MetadataTargetLibraryResolver
|
||
|
|
checkPrimary PrimaryProfileChecker
|
||
|
|
pdp PermissionDecider
|
||
|
|
groups access.GroupPolicyProvider
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewPolicyPermissionMiddleware creates a PDP-backed permission middleware.
|
||
|
|
func NewPolicyPermissionMiddleware(
|
||
|
|
users PermissionUserLoader,
|
||
|
|
libraries MetadataTargetLibraryResolver,
|
||
|
|
checkPrimary PrimaryProfileChecker,
|
||
|
|
pdp PermissionDecider,
|
||
|
|
groups ...access.GroupPolicyProvider,
|
||
|
|
) *PolicyPermissionMiddleware {
|
||
|
|
var groupProvider access.GroupPolicyProvider
|
||
|
|
if len(groups) > 0 {
|
||
|
|
groupProvider = groups[0]
|
||
|
|
}
|
||
|
|
return &PolicyPermissionMiddleware{
|
||
|
|
users: users,
|
||
|
|
libraries: libraries,
|
||
|
|
checkPrimary: checkPrimary,
|
||
|
|
pdp: pdp,
|
||
|
|
groups: groupProvider,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// RequireMetadataCurationForItem allows acting admins or users with
|
||
|
|
// metadata_curation permission when every library containing the target item is
|
||
|
|
// within the user's assigned libraries. It preserves the legacy middleware's
|
||
|
|
// lookup ordering and outward error mapping.
|
||
|
|
func (m *PolicyPermissionMiddleware) 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
|
||
|
|
}
|
||
|
|
|
||
|
|
declaredProfileID, actingAsPrimary, err := resolveActingAdminFacts(r, claims.UserID, m.primaryChecker())
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if claims.Role == "admin" {
|
||
|
|
actingAdmin, err := m.checkPermission(r, policy.PermissionInput{
|
||
|
|
UserID: claims.UserID,
|
||
|
|
Role: claims.Role,
|
||
|
|
UserEnabled: true,
|
||
|
|
Permission: policy.PermissionActingAdmin,
|
||
|
|
DeclaredProfileID: declaredProfileID,
|
||
|
|
ActingAsPrimary: actingAsPrimary,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if actingAdmin.Allowed {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if m == nil || m.users == nil || m.libraries == nil || m.pdp == nil {
|
||
|
|
writeForbidden(w, metadataCurationRequiredMsg)
|
||
|
|
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, metadataCurationRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
effective, err := access.EffectivePolicyForUser(r.Context(), user, m.groups)
|
||
|
|
if err != nil {
|
||
|
|
writeForbidden(w, metadataCurationRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
permissionOnlyInput := policy.PermissionInput{
|
||
|
|
UserID: user.ID,
|
||
|
|
Role: user.Role,
|
||
|
|
UserEnabled: user.Enabled,
|
||
|
|
AssignedPermissions: slices.Clone(effective.Permissions),
|
||
|
|
Permission: policy.PermissionMetadataCuration,
|
||
|
|
DeclaredProfileID: declaredProfileID,
|
||
|
|
ActingAsPrimary: actingAsPrimary,
|
||
|
|
TargetLibraryIDs: []int{0},
|
||
|
|
}
|
||
|
|
permissionOnly, err := m.checkPermission(r, permissionOnlyInput)
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, "Failed to verify metadata curation permission")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !permissionOnly.Allowed {
|
||
|
|
writeForbidden(w, metadataCurationRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID)
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, "Failed to resolve item libraries")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(targetLibraries) == 0 {
|
||
|
|
writePermissionError(w, http.StatusNotFound, "not_found", "Item not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
decision, err := m.checkPermission(r, policy.PermissionInput{
|
||
|
|
UserID: user.ID,
|
||
|
|
Role: user.Role,
|
||
|
|
UserEnabled: user.Enabled,
|
||
|
|
AssignedPermissions: slices.Clone(effective.Permissions),
|
||
|
|
Permission: policy.PermissionMetadataCuration,
|
||
|
|
DeclaredProfileID: declaredProfileID,
|
||
|
|
ActingAsPrimary: actingAsPrimary,
|
||
|
|
TargetLibraryIDs: slices.Clone(targetLibraries),
|
||
|
|
UserLibraryIDs: slices.Clone(effective.LibraryIDs),
|
||
|
|
UserLibrariesRestricted: effective.LibraryIDs != nil,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, "Failed to verify metadata curation permission")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !decision.Allowed {
|
||
|
|
if decision.ReasonCode == policy.ReasonCodeItemOutsideUserLibraries {
|
||
|
|
writeForbidden(w, "Item is outside your assigned libraries")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeForbidden(w, metadataCurationRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// RequireMarkerEdit gates manual marker writes through the policy PDP. Unlike
|
||
|
|
// the legacy handler check, admins are not short-circuited: the decision runs
|
||
|
|
// through the policy so group masks and custom overrides can tighten it.
|
||
|
|
func (m *PolicyPermissionMiddleware) RequireMarkerEdit(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 m == nil || m.users == nil || m.pdp == nil {
|
||
|
|
writeForbidden(w, markerEditRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
declaredProfileID, actingAsPrimary, err := resolveActingAdminFacts(r, claims.UserID, m.primaryChecker())
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, activeProfileVerificationFailedMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||
|
|
if err != nil || user == nil || !user.Enabled {
|
||
|
|
writeForbidden(w, markerEditRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
effective, err := access.EffectivePolicyForUser(r.Context(), user, m.groups)
|
||
|
|
if err != nil {
|
||
|
|
writeForbidden(w, markerEditRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
decision, err := m.checkPermission(r, policy.PermissionInput{
|
||
|
|
UserID: user.ID,
|
||
|
|
Role: user.Role,
|
||
|
|
UserEnabled: user.Enabled,
|
||
|
|
AssignedPermissions: slices.Clone(effective.Permissions),
|
||
|
|
Permission: policy.PermissionMarkerEdit,
|
||
|
|
DeclaredProfileID: declaredProfileID,
|
||
|
|
ActingAsPrimary: actingAsPrimary,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
writePermissionError(w, http.StatusInternalServerError, policyInternalErrorCode, "Failed to verify marker edit permission")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !decision.Allowed {
|
||
|
|
writeForbidden(w, markerEditRequiredMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *PolicyPermissionMiddleware) primaryChecker() PrimaryProfileChecker {
|
||
|
|
if m == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return m.checkPrimary
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *PolicyPermissionMiddleware) checkPermission(r *http.Request, input policy.PermissionInput) (policy.PermissionDecision, error) {
|
||
|
|
if m == nil || m.pdp == nil {
|
||
|
|
return policy.PermissionDecision{}, errMissingPolicyDecider{}
|
||
|
|
}
|
||
|
|
input.SchemaVersion = 1
|
||
|
|
input.RequestTime = policyRequestTime()
|
||
|
|
input.DeviceID = policyDeviceID(r)
|
||
|
|
input.ClientIP = clientip.FromContext(r.Context())
|
||
|
|
decision, _, err := m.pdp.CheckPermission(r.Context(), input)
|
||
|
|
return decision, err
|
||
|
|
}
|
||
|
|
|
||
|
|
type errMissingPolicyDecider struct{}
|
||
|
|
|
||
|
|
func (errMissingPolicyDecider) Error() string {
|
||
|
|
return "missing policy permission decider"
|
||
|
|
}
|
||
|
|
|
||
|
|
func resolveActingAdminFacts(r *http.Request, userID int, checkPrimary PrimaryProfileChecker) (string, bool, error) {
|
||
|
|
profileID := declaredProfileID(r)
|
||
|
|
if profileID == "" {
|
||
|
|
return "", false, nil
|
||
|
|
}
|
||
|
|
if checkPrimary == nil {
|
||
|
|
return profileID, true, nil
|
||
|
|
}
|
||
|
|
isPrimary, found, err := checkPrimary(r.Context(), userID, profileID)
|
||
|
|
if err != nil {
|
||
|
|
return profileID, false, err
|
||
|
|
}
|
||
|
|
return profileID, found && isPrimary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func policyRequestTime() string {
|
||
|
|
return time.Now().UTC().Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
|
||
|
|
func policyDeviceID(r *http.Request) string {
|
||
|
|
return r.Header.Get(siloDeviceIDHeader)
|
||
|
|
}
|