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:
Jiayuan 2026-03-30 03:40:20 +08:00
parent e1e4079da1
commit f4a6e7c475
8 changed files with 198 additions and 64 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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)...)