diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 07b9d170..2bfc774e 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -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) }) }) }) diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index d5303285..d2680a24 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -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 { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index aaee0cd5..c4663941 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -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 { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 659717d4..15b0b437 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -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) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index bccf4051..e9aa5263 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -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 { diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go index 96ccb6a2..d6995659 100644 --- a/server/internal/handler/skill.go +++ b/server/internal/handler/skill.go @@ -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 { diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index f3369ed4..5e4b4298 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -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)...) diff --git a/server/internal/middleware/workspace.go b/server/internal/middleware/workspace.go new file mode 100644 index 00000000..b9b3ee7c --- /dev/null +++ b/server/internal/middleware/workspace.go @@ -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)) + }) + } +}