358 lines
13 KiB
Go
358 lines
13 KiB
Go
package middleware
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/policy"
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestPolicyActingAdminMiddlewareParity(t *testing.T) {
|
||
|
|
pdp := newMiddlewarePolicyPDP(t)
|
||
|
|
checkErr := errors.New("profile store down")
|
||
|
|
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
claims *auth.Claims
|
||
|
|
profileID string
|
||
|
|
check PrimaryProfileChecker
|
||
|
|
}{
|
||
|
|
{name: "missing_claims"},
|
||
|
|
{name: "non_admin", claims: &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}},
|
||
|
|
{name: "admin_without_profile", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, check: primaryChecker(false, true, nil)},
|
||
|
|
{name: "admin_primary_profile", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, profileID: "prof-1", check: primaryChecker(true, true, nil)},
|
||
|
|
{name: "admin_non_primary_profile", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, profileID: "prof-2", check: primaryChecker(false, true, nil)},
|
||
|
|
{name: "admin_unknown_profile", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, profileID: "prof-x", check: primaryChecker(false, false, nil)},
|
||
|
|
{name: "checker_error", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, profileID: "prof-1", check: primaryChecker(false, false, checkErr)},
|
||
|
|
{name: "nil_checker_allows_declared_profile", claims: &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}, profileID: "prof-2"},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, test := range tests {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
legacy := captureActingAdminResponse(RequireActingAdmin(test.check), test.claims, test.profileID)
|
||
|
|
policyBacked := captureActingAdminResponse(NewPolicyActingAdminMiddleware(pdp, test.check), test.claims, test.profileID)
|
||
|
|
assertMiddlewareResponsesEqual(t, policyBacked, legacy)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyMetadataCurationMiddlewareParity(t *testing.T) {
|
||
|
|
pdp := newMiddlewarePolicyPDP(t)
|
||
|
|
resolverErr := errors.New("resolver down")
|
||
|
|
userErr := errors.New("user store down")
|
||
|
|
|
||
|
|
curator := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{policy.PermissionMetadataCuration}}
|
||
|
|
unrestrictedCurator := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{policy.PermissionMetadataCuration}}
|
||
|
|
noPermission := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||
|
|
disabledCurator := &models.User{ID: 7, Role: "user", Enabled: false, LibraryIDs: nil, Permissions: []string{policy.PermissionMetadataCuration}}
|
||
|
|
nonPrimaryAdmin := &models.User{ID: 7, Role: "admin", Enabled: true, LibraryIDs: nil, Permissions: nil}
|
||
|
|
assignedNonPrimaryAdmin := &models.User{ID: 7, Role: "admin", Enabled: true, LibraryIDs: nil, Permissions: []string{policy.PermissionMetadataCuration}}
|
||
|
|
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
claims *auth.Claims
|
||
|
|
user *models.User
|
||
|
|
userErr error
|
||
|
|
targetIDs []int
|
||
|
|
targetErr error
|
||
|
|
profileID string
|
||
|
|
check PrimaryProfileChecker
|
||
|
|
itemID string
|
||
|
|
}{
|
||
|
|
{name: "missing_claims", itemID: "item-1"},
|
||
|
|
{name: "acting_admin_primary_bypasses_missing_repos", claims: adminClaims(), profileID: "prof-1", check: primaryChecker(true, true, nil), itemID: "item-1"},
|
||
|
|
{name: "acting_admin_no_profile_bypasses", claims: adminClaims(), itemID: "item-1"},
|
||
|
|
{name: "non_primary_admin_without_assigned_permission", claims: adminClaims(), user: nonPrimaryAdmin, targetIDs: []int{1}, profileID: "prof-2", check: primaryChecker(false, true, nil), itemID: "item-1"},
|
||
|
|
{name: "non_primary_admin_with_assigned_permission", claims: adminClaims(), user: assignedNonPrimaryAdmin, targetIDs: []int{1}, profileID: "prof-2", check: primaryChecker(false, true, nil), itemID: "item-1"},
|
||
|
|
{name: "non_primary_admin_with_assigned_permission_out_of_scope", claims: adminClaims(), user: &models.User{ID: 7, Role: "admin", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{policy.PermissionMetadataCuration}}, targetIDs: []int{2}, profileID: "prof-2", check: primaryChecker(false, true, nil), itemID: "item-1"},
|
||
|
|
{name: "user_without_permission", claims: userClaims(), user: noPermission, targetIDs: []int{1}, itemID: "item-1"},
|
||
|
|
{name: "unrestricted_curator", claims: userClaims(), user: unrestrictedCurator, targetIDs: []int{8, 9}, itemID: "item-1"},
|
||
|
|
{name: "curator_in_scope", claims: userClaims(), user: curator, targetIDs: []int{1, 3}, itemID: "item-1"},
|
||
|
|
{name: "curator_out_of_scope", claims: userClaims(), user: curator, targetIDs: []int{1, 4}, itemID: "item-1"},
|
||
|
|
{name: "target_resolver_error", claims: userClaims(), user: curator, targetErr: resolverErr, itemID: "item-1"},
|
||
|
|
{name: "target_not_found", claims: userClaims(), user: unrestrictedCurator, targetIDs: nil, itemID: "item-1"},
|
||
|
|
{name: "missing_item_id", claims: userClaims(), user: curator},
|
||
|
|
{name: "user_loader_error", claims: userClaims(), userErr: userErr, itemID: "item-1"},
|
||
|
|
{name: "user_not_found", claims: userClaims(), user: nil, itemID: "item-1"},
|
||
|
|
{name: "user_disabled", claims: userClaims(), user: disabledCurator, itemID: "item-1"},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, test := range tests {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
legacy := captureMetadataCurationResponse(
|
||
|
|
NewPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: test.user, err: test.userErr},
|
||
|
|
fakeTargetLibraryResolver{ids: test.targetIDs, err: test.targetErr},
|
||
|
|
test.check,
|
||
|
|
),
|
||
|
|
test.claims,
|
||
|
|
test.profileID,
|
||
|
|
test.itemID,
|
||
|
|
)
|
||
|
|
policyBacked := captureMetadataCurationResponse(
|
||
|
|
NewPolicyPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: test.user, err: test.userErr},
|
||
|
|
fakeTargetLibraryResolver{ids: test.targetIDs, err: test.targetErr},
|
||
|
|
test.check,
|
||
|
|
pdp,
|
||
|
|
),
|
||
|
|
test.claims,
|
||
|
|
test.profileID,
|
||
|
|
test.itemID,
|
||
|
|
)
|
||
|
|
assertMiddlewareResponsesEqual(t, policyBacked, legacy)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyActingAdminMiddlewareEvalErrorIsInternal(t *testing.T) {
|
||
|
|
rec := captureActingAdminResponse(
|
||
|
|
NewPolicyActingAdminMiddleware(errorPermissionDecider{}, nil),
|
||
|
|
adminClaims(),
|
||
|
|
"",
|
||
|
|
)
|
||
|
|
if rec.code != http.StatusInternalServerError {
|
||
|
|
t.Fatalf("status = %d, want %d; body %s", rec.code, http.StatusInternalServerError, rec.body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyMetadataCurationMiddlewareAppliesGroupPermissionMask(t *testing.T) {
|
||
|
|
user := &models.User{
|
||
|
|
ID: 7,
|
||
|
|
Role: "user",
|
||
|
|
Enabled: true,
|
||
|
|
LibraryIDs: []int{1},
|
||
|
|
Permissions: []string{policy.PermissionMetadataCuration},
|
||
|
|
}
|
||
|
|
rec := captureMetadataCurationResponse(
|
||
|
|
NewPolicyPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: user},
|
||
|
|
fakeTargetLibraryResolver{ids: []int{1}},
|
||
|
|
nil,
|
||
|
|
newMiddlewarePolicyPDP(t),
|
||
|
|
middlewareGroupProvider{group: &access.GroupPolicy{
|
||
|
|
AllowedPermissions: []string{policy.PermissionMarkerEdit},
|
||
|
|
DownloadAllowed: true,
|
||
|
|
DownloadTranscodeAllowed: true,
|
||
|
|
RequestsAllowed: true,
|
||
|
|
}},
|
||
|
|
),
|
||
|
|
userClaims(),
|
||
|
|
"",
|
||
|
|
"item-1",
|
||
|
|
)
|
||
|
|
if rec.code != http.StatusForbidden {
|
||
|
|
t.Fatalf("status = %d body = %s, want forbidden", rec.code, rec.body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyMarkerEditMiddlewareParity(t *testing.T) {
|
||
|
|
pdp := newMiddlewarePolicyPDP(t)
|
||
|
|
userErr := errors.New("user store down")
|
||
|
|
|
||
|
|
editor := &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{policy.PermissionMarkerEdit}}
|
||
|
|
noPermission := &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil}
|
||
|
|
disabledEditor := &models.User{ID: 7, Role: "user", Enabled: false, Permissions: []string{policy.PermissionMarkerEdit}}
|
||
|
|
enabledAdmin := &models.User{ID: 7, Role: "admin", Enabled: true, Permissions: nil}
|
||
|
|
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
claims *auth.Claims
|
||
|
|
user *models.User
|
||
|
|
userErr error
|
||
|
|
}{
|
||
|
|
{name: "missing_claims"},
|
||
|
|
{name: "admin", claims: adminClaims(), user: enabledAdmin},
|
||
|
|
{name: "user_with_permission", claims: userClaims(), user: editor},
|
||
|
|
{name: "user_without_permission", claims: userClaims(), user: noPermission},
|
||
|
|
{name: "user_disabled", claims: userClaims(), user: disabledEditor},
|
||
|
|
{name: "user_loader_error", claims: userClaims(), userErr: userErr},
|
||
|
|
{name: "user_not_found", claims: userClaims(), user: nil},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, test := range tests {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
legacy := captureMarkerEditResponse(
|
||
|
|
NewPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: test.user, err: test.userErr},
|
||
|
|
nil,
|
||
|
|
nil,
|
||
|
|
),
|
||
|
|
test.claims,
|
||
|
|
)
|
||
|
|
policyBacked := captureMarkerEditResponse(
|
||
|
|
NewPolicyPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: test.user, err: test.userErr},
|
||
|
|
nil,
|
||
|
|
nil,
|
||
|
|
pdp,
|
||
|
|
),
|
||
|
|
test.claims,
|
||
|
|
)
|
||
|
|
assertMiddlewareResponsesEqual(t, policyBacked, legacy)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyMarkerEditMiddlewareAppliesGroupPermissionMask(t *testing.T) {
|
||
|
|
user := &models.User{
|
||
|
|
ID: 7,
|
||
|
|
Role: "user",
|
||
|
|
Enabled: true,
|
||
|
|
Permissions: []string{policy.PermissionMarkerEdit},
|
||
|
|
}
|
||
|
|
rec := captureMarkerEditResponse(
|
||
|
|
NewPolicyPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: user},
|
||
|
|
nil,
|
||
|
|
nil,
|
||
|
|
newMiddlewarePolicyPDP(t),
|
||
|
|
middlewareGroupProvider{group: &access.GroupPolicy{
|
||
|
|
AllowedPermissions: []string{policy.PermissionMetadataCuration},
|
||
|
|
DownloadAllowed: true,
|
||
|
|
DownloadTranscodeAllowed: true,
|
||
|
|
RequestsAllowed: true,
|
||
|
|
}},
|
||
|
|
),
|
||
|
|
userClaims(),
|
||
|
|
)
|
||
|
|
if rec.code != http.StatusForbidden {
|
||
|
|
t.Fatalf("status = %d body = %s, want forbidden", rec.code, rec.body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPolicyMarkerEditMiddlewareEvalErrorIsInternal(t *testing.T) {
|
||
|
|
user := &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{policy.PermissionMarkerEdit}}
|
||
|
|
rec := captureMarkerEditResponse(
|
||
|
|
NewPolicyPermissionMiddleware(
|
||
|
|
fakePermissionUserLoader{user: user},
|
||
|
|
nil,
|
||
|
|
nil,
|
||
|
|
errorPermissionDecider{},
|
||
|
|
),
|
||
|
|
userClaims(),
|
||
|
|
)
|
||
|
|
if rec.code != http.StatusInternalServerError {
|
||
|
|
t.Fatalf("status = %d, want %d; body %s", rec.code, http.StatusInternalServerError, rec.body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newMiddlewarePolicyPDP(t *testing.T) *policy.PDP {
|
||
|
|
t.Helper()
|
||
|
|
engine, err := policy.NewEngine(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("NewEngine() error: %v", err)
|
||
|
|
}
|
||
|
|
return policy.NewPDP(engine)
|
||
|
|
}
|
||
|
|
|
||
|
|
type middlewareResponse struct {
|
||
|
|
code int
|
||
|
|
body string
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureActingAdminResponse(
|
||
|
|
mw func(http.Handler) http.Handler,
|
||
|
|
claims *auth.Claims,
|
||
|
|
profileID string,
|
||
|
|
) middlewareResponse {
|
||
|
|
next := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}))
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/sessions", nil)
|
||
|
|
if profileID != "" {
|
||
|
|
req.Header.Set("X-Profile-Id", profileID)
|
||
|
|
}
|
||
|
|
if claims != nil {
|
||
|
|
req = req.WithContext(SetClaims(req.Context(), claims))
|
||
|
|
}
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
next.ServeHTTP(rec, req)
|
||
|
|
return middlewareResponse{code: rec.Code, body: rec.Body.String()}
|
||
|
|
}
|
||
|
|
|
||
|
|
type metadataCurationGate interface {
|
||
|
|
RequireMetadataCurationForItem(http.Handler) http.Handler
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureMetadataCurationResponse(
|
||
|
|
mw metadataCurationGate,
|
||
|
|
claims *auth.Claims,
|
||
|
|
profileID string,
|
||
|
|
itemID string,
|
||
|
|
) middlewareResponse {
|
||
|
|
next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}))
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/"+itemID+"/refresh-metadata", nil)
|
||
|
|
if profileID != "" {
|
||
|
|
req.Header.Set("X-Profile-Id", profileID)
|
||
|
|
}
|
||
|
|
ctx := req.Context()
|
||
|
|
if claims != nil {
|
||
|
|
ctx = SetClaims(ctx, claims)
|
||
|
|
}
|
||
|
|
routeCtx := chi.NewRouteContext()
|
||
|
|
if itemID != "" {
|
||
|
|
routeCtx.URLParams.Add("id", itemID)
|
||
|
|
}
|
||
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
next.ServeHTTP(rec, req.WithContext(ctx))
|
||
|
|
return middlewareResponse{code: rec.Code, body: rec.Body.String()}
|
||
|
|
}
|
||
|
|
|
||
|
|
type markerEditGate interface {
|
||
|
|
RequireMarkerEdit(http.Handler) http.Handler
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureMarkerEditResponse(mw markerEditGate, claims *auth.Claims) middlewareResponse {
|
||
|
|
next := mw.RequireMarkerEdit(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}))
|
||
|
|
req := httptest.NewRequest(http.MethodPut, "/markers/files/5", nil)
|
||
|
|
if claims != nil {
|
||
|
|
req = req.WithContext(SetClaims(req.Context(), claims))
|
||
|
|
}
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
next.ServeHTTP(rec, req)
|
||
|
|
return middlewareResponse{code: rec.Code, body: rec.Body.String()}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertMiddlewareResponsesEqual(t *testing.T, got, want middlewareResponse) {
|
||
|
|
t.Helper()
|
||
|
|
if got != want {
|
||
|
|
t.Fatalf("policy response = %#v, want legacy %#v", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func adminClaims() *auth.Claims {
|
||
|
|
return &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess}
|
||
|
|
}
|
||
|
|
|
||
|
|
func userClaims() *auth.Claims {
|
||
|
|
return &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}
|
||
|
|
}
|
||
|
|
|
||
|
|
type errorPermissionDecider struct{}
|
||
|
|
|
||
|
|
func (errorPermissionDecider) CheckPermission(context.Context, policy.PermissionInput) (policy.PermissionDecision, policy.Meta, error) {
|
||
|
|
return policy.PermissionDecision{}, policy.Meta{}, errors.New("policy unavailable")
|
||
|
|
}
|
||
|
|
|
||
|
|
type middlewareGroupProvider struct {
|
||
|
|
group *access.GroupPolicy
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p middlewareGroupProvider) GetPolicyForUser(context.Context, int) (*access.GroupPolicy, error) {
|
||
|
|
return p.group, p.err
|
||
|
|
}
|