Files
pad/internal/server/handlers_audit.go
T
xarmian 9f6c1d8f47 fix(security): scope MCP workspace-global reads by OAuth consent allow-list (BUG-2102) (#935)
The OAuth token consent allow-list (TokenAllowedWorkspaces) was enforced only
by RequireWorkspaceAccess, which fires solely for /{slug} path-param routes.
Every MCP-reachable read that is workspace-global or takes the workspace as a
query/body param bypassed the gate, so a token consented to workspace A could
reach data in other co-membership workspaces. Investigation found five
bypasses; this closes all of them:

- pad_search (HIGH): fan-out (no workspace) searched ALL memberships; naming a
  workspace returned its item titles + content. Now the fan-out is restricted
  to the allow-list and a named non-consented workspace returns empty (no
  existence leak).
- pad_workspace.list (the original BUG-2102): filtered by the allow-list.
- pad_workspace.deleted: filtered by the allow-list.
- pad_workspace.audit-log: platform-wide admin surface; denied for
  consent-scoped tokens.
- pad_workspace.restore: gated by the allow-list (404 for out-of-consent slugs).

All gates are no-ops for nil/wildcard allow-lists, so PAT auth, web sessions,
and local stdio are unchanged.

The allow-set semantics move into internal/server as the canonical
TokenAllowedWorkspaceSet(ctx) (promoted from internal/mcp's buildAllowSet);
the two mcp call sites (error-hint lister, workspaces resource) and its unit
tests migrate with it, so server handlers and MCP filters share one
implementation instead of drifting per-surface (the pattern that caused this
bug: TASK-977 and TASK-2101 each point-fixed one surface).

Tests: per-handler regression tests carrying the WithTokenAllowedWorkspaces
context MCPBearerAuth produces; each asserts the consent layer (not membership)
drives exclusion, with nil/wildcard baselines guarding against over-blocking.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 20:37:03 -04:00

68 lines
2.0 KiB
Go

package server
import (
"net/http"
"strconv"
"github.com/PerpetualSoftware/pad/internal/models"
)
// handleAuditLog returns a filtered audit log. Admin-only.
// Supports filtering by action, user (user ID), workspace, days, and pagination.
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
// OAuth consent scoping (BUG-2102): the audit log is a platform-wide,
// cross-workspace admin surface with no per-workspace routing, so a
// consent-scoped OAuth token (specific allow-list) must not receive it —
// it would leak audit entries for every workspace and user, well outside
// the token's granted scope. nil/wildcard allow-list (PAT / web session /
// broad consent) is unaffected.
if TokenAllowedWorkspaceSet(r.Context()) != nil {
writeError(w, http.StatusForbidden, "forbidden",
"The platform audit log is not available to workspace-scoped tokens")
return
}
// Accept both "user" and "actor" query params for filtering by user ID.
actorFilter := r.URL.Query().Get("user")
if actorFilter == "" {
actorFilter = r.URL.Query().Get("actor")
}
params := models.AuditLogParams{
Action: r.URL.Query().Get("action"),
Actor: actorFilter,
WorkspaceID: r.URL.Query().Get("workspace"),
Days: 30, // default
}
if d := r.URL.Query().Get("days"); d != "" {
if days, err := strconv.Atoi(d); err == nil && days > 0 {
params.Days = days
}
}
if l := r.URL.Query().Get("limit"); l != "" {
if limit, err := strconv.Atoi(l); err == nil && limit > 0 {
params.Limit = limit
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if offset, err := strconv.Atoi(o); err == nil && offset >= 0 {
params.Offset = offset
}
}
activities, err := s.store.ListAuditLog(params)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, activities)
}