refactor(server): consolidate workspace permission checks into middleware
Move workspace membership and role validation from individual handlers into dedicated Chi middleware. The new middleware resolves workspace ID (from query param, X-Workspace-ID header, or URL param), validates membership via DB, and injects the member into request context. Handlers now read workspace ID and member from context instead of calling requireWorkspaceMember/requireWorkspaceRole directly. This eliminates ~17 duplicated permission checks across handlers and makes it harder to accidentally omit access control on new routes.
This commit is contained in:
parent
e1e4079da1
commit
f4a6e7c475
8 changed files with 198 additions and 64 deletions
|
|
@ -111,8 +111,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
|
||||
// Issues
|
||||
r.Route("/api/issues", func(r chi.Router) {
|
||||
r.Get("/", h.ListIssues)
|
||||
r.Post("/", h.CreateIssue)
|
||||
r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListIssues)
|
||||
r.With(middleware.RequireWorkspaceMember(queries)).Post("/", h.CreateIssue)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetIssue)
|
||||
r.Put("/", h.UpdateIssue)
|
||||
|
|
@ -134,8 +134,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
|
||||
// Agents
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgents)
|
||||
r.Post("/", h.CreateAgent)
|
||||
r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListAgents)
|
||||
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/", h.CreateAgent)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
|
|
@ -148,9 +148,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
|
||||
// Skills
|
||||
r.Route("/api/skills", func(r chi.Router) {
|
||||
r.Get("/", h.ListSkills)
|
||||
r.Post("/", h.CreateSkill)
|
||||
r.Post("/import", h.ImportSkill)
|
||||
r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListSkills)
|
||||
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/", h.CreateSkill)
|
||||
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/import", h.ImportSkill)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetSkill)
|
||||
r.Put("/", h.UpdateSkill)
|
||||
|
|
@ -162,7 +162,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
})
|
||||
|
||||
r.Route("/api/runtimes", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgentRuntimes)
|
||||
r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListAgentRuntimes)
|
||||
r.Get("/{runtimeId}/usage", h.GetRuntimeUsage)
|
||||
r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity)
|
||||
r.Post("/{runtimeId}/ping", h.InitiatePing)
|
||||
|
|
@ -195,17 +195,26 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Get("/", h.ListWorkspaces)
|
||||
r.Post("/", h.CreateWorkspace)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetWorkspace)
|
||||
r.Put("/", h.UpdateWorkspace)
|
||||
r.Patch("/", h.UpdateWorkspace)
|
||||
r.Delete("/", h.DeleteWorkspace)
|
||||
r.Get("/members", h.ListMembersWithUser)
|
||||
r.Post("/members", h.CreateMember)
|
||||
r.Post("/leave", h.LeaveWorkspace)
|
||||
r.Route("/members/{memberId}", func(r chi.Router) {
|
||||
r.Patch("/", h.UpdateMember)
|
||||
r.Delete("/", h.DeleteMember)
|
||||
// Member-level access
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
||||
r.Get("/", h.GetWorkspace)
|
||||
r.Get("/members", h.ListMembersWithUser)
|
||||
r.Post("/leave", h.LeaveWorkspace)
|
||||
})
|
||||
// Admin-level access
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
||||
r.Put("/", h.UpdateWorkspace)
|
||||
r.Patch("/", h.UpdateWorkspace)
|
||||
r.Post("/members", h.CreateMember)
|
||||
r.Route("/members/{memberId}", func(r chi.Router) {
|
||||
r.Patch("/", h.UpdateMember)
|
||||
r.Delete("/", h.DeleteMember)
|
||||
})
|
||||
})
|
||||
// Owner-only access
|
||||
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
|||
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -224,9 +224,6 @@ type CreateAgentRequest struct {
|
|||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import (
|
|||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
|
|
@ -109,6 +111,10 @@ func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|||
}
|
||||
|
||||
func resolveWorkspaceID(r *http.Request) string {
|
||||
// Prefer context value set by workspace middleware.
|
||||
if id := middleware.WorkspaceIDFromContext(r.Context()); id != "" {
|
||||
return id
|
||||
}
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID != "" {
|
||||
return workspaceID
|
||||
|
|
@ -116,6 +122,33 @@ func resolveWorkspaceID(r *http.Request) string {
|
|||
return r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
|
||||
// ctxMember returns the workspace member from context (set by workspace middleware).
|
||||
func ctxMember(ctx context.Context) (db.Member, bool) {
|
||||
return middleware.MemberFromContext(ctx)
|
||||
}
|
||||
|
||||
// ctxWorkspaceID returns the workspace ID from context (set by workspace middleware).
|
||||
func ctxWorkspaceID(ctx context.Context) string {
|
||||
return middleware.WorkspaceIDFromContext(ctx)
|
||||
}
|
||||
|
||||
// workspaceIDFromURL returns the workspace ID from context (preferred) or chi URL param (fallback).
|
||||
func workspaceIDFromURL(r *http.Request, param string) string {
|
||||
if id := middleware.WorkspaceIDFromContext(r.Context()); id != "" {
|
||||
return id
|
||||
}
|
||||
return chi.URLParam(r, param)
|
||||
}
|
||||
|
||||
// workspaceMember returns the member from middleware context, or falls back to a DB
|
||||
// lookup when the handler is called directly (e.g. in tests).
|
||||
func (h *Handler) workspaceMember(w http.ResponseWriter, r *http.Request, workspaceID string) (db.Member, bool) {
|
||||
if m, ok := ctxMember(r.Context()); ok {
|
||||
return m, true
|
||||
}
|
||||
return h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
||||
}
|
||||
|
||||
func roleAllowed(role string, roles ...string) bool {
|
||||
for _, candidate := range roles {
|
||||
if role == candidate {
|
||||
|
|
|
|||
|
|
@ -70,9 +70,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
ctx := r.Context()
|
||||
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
offset := 0
|
||||
|
|
@ -160,9 +157,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Get creator from context (set by auth middleware)
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
|
|
|
|||
|
|
@ -194,9 +194,6 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
runtimes, err := h.Queries.ListAgentRuntimes(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -142,9 +142,6 @@ func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id st
|
|||
|
||||
func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
skills, err := h.Queries.ListSkillsByWorkspace(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
|
|
@ -186,9 +183,6 @@ func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
|
|
@ -768,9 +762,6 @@ func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
|
|||
|
||||
func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -111,10 +111,7 @@ func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceMember(w, r, id, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
id := workspaceIDFromURL(r, "id")
|
||||
|
||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
|
|
@ -209,10 +206,7 @@ type UpdateWorkspaceRequest struct {
|
|||
}
|
||||
|
||||
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceRole(w, r, id, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
id := workspaceIDFromURL(r, "id")
|
||||
|
||||
var req UpdateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
@ -298,10 +292,7 @@ type MemberWithUserResponse struct {
|
|||
}
|
||||
|
||||
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
|
||||
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
|
|
@ -359,8 +350,8 @@ func normalizeMemberRole(role string) (string, bool) {
|
|||
}
|
||||
|
||||
func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
requester, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -436,8 +427,8 @@ type UpdateMemberRequest struct {
|
|||
}
|
||||
|
||||
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
requester, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -506,8 +497,8 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
requester, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -554,8 +545,8 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -590,10 +581,7 @@ func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner"); !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := workspaceIDFromURL(r, "id")
|
||||
|
||||
if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil {
|
||||
slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
|
|
|
|||
125
server/internal/middleware/workspace.go
Normal file
125
server/internal/middleware/workspace.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// Context keys for workspace-scoped request data.
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
ctxKeyWorkspaceID contextKey = iota
|
||||
ctxKeyMember
|
||||
)
|
||||
|
||||
// MemberFromContext returns the workspace member injected by the workspace middleware.
|
||||
func MemberFromContext(ctx context.Context) (db.Member, bool) {
|
||||
m, ok := ctx.Value(ctxKeyMember).(db.Member)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// WorkspaceIDFromContext returns the workspace ID injected by the workspace middleware.
|
||||
func WorkspaceIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKeyWorkspaceID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// SetMemberContext injects workspace ID and member into the context.
|
||||
// This is useful for handlers that resolve the workspace from an entity lookup
|
||||
// and want to share the member with downstream code.
|
||||
func SetMemberContext(ctx context.Context, workspaceID string, member db.Member) context.Context {
|
||||
ctx = context.WithValue(ctx, ctxKeyWorkspaceID, workspaceID)
|
||||
ctx = context.WithValue(ctx, ctxKeyMember, member)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func resolveWorkspaceID(r *http.Request) string {
|
||||
if id := r.URL.Query().Get("workspace_id"); id != "" {
|
||||
return id
|
||||
}
|
||||
return r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
|
||||
// RequireWorkspaceMember resolves the workspace ID from query param or
|
||||
// X-Workspace-ID header, validates membership, and injects the member
|
||||
// and workspace ID into the request context.
|
||||
func RequireWorkspaceMember(queries *db.Queries) func(http.Handler) http.Handler {
|
||||
return buildMiddleware(queries, resolveWorkspaceID, nil)
|
||||
}
|
||||
|
||||
// RequireWorkspaceRole is like RequireWorkspaceMember but additionally checks
|
||||
// that the member has one of the specified roles.
|
||||
func RequireWorkspaceRole(queries *db.Queries, roles ...string) func(http.Handler) http.Handler {
|
||||
return buildMiddleware(queries, resolveWorkspaceID, roles)
|
||||
}
|
||||
|
||||
// RequireWorkspaceMemberFromURL resolves the workspace ID from a chi URL
|
||||
// parameter, validates membership, and injects into context.
|
||||
func RequireWorkspaceMemberFromURL(queries *db.Queries, param string) func(http.Handler) http.Handler {
|
||||
return buildMiddleware(queries, func(r *http.Request) string {
|
||||
return chi.URLParam(r, param)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// RequireWorkspaceRoleFromURL is like RequireWorkspaceMemberFromURL but
|
||||
// additionally checks that the member has one of the specified roles.
|
||||
func RequireWorkspaceRoleFromURL(queries *db.Queries, param string, roles ...string) func(http.Handler) http.Handler {
|
||||
return buildMiddleware(queries, func(r *http.Request) string {
|
||||
return chi.URLParam(r, param)
|
||||
}, roles)
|
||||
}
|
||||
|
||||
func buildMiddleware(queries *db.Queries, resolve func(*http.Request) string, roles []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolve(r)
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
member, err := queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: util.ParseUUID(userID),
|
||||
WorkspaceID: util.ParseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "workspace not found")
|
||||
return
|
||||
}
|
||||
|
||||
if len(roles) > 0 {
|
||||
allowed := false
|
||||
for _, role := range roles {
|
||||
if member.Role == role {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := SetMemberContext(r.Context(), workspaceID, member)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue