RequireWorkspaceAccess checks that the current user is a member of the workspace identified by the {slug} URL parameter. The user's workspace role is stored in the request context for downstream permission checks. When no users exist (fresh install), access is granted with an implicit "owner" role.
(next http.Handler)
| 526 | // "owner" role. Legacy API tokens (workspace-scoped, no user) are allowed |
| 527 | // if the token's workspace matches the requested workspace. |
| 528 | func (s *Server) RequireWorkspaceAccess(next http.Handler) http.Handler { |
| 529 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 530 | slugOrID := chi.URLParam(r, "slug") |
| 531 | if slugOrID == "" { |
| 532 | next.ServeHTTP(w, r) |
| 533 | return |
| 534 | } |
| 535 | |
| 536 | // Resolve workspace: supports both UUID and slug. |
| 537 | ws, err := s.resolveWorkspace(slugOrID, currentUser(r)) |
| 538 | if err != nil { |
| 539 | writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve workspace") |
| 540 | return |
| 541 | } |
| 542 | if ws == nil { |
| 543 | writeError(w, http.StatusNotFound, "not_found", "Workspace not found") |
| 544 | return |
| 545 | } |
| 546 | |
| 547 | // OAuth token allow-list gate (TASK-953). The consent UI |
| 548 | // (TASK-952) lets the user pick which workspaces a token |
| 549 | // can access; MCPBearerAuth stashes that list in context |
| 550 | // via WithTokenAllowedWorkspaces. Reject any request hitting |
| 551 | // a workspace outside the list, even if the user is a |
| 552 | // member of it — the user explicitly chose not to grant the |
| 553 | // app that access. |
| 554 | // |
| 555 | // nil → no token-level constraint (PAT auth, or pre-TASK-952 |
| 556 | // OAuth tokens that predate the consent UI). Wildcard `["*"]` |
| 557 | // → grant access to any membership the user has. Else: the |
| 558 | // resolved workspace's slug MUST appear in the list. |
| 559 | // |
| 560 | // Compares against the canonical slug (ws.Slug) because the |
| 561 | // consent UI persists slugs and the URL slugOrID may be a |
| 562 | // UUID which resolveWorkspace just translated. Slug-vs-slug |
| 563 | // is the right comparison. |
| 564 | if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) { |
| 565 | // TASK-961: count workspace-allow-list denials so the |
| 566 | // MCP dashboard can flag tokens hitting workspaces outside |
| 567 | // their consent scope. Gated on MCP-origin requests only |
| 568 | // (presence of MCP token identity in context) so non-MCP |
| 569 | // /api/v1 traffic — which can't even reach this gate |
| 570 | // today, but might in a future PAT-with-allow-list world |
| 571 | // — doesn't pollute the MCP-specific counter. |
| 572 | s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist") |
| 573 | writeError(w, http.StatusForbidden, "permission_denied", |
| 574 | "Token is not authorized for this workspace") |
| 575 | return |
| 576 | } |
| 577 | |
| 578 | // Store resolved workspace ID in context for downstream handlers |
| 579 | ctx := context.WithValue(r.Context(), ctxResolvedWorkspaceID, ws.ID) |
| 580 | |
| 581 | // Fresh install: no users → everyone gets owner access |
| 582 | count, _ := s.store.UserCount() |
| 583 | if count == 0 { |
| 584 | ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner") |
| 585 | next.ServeHTTP(w, r.WithContext(ctx)) |
nothing calls this directly
no test coverage detected