feat: quiz and rbac
This commit is contained in:
@@ -1,17 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
type staffCtxKey struct{}
|
||||
|
||||
// StaffContext is the caller's resolved admin access, set by RequireStaff.
|
||||
type StaffContext struct {
|
||||
RoleKey string
|
||||
RoleName string
|
||||
Perms rbac.Set
|
||||
}
|
||||
|
||||
// Staff returns the caller's staff context, or nil outside RequireStaff.
|
||||
func Staff(ctx context.Context) *StaffContext {
|
||||
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
|
||||
return s
|
||||
}
|
||||
|
||||
// RequireStaff admits accounts that hold an admin role and aren't blocked from
|
||||
// the panel. It MUST run after RequireAuth.
|
||||
//
|
||||
// It resolves the role's permissions once per request and puts them in
|
||||
// context, so the RequirePermission checks on each route are map lookups
|
||||
// rather than further queries. Being staff gets you through the door only;
|
||||
// every route then names the permission it needs.
|
||||
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid := UserID(r.Context())
|
||||
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := users.FindByID(r.Context(), uid)
|
||||
access, err := staff.Access(r.Context(), uid)
|
||||
if errors.Is(err, repository.ErrNotStaff) {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||
return
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
if access.Blocked {
|
||||
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
|
||||
RoleKey: access.RoleKey,
|
||||
RoleName: access.RoleName,
|
||||
Perms: rbac.NewSet(access.Permissions),
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission gates a route to staff holding perm. Must run inside
|
||||
// RequireStaff; outside it there is no staff context and everything is refused.
|
||||
func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := Staff(r.Context())
|
||||
if s == nil || !s.Perms.Has(perm) {
|
||||
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
)
|
||||
|
||||
func withStaff(perms ...string) context.Context {
|
||||
return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{
|
||||
RoleKey: "x", Perms: rbac.NewSet(perms),
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequirePermission(t *testing.T) {
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
gate := RequirePermission("users:ban")(ok)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
want int
|
||||
}{
|
||||
{"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden},
|
||||
{"admin with it", withStaff("users:ban"), http.StatusNoContent},
|
||||
{"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent},
|
||||
// Outside RequireStaff there's no staff context at all: refuse.
|
||||
{"no staff context", context.Background(), http.StatusForbidden},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx))
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Staff management is never reachable through a catalog permission.
|
||||
if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) {
|
||||
t.Error("a non-wildcard set holds admins:manage")
|
||||
}
|
||||
}
|
||||
|
||||
func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) }
|
||||
Reference in New Issue
Block a user