Files
pad/internal/server/handlers_workspaces.go
T
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00

1103 lines
43 KiB
Go

package server
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
"net/http"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/watchevents"
"github.com/go-chi/chi/v5"
)
// filterWorkspacesByTokenAllowlist returns the subset of wss the request's
// OAuth consent allow-list permits, or wss unchanged when no per-slug gate
// applies (nil/wildcard allow-list — an unpinned PAT, web session, local stdio, or
// wildcard consent). A workspace-pinned PAT carries the one-slug allow-list
// (BUG-3982). Shared by the workspace-global reads that sit outside
// the /{slug} subrouter (list, deleted-list, search fan-out) and therefore
// never pass through RequireWorkspaceAccess's allow-list gate. See BUG-2102.
func filterWorkspacesByTokenAllowlist(ctx context.Context, wss []models.Workspace) []models.Workspace {
allow := TokenAllowedWorkspaceSet(ctx)
if allow == nil {
return wss
}
out := make([]models.Workspace, 0, len(wss))
for _, ws := range wss {
if _, ok := allow[ws.Slug]; ok {
out = append(out, ws)
}
}
return out
}
func normalizeWorkspaceInput(input *models.WorkspaceCreate) error {
if input == nil {
return nil
}
settings, err := models.NormalizeWorkspaceSettings(input.Settings)
if err != nil {
return fmt.Errorf("invalid settings JSON: %w", err)
}
if input.Context != nil {
settings, err = models.ApplyWorkspaceContext(settings, input.Context)
if err != nil {
return fmt.Errorf("invalid workspace context: %w", err)
}
}
input.Settings = settings
return nil
}
func normalizeWorkspaceUpdateInput(input *models.WorkspaceUpdate) error {
if input == nil {
return nil
}
if input.Settings != nil {
settings, err := models.NormalizeWorkspaceSettings(*input.Settings)
if err != nil {
return fmt.Errorf("invalid settings JSON: %w", err)
}
input.Settings = &settings
}
if input.Context != nil {
base := "{}"
if input.Settings != nil {
base = *input.Settings
}
settings, err := models.ApplyWorkspaceContext(base, input.Context)
if err != nil {
return fmt.Errorf("invalid workspace context: %w", err)
}
input.Settings = &settings
}
return nil
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{"status": "ok"}
if s.version != "" {
resp["version"] = s.version
}
if s.commit != "" {
resp["commit"] = s.commit
}
if s.buildTime != "" {
resp["build_time"] = s.buildTime
}
resp["cloud_mode"] = s.cloudMode
// MCP is turned on but cannot be served (TASK-1069). A flag only: the
// reason names configuration, which is for the admin panel, not an
// unauthenticated endpoint. Absent when MCP is off or available.
if s.MCPBlockedReason() != "" {
resp["mcp_blocked"] = true
}
if s.webIdentity != nil {
resp["web"] = s.webIdentity
}
writeJSON(w, http.StatusOK, resp)
}
// handleHealthLive is a lightweight liveness probe — always returns 200 if the
// process is running. Kubernetes uses this to decide whether to restart the pod.
func (s *Server) handleHealthLive(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleHealthReady is a readiness probe — returns 200 only when the service
// can accept traffic (DB connection healthy). Kubernetes uses this to decide
// whether to route traffic to the pod.
func (s *Server) handleHealthReady(w http.ResponseWriter, r *http.Request) {
if err := s.store.Ping(); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "not ready",
"error": "database unavailable",
})
return
}
resp := map[string]interface{}{
"status": "ready",
}
// Include connection pool stats (useful for debugging, not required for pass/fail).
dbStats := s.store.DB().Stats()
resp["db"] = map[string]interface{}{
"open_connections": dbStats.OpenConnections,
"in_use": dbStats.InUse,
"idle": dbStats.Idle,
"driver": string(s.store.D().Driver()),
}
// Redis reachability, INFORMATIONAL — it never changes the 200/503
// (BUG-2727). Readiness stays database-only because every item-write path,
// the REST API and the web UI work with Redis down; gating on it would
// pull healthy replicas out of the load balancer over a degraded
// feature. What Redis being down actually costs is named on the
// `degrades` field so a reader does not have to know the architecture
// to interpret the flag.
//
// Absent when no prober is wired, which means no Redis is configured.
// A `false` there would say "Redis is down" about a deployment that
// has none.
if s.redisHealth != nil {
status := s.redisHealth.Status()
redisResp := map[string]interface{}{
"reachable": status.Reachable,
"probed": status.Probed,
}
if status.Error != "" {
redisResp["error"] = status.Error
}
if !status.LastCheck.IsZero() {
redisResp["last_check"] = status.LastCheck.UTC().Format(time.RFC3339)
}
if status.Probed && !status.Reachable {
redisResp["degrades"] = []string{
// NOT "cross-instance" — the activity bus does not fall
// back to a local fan-out when its Redis publish fails
// (internal/events/redis_bus.go: Publish logs and
// returns), so subscribers on THIS instance stop
// receiving too. An operator told only about
// cross-instance delivery would conclude local streams
// were healthy and look elsewhere.
"all activity events, including to clients on this instance",
"watch notifications",
"session presence and session-targeted push",
}
}
resp["redis"] = redisResp
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
type templateInfo struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Icon string `json:"icon"`
Collections []string `json:"collections"`
}
templates := collections.ListTemplates()
result := make([]templateInfo, 0, len(templates))
for _, t := range templates {
colls := make([]string, 0, len(t.Collections))
for _, c := range t.Collections {
colls = append(colls, c.Icon+" "+c.Name)
}
result = append(result, templateInfo{
Name: t.Name,
Category: t.Category,
Description: t.Description,
Icon: t.Icon,
Collections: colls,
})
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) handleListWorkspaces(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
// Authenticated users — including admins — see only workspaces they're
// a member of (which includes ones they own, since owners get a
// workspace_members row at creation time). Server admins previously got
// the unfiltered list here, which leaked workspace metadata into their
// "shared with me" switcher even though they weren't members
// (BUG-982). Cross-tenant visibility for admins is available through
// the admin panel routes (/api/v1/admin/...), which call
// ListWorkspaces() directly with the appropriate auth gate.
if user != nil {
workspaces, err := s.store.GetUserWorkspaces(user.ID)
if err != nil {
writeInternalError(w, err)
return
}
// OAuth consent scoping (BUG-2102): this route is workspace-global
// (no {slug} path param), so RequireWorkspaceAccess — the sole
// enforcer of the token allow-list — never runs. Filter here so a
// consent-scoped token can't enumerate the slugs of workspaces it
// wasn't granted. No-op for web-session / PAT auth (nil allow-list).
workspaces = filterWorkspacesByTokenAllowlist(r.Context(), workspaces)
if workspaces == nil {
workspaces = []models.Workspace{}
}
// TASK-896: the list carries the partial-import STATUS only; the
// note is read through GET /workspaces/{slug}, which knows the
// caller's role there.
if len(workspaces) > 0 {
ids := make([]string, len(workspaces))
for i := range workspaces {
ids[i] = workspaces[i].ID
}
statuses, err := s.store.ListWorkspaceImportStatuses(ids)
if err != nil {
writeInternalError(w, err)
return
}
for i := range workspaces {
if st, ok := statuses[workspaces[i].ID]; ok {
workspaces[i].ImportStatus = &models.WorkspaceImportStatus{Status: st.Status}
}
}
}
writeJSON(w, http.StatusOK, workspaces)
return
}
// A user-less legacy workspace token sees its own workspace only
// (BUG-3353): it used to fall into the bootstrap branch below and list
// every tenant on the instance.
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" {
ws, err := s.store.GetWorkspaceByID(tokenWsID)
if err != nil {
writeInternalError(w, err)
return
}
out := []models.Workspace{}
if ws != nil {
out = append(out, *ws)
}
writeJSON(w, http.StatusOK, out)
return
}
// Pre-auth / fresh-install bootstrap: list everything so the setup
// flow can find any seeded workspace. Only on an instance that READS as
// empty (BUG-3353; the BUG-3334 rule for the count).
if count, err := s.userCount(); err != nil || count != 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
workspaces, err := s.store.ListWorkspaces()
if err != nil {
writeInternalError(w, err)
return
}
if workspaces == nil {
workspaces = []models.Workspace{}
}
writeJSON(w, http.StatusOK, workspaces)
}
func (s *Server) handleReorderWorkspaces(w http.ResponseWriter, r *http.Request) {
userID := currentUserID(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var input []struct {
Slug string `json:"slug"`
SortOrder int `json:"sort_order"`
}
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
for _, item := range input {
ws, err := s.store.GetWorkspaceBySlug(item.Slug)
if err != nil {
writeInternalError(w, err)
return
}
if ws == nil {
continue
}
// OAuth consent scoping (BUG-3255): like the list endpoint above, this
// route is workspace-global, so RequireWorkspaceAccess never checks the
// token allow-list here. Skip a workspace outside it silently, the same
// answer an unknown or non-member slug gets, so the response still says
// nothing about which slugs exist. No-op for web session / unpinned PAT auth.
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
continue
}
// Skip silently if the user is not a member of this workspace.
// UpdateWorkspaceSortOrder is scoped to the caller's own
// workspace_members row (WHERE user_id = ? AND workspace_id = ?),
// so a non-member's PATCH touches zero rows and returns
// sql.ErrNoRows — there's no cross-workspace write or leak here
// even for an attacker-supplied slug list. (The old comment cited
// "admin sees all workspaces"; that rationale is stale —
// handleListWorkspaces returns membership-only for all
// authenticated users including admins since BUG-982, so the
// switcher never surfaces non-member workspaces to reorder.
// BUG-1618 confirmed this site needs no auth gate.)
if err := s.store.UpdateWorkspaceSortOrder(userID, ws.ID, item.SortOrder); err != nil {
if err == sql.ErrNoRows {
continue
}
writeInternalError(w, err)
return
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// requireWorkspaceCreationConsent gates a request that MINTS a workspace
// on the calling OAuth connection's `may_create_workspaces` grant.
// Returns true when the request may proceed; on false it has already
// written the response.
//
// Two callers, which are the two endpoints an OAuth-bound caller can
// mint through: handleCreateWorkspace and handleImportWorkspace. NOT
// every path to store.CreateWorkspace — autoCreateWorkspace
// (handlers_cloud.go) mints a workspace during registration, bootstrap
// and OAuth-login, and is deliberately outside this gate: it runs at
// signup with no OAuth connection in context, provisioning the user's
// own first workspace rather than acting for a connected app.
//
// Ruled by Dave on IDEA-2756 (2026-08-26): the consent screen's "may
// create workspaces" checkbox is a permission on whether the connected
// token has the right to CREATE a workspace, and it has to be true to
// what a user would honestly expect from the option. Before this, the
// flag gated only maybeAutoAddCreatorConnection's allow-list insert —
// so a connection whose user explicitly left the box unticked could
// still create workspaces, it just could not then see them. A
// permission that does not prevent the action it names is a consent
// mismatch, and the behaviour-change-for-existing-connections argument
// lost to honest consent semantics.
//
// Shape mirrors handleAuditLog's consent refusal (BUG-2102): a hard 403
// rather than a narrowed response, because there is no narrower version
// of creating a workspace.
//
// Three non-refusal cases, each deliberate:
//
// - Not an OAuth grant (PAT, CLI session token, local stdio — no
// request_id in context). Creation rides on ordinary account
// authority; this flag has no opinion about it.
// - ErrOAuthConnectionNotFound — no connection row for this grant.
// The expected cause is a pre-Phase-C grant not yet backfilled, and
// the backfill mints those with may_create_workspaces ON
// (oauth_connections_backfill.go), so allowing here is that same
// default applied early rather than a gap. Stated as the expected
// cause and not the only one, because the code cannot tell them
// apart: any missing row takes this branch.
// Note the deliberate asymmetry with maybeAutoAddCreatorConnection,
// which treats not-found as "no auto-add": that path is declining a
// convenience, this one would be inventing a refusal.
// - Flag set — proceeds, and the auto-add downstream is unchanged.
//
// A real I/O error reading the connection FAILS CLOSED with a 500. The
// alternative — allowing the create when the deciding state could not
// be read — silently grants a permission the user declined, on the
// strength of a database blip. It is a 500 and not stored_state_
// unreadable because the state is not unreadable-in-principle; the read
// failed, the fault is ours, and a retry can legitimately succeed.
func (s *Server) requireWorkspaceCreationConsent(w http.ResponseWriter, r *http.Request) bool {
kind, requestID := MCPTokenIdentityFromContext(r.Context())
if kind != "oauth" || requestID == "" {
return true
}
conn, err := s.store.GetOAuthConnection(requestID)
if err != nil {
if errors.Is(err, store.ErrOAuthConnectionNotFound) {
return true
}
slog.Error("workspace creation consent check failed to read connection",
"request_id", requestID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error",
"Could not verify this connection's workspace-creation permission")
return false
}
if !conn.MayCreateWorkspaces {
// The quoted string is the checkbox's ACTUAL label, verbatim from
// the consent template (handlers_oauth.go) and the connections
// page (web console). A remedy that names a control the user
// cannot find is not a remedy; if either label is reworded, this
// message is part of that change.
//
// Both remedies are named because there are two: a fresh
// authorization, and flipping the flag on the EXISTING
// connection via PATCH /connected-apps/{id}/flags, which the
// console page drives. Saying only "re-authorize" would send a
// user through a longer path than they need.
writeError(w, http.StatusForbidden, "forbidden",
"This connection is not permitted to create workspaces. "+
"Re-authorize it with \"Let this app create new workspaces\" enabled, "+
"or enable it for this connection under /console/connected-apps.")
return false
}
return true
}
func (s *Server) handleCreateWorkspace(w http.ResponseWriter, r *http.Request) {
// Every precondition that does not need the body, from the one place
// both mint doors call (BUG-2809). Before decoding, so a refusal never
// depends on body validity and cannot be probed by shape.
mint, ok := s.beginWorkspaceMint(w, r)
if !ok {
return
}
var input models.WorkspaceCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// The payload-shaped preconditions, from that same place.
if err := validateWorkspaceMintPayload(input.Name, &input.Settings); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Context is a create-only input concept (an export carries none), so it
// stays here rather than in the shared step. Settings are already
// normalized above, which is what this call would otherwise redo.
if err := normalizeWorkspaceInput(&input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Attribution and ownership come from beginWorkspaceMint, which derived
// both AUTHORITATIVELY from the request's auth shape — never from the
// request body (WorkspaceCreate.Source is `json:"-"` for exactly this
// reason). A cli/mcp origin is what tells the dashboard an agent is
// already connected right after `pad init`, so a web client must not be
// able to spoof it to suppress the connect-agent/onboarding prompts
// (BUG-1557). The import door now gets the same value from the same
// place, which it previously got not at all.
input.Source = mint.Source
// The owner is the authenticated user, never the body (BUG-3353): a
// body owner_id let a caller with no user attribute a workspace to
// anyone, spend their plan quota, and plant it in their reach. The
// only no-user mint left is the fresh-install one, which has no user
// to name.
input.OwnerID = mint.OwnerID
// The limit is enabled only for an AUTHENTICATED owner (mint.OwnerID),
// which input.OwnerID now always equals. The pre-check in
// beginWorkspaceMint makes the same decision.
ws, err := s.store.CreateWorkspace(input, s.planLimitMintOpts(mint.OwnerID)...)
if err != nil {
if writeStorePlanLimitError(w, r, err, "") {
return
}
if writeWorkspaceSlugContended(w, err) {
return
}
writeInternalError(w, err)
return
}
// Seed collections for the new workspace using the requested template
if err := s.store.SeedCollectionsFromTemplate(ws.ID, input.Template); err != nil {
// COMPENSATE HERE TOO (BUG-2715, codex round 4). This step's failure
// produced the same husk as the owner step's — a live, ownerless,
// unseeded workspace still holding its slug, so the retry landed on
// `name-2` — and the old message said so out loud ("Workspace created
// but failed to seed collections") while leaving the caller nothing they
// could use or remove. Compensating only the owner step would have left
// the invariant true of one failure point and false of its neighbour.
userID := currentUserID(r)
msg := "Failed to seed collections: " + err.Error()
if rerr := s.removeUnusableWorkspace("create workspace (seed)", ws.ID, ws.Slug, userID, err); rerr != nil {
slog.Error("workspace seeding failed and the workspace could not be removed",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "error", rerr)
// BUG-3479: the caller is told the half-made workspace survived,
// since it holds the name and a retry will land on name-2.
msg += fmt.Sprintf("; the unusable workspace %q could not be removed and still holds its name (see the server log)", ws.Slug)
}
writeError(w, http.StatusInternalServerError, "internal_error", msg)
return
}
// Add the creator as workspace owner. FATAL as of BUG-2715: a workspace
// nobody can administer is worse than no workspace, so the door answers
// with a usable workspace or with nothing. See addOwnerOrCompensate for the
// reconcile-before-destroying rule and for the crash window this carries.
if userID := currentUserID(r); userID != "" {
if err := s.addOwnerOrCompensate("create workspace", ws.ID, ws.Slug, userID); err != nil {
writeInternalError(w, err)
return
}
}
// OAuth connection auto-add (PLAN-1519 / TASK-1521 / IDEA-1517 §1):
// when the creating call came over an OAuth-bound MCP session AND
// that connection has `may_create_workspaces=true`, immediately
// add the new workspace to the connection's allow-list with
// added_by='agent-create'. The agent can then use the workspace
// without a re-auth round-trip — the whole point of the "agent
// creates and immediately uses" flow.
//
// Best-effort: any error here logs but does NOT fail the response.
// The workspace already exists; failing the response would be
// confusing ("create succeeded but you got an error") and the user
// can always re-grant via the Connect-project modal. PAT auth has
// no request_id and the no-op short-circuits at the kind check.
s.finishWorkspaceMint(r, ws.ID)
writeJSON(w, http.StatusCreated, ws)
}
// maybeAutoAddCreatorConnection inserts the newly-created workspace
// into the calling OAuth connection's allow-list when the grant carries
// `may_create_workspaces=true` AT THE MOMENT OF THE WRITE. No-op when:
//
// - The calling token isn't an OAuth grant (PAT, CLI session token —
// they don't carry a request_id).
//
// - The grant's connection row doesn't exist (pre-Phase-C tokens
// fall here until backfill).
//
// - The flag is off. Since IDEA-2756 handleCreateWorkspace refuses a
// flag-off connection before it reaches here, so this case arises
// when the user revokes creation power from /console/connected-apps
// (PATCH /connected-apps/{id}/flags) after that gate ran. The
// workspace then exists without joining a connection whose grant was
// withdrawn mid-flight.
//
// The flag check and the insert are ONE store statement
// (AddCreatedWorkspaceIfPermitted), not a read here followed by an
// unconditional insert. Two statements left a window in which a
// revocation committing between them still added the workspace
// (BUG-2792). The store method's comment explains why Postgres also
// needs a row lock for the single statement to close that window.
//
// Errors are logged at WARN, never propagated. The caller's response
// must not fail because of an auth-bookkeeping issue post-creation.
func (s *Server) maybeAutoAddCreatorConnection(r *http.Request, workspaceID string) {
kind, requestID := MCPTokenIdentityFromContext(r.Context())
if kind != "oauth" || requestID == "" {
return
}
if s.autoAddPreInsertHook != nil {
s.autoAddPreInsertHook(requestID, workspaceID)
}
if err := s.store.AddCreatedWorkspaceIfPermitted(requestID, workspaceID); err != nil {
// A missing connection or an unset flag is a silent no-op in the
// store, so an error here is an unexpected store error: rare,
// possibly transient (a busy SQLite), and worth logging so ops
// sees it. The workspace itself is already created either way.
slog.Warn("auto-add workspace to OAuth connection failed",
"request_id", requestID,
"workspace_id", workspaceID,
"error", err,
)
}
}
func (s *Server) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
// BUG-3254: mark a guest here as the list endpoint does. RequireWorkspaceAccess
// admitted this caller on grants alone and recorded that as the "guest" role.
// A client that reaches the workspace by URL resolves it through THIS endpoint,
// and without the flag it rendered member chrome.
ws.IsGuest = workspaceRole(r) == "guest"
if !s.attachImportStatus(w, r, ws) {
return
}
writeJSON(w, http.StatusOK, ws)
}
// attachImportStatus sets ws.ImportStatus from the partial-import marker
// (TASK-896). The note is shown to owners and instance admins only; anyone
// else who can read the workspace sees just the status. Answers 500 and
// returns false on a store error.
func (s *Server) attachImportStatus(w http.ResponseWriter, r *http.Request, ws *models.Workspace) bool {
st, err := s.store.GetWorkspaceImportStatus(ws.ID)
if err != nil {
writeInternalError(w, err)
return false
}
if st != nil && !mayReadImportNote(r) {
st.Note = ""
}
ws.ImportStatus = st
return true
}
// mayReadImportNote: the workspace's owners, and instance admins who are
// MEMBERS of it. An admin admitted only through a guest grant reads the
// workspace as a guest and gets the status alone (codex r2).
func mayReadImportNote(r *http.Request) bool {
role := workspaceRole(r)
if role == "owner" {
return true
}
if role == "" || role == "guest" {
return false
}
u := currentUser(r)
return u != nil && u.Role == "admin"
}
func (s *Server) handleUpdateWorkspace(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
existing, ok := s.getWorkspace(w, r)
if !ok {
return
}
var input models.WorkspaceUpdate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := normalizeWorkspaceUpdateInput(&input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
ws, err := s.store.UpdateWorkspace(existing.Slug, input)
if err != nil {
writeInternalError(w, err)
return
}
if ws == nil {
writeWorkspaceNotFound(w, "Workspace not found")
return
}
if input.ClearImportStatus {
if err := s.store.ClearWorkspaceImportStatus(ws.ID); err != nil {
writeInternalError(w, err)
return
}
}
if !s.attachImportStatus(w, r, ws) {
return
}
s.publishEvent(events.WorkspaceUpdated, ws.ID, "", ws.Name, "", "", "")
writeJSON(w, http.StatusOK, ws)
}
func (s *Server) handleDeleteWorkspace(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
// Read before the write, like every lifecycle door (TASK-3272). A soft
// delete keeps these rows, but reading first keeps one rule for all.
accessUsers := s.workspaceAccessUsers(ws.ID)
err := s.store.DeleteWorkspace(ws.Slug)
if errors.Is(err, sql.ErrNoRows) {
// Resolved above and gone by the time the delete ran: the
// workspace itself no longer resolves, so it is the marked 404.
writeWorkspaceNotFound(w, "Workspace not found")
return
}
if err != nil {
// Any other failure is not evidence the workspace is gone, and
// must not carry the marker that tells a client to stop asking
// about it (BUG-3069).
writeInternalError(w, err)
return
}
s.publishWorkspaceAccessChangedFromRequest(r, ws.ID, watchevents.AccessDeleted, accessUsers...)
w.WriteHeader(http.StatusNoContent)
}
// deletedWorkspaceResponse is one entry in the GET /workspaces/deleted
// payload: the soft-deleted workspace plus purge-window fields the UI
// renders as "N days left" before it's permanently removed. PurgeAt and
// DaysLeft are BOTH derived from workspacePurgeRetention so restore and
// the purge sweeper (workspace_purge.go) share exactly one window — no
// drift.
type deletedWorkspaceResponse struct {
models.Workspace
// PurgeAt is when the retention sweeper will hard-delete the
// workspace (deleted_at + workspacePurgeRetention).
PurgeAt time.Time `json:"purge_at"`
// DaysLeft is whole days remaining until PurgeAt, rounded up and
// clamped at 0. 0 means it's eligible for purge on the next sweep.
DaysLeft int `json:"days_left"`
}
// handleListDeletedWorkspaces lists the soft-deleted workspaces the
// caller OWNS that are still restorable — i.e. not yet past the purge
// retention window. Owner-scoped inside the store query; the cutoff is
// derived from workspacePurgeRetention so this list and the purge
// sweeper agree on exactly which workspaces are recoverable.
func (s *Server) handleListDeletedWorkspaces(w http.ResponseWriter, r *http.Request) {
userID := currentUserID(r)
if userID == "" {
// No authenticated user (fresh install / pre-auth) has no owned
// workspaces to restore — return an empty list rather than 401
// so the switcher's "recently deleted" section just renders
// nothing.
writeJSON(w, http.StatusOK, []deletedWorkspaceResponse{})
return
}
// Use the retention the purge sweeper actually enforces (which may be
// overridden via SetWorkspacePurgeConfig / PAD_WORKSPACE_PURGE_RETENTION)
// so the restore window + purge_at/days_left never drift from the
// horizon at which the workspace is really hard-deleted.
retention := s.effectivePurgeRetention()
cutoff := time.Now().UTC().Add(-retention)
workspaces, err := s.store.ListDeletedWorkspaces(userID, cutoff)
if err != nil {
writeInternalError(w, err)
return
}
// OAuth consent scoping (BUG-2102): workspace-global route, outside
// RequireWorkspaceAccess. A consent-scoped token must not enumerate
// soft-deleted workspaces it wasn't granted. No-op for PAT / web session.
workspaces = filterWorkspacesByTokenAllowlist(r.Context(), workspaces)
now := time.Now().UTC()
out := make([]deletedWorkspaceResponse, 0, len(workspaces))
for _, ws := range workspaces {
entry := deletedWorkspaceResponse{Workspace: ws}
if ws.DeletedAt != nil {
entry.PurgeAt = ws.DeletedAt.Add(retention)
days := int(math.Ceil(entry.PurgeAt.Sub(now).Hours() / 24))
if days < 0 {
days = 0
}
entry.DaysLeft = days
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, out)
}
// handleRestoreWorkspace un-soft-deletes a workspace, resurfacing it (and
// everything transitively hidden underneath) intact. Owner-only, mirroring
// the Danger-Zone gating on handleDeleteWorkspace — but it can't lean on
// RequireWorkspaceAccess/requireMinRole because those resolve only LIVE
// workspaces (`deleted_at IS NULL`), so a soft-deleted workspace 404s
// before any handler runs. Instead it resolves the soft-deleted row
// directly and checks ownership here.
//
// Status codes:
// - 404 — no restorable soft-deleted workspace with that slug (already
// live, unknown, or hard-purged).
// - 403 — the workspace exists but the caller doesn't own it.
// - 200 — restored; returns the now-live workspace.
func (s *Server) handleRestoreWorkspace(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
ws, err := s.store.GetDeletedWorkspaceBySlug(slug)
if err != nil {
writeInternalError(w, err)
return
}
if ws == nil {
// Not soft-deleted (live), unknown, or already hard-purged —
// nothing to restore.
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
return
}
// OAuth consent scoping (BUG-2102): this route is a sibling of the
// /{slug} subrouter, so RequireWorkspaceAccess never gates it. A
// consent-scoped token must not restore a workspace outside its
// allow-list, even one the user owns. Return the same 404 as
// "not restorable" so the token can't probe which slugs exist.
// nil/wildcard allow-list (unpinned PAT / web session) → no gate. A pinned
// PAT is refused earlier, by the route gate (BUG-3982).
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
return
}
// Owner-only, with NO fresh-install / UserCount==0 bypass. A
// soft-deleted workspace existing while zero users remain is precisely
// the account-deletion case (DeleteAccountAtomic soft-deletes the
// owner's workspaces, then removes the owner) — a bypass there would
// let an unauthenticated caller restore an account-deleted workspace by
// guessing its slug and resurface data whose owner is gone. On a
// genuine fresh install there are no soft-deleted workspaces to restore
// anyway, so requiring the owner unconditionally loses nothing.
userID := currentUserID(r)
if userID == "" || ws.OwnerID != userID {
// Not the owner. If the owner user no longer exists — the
// account-deletion case, where DeleteAccountAtomic soft-deleted
// this workspace and then removed its owner — NO live user could
// ever restore it, so return the same 404 as "not restorable"
// instead of 403. That stops a guessed slug from confirming an
// account-deleted workspace row still exists. A genuine non-owner
// attempt on a live-owned workspace still gets 403. (GetUser only
// runs on this rejection path, never the owner's success path.)
if owner, _ := s.store.GetUser(ws.OwnerID); owner == nil {
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
return
}
writeError(w, http.StatusForbidden, "forbidden", "Only the workspace owner can restore it")
return
}
// Enforce the SAME purge horizon the deleted-list uses so restore and
// the list agree: a workspace older than the retention window is
// already eligible for hard-purge and is hidden from the list, so it
// must not be restorable by slug either (the sweeper may not have run
// yet — no attachments registry, deferred blob, or startup lag). Checked
// after the owner gate so a non-owner can't probe window state.
cutoff := time.Now().UTC().Add(-s.effectivePurgeRetention())
if ws.DeletedAt == nil || !ws.DeletedAt.After(cutoff) {
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
return
}
if err := s.store.RestoreWorkspace(slug); err != nil {
if err == sql.ErrNoRows {
// Raced with a concurrent restore/purge between the lookup and
// the update — treat as nothing-to-restore.
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
return
}
writeInternalError(w, err)
return
}
// Published once the restore commits, before the re-fetch below can 500
// over it (TASK-3272, codex round 1's class).
s.publishWorkspaceAccessChangedFromRequest(r, ws.ID, watchevents.AccessRestored, s.workspaceAccessUsers(ws.ID)...)
// Re-fetch the now-live row so the response carries the fully hydrated,
// un-deleted workspace.
restored, err := s.store.GetWorkspaceBySlug(slug)
if err != nil {
writeInternalError(w, err)
return
}
if restored == nil {
// Extremely unlikely (just restored), but don't lie about success.
writeWorkspaceNotFound(w, "Workspace not found")
return
}
// Log the restore in the (now live) workspace's activity feed, mirroring
// how item restore logs a "restored" action.
s.logActivity(restored.ID, "", "restored", r)
s.publishEvent(events.WorkspaceUpdated, restored.ID, "", restored.Name, "", "", "")
writeJSON(w, http.StatusOK, restored)
}
func (s *Server) handleExportWorkspace(w http.ResponseWriter, r *http.Request) {
// `?format=tar` switches to the tar.gz bundle that includes
// attachment blobs (TASK-884). Default stays JSON for backward
// compat — existing automation hitting this endpoint without a
// query param keeps working unchanged. The CLI's
// `pad workspace export` opts into the bundle by default.
if strings.EqualFold(r.URL.Query().Get("format"), "tar") {
s.handleExportWorkspaceBundle(w, r)
return
}
if !requireMinRole(w, r, "owner") {
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
if !s.requireUnrestrictedExportAccess(w, r, ws.ID) {
return
}
export, _, err := s.store.ExportWorkspaceSnapshot(r.Context(), ws.Slug, false)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
w.Header().Set("Content-Disposition", contentDisposition("attachment", ws.Slug+"-export.json"))
writeJSON(w, http.StatusOK, export)
}
func (s *Server) handleImportWorkspace(w http.ResponseWriter, r *http.Request) {
// Every pre-body precondition, from the one place both mint doors call
// (BUG-2809). This used to be two gates written out here, each added
// separately after a reviewer found the create door had it and this one
// did not: the OAuth consent grant (IDEA-2756) and the user-scoped plan
// limit (BUG-2793).
//
// The PLACEMENT is the load-bearing part rather than the call. It sits
// ABOVE the Content-Type dispatch, so the tar.gz bundle path is covered
// by this same line rather than needing its own, and above either body
// read, so a refused caller never uploads anything. The two reads have
// different bounds (the JSON path's 64 MiB decodeJSONWithLimit, the
// bundle path's own configurable and much larger limit); the gate
// precedes both.
//
// Reachability of the consent half, stated precisely because the create
// door's is different: NO OAuth-bound caller can reach this handler
// today. The OAuth identity is stashed only by MCPBearerAuth, mounted on
// /mcp alone, so an OAuth connection reaches an /api/v1 handler only
// through the in-process MCP dispatcher — and its route table has no
// `workspace import` action. The gate is correct but currently
// unexercised in production: it exists so that adding that action later
// cannot silently reopen the door.
mint, ok := s.beginWorkspaceMint(w, r)
if !ok {
return
}
// After the mint gate and before either body read: both body shapes read
// under the per-Read deadline rather than the server-wide one (BUG-3184).
s.withImportReadDeadline(w, r)
// Content-Type dispatch:
// application/gzip / application/x-gzip / application/x-tar
// → tar.gz bundle path (TASK-885) — handles attachments.
// anything else → JSON path (legacy items-only).
//
// We prefer Content-Type over file-magic sniffing so a misnamed
// upload fails fast with a clear error rather than silently going
// through the wrong code path. The CLI's pad import command sets
// the right header based on the file extension; web UI does the
// same when uploading a .tar.gz.
ct := strings.TrimSpace(r.Header.Get("Content-Type"))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.ToLower(strings.TrimSpace(ct))
if ct == "application/gzip" || ct == "application/x-gzip" || ct == "application/x-tar" {
// mint travels as an ARGUMENT rather than on the Server: it is
// per-request state, and the two things it carries (owner, source)
// are exactly the two a concurrent request would differ on.
s.handleImportWorkspaceBundle(w, r, mint)
return
}
var data models.WorkspaceExport
// WorkspaceExport contains all collections, items, comments, and item
// versions for the workspace — even a modest project export blows past
// the default 2 MiB decodeJSON cap. 64 MiB is well above any realistic
// single-workspace backup while still far from the heap-exhaustion
// range the default cap protects against.
//
// --repair-nul (DOC-2823 S3 / BUG-2810). The default is strict; the flag
// buys the body ONE repair attempt and then runs the same gate on the
// repaired bytes, so this is not a decode path that skips the check.
repair := &nulRepairTally{Enabled: wantsNULRepair(r)}
var decodeErr error
if repair.Enabled {
decodeErr = decodeJSONRepairingNUL(r, &data, 64<<20, repair)
} else {
decodeErr = decodeJSONWithLimit(r, &data, 64<<20)
}
if decodeErr != nil {
msg := "invalid export data: " + decodeErr.Error()
if errors.Is(decodeErr, errJSONBodyNUL) {
// The strict refusal NAMES the remedy, per Dave's day-54 ruling —
// and TestImportStrictRefusalNamesTheWorkingRemedy drives the named
// flag against this exact failing body, because a suggested remedy
// is an untested contract claim until it has been run (PATTE-135).
msg += nulRepairRemedy(repair)
}
writeError(w, http.StatusBadRequest, "bad_request", msg)
return
}
// Optional: override workspace name via query param
newName := r.URL.Query().Get("name")
// The payload-shaped preconditions, from the same place the create door
// calls (BUG-2809). The EFFECTIVE name is checked — the override when
// one was given, the bundle's own otherwise — because that is what
// becomes the slug, and an empty name slugifies to an EMPTY SLUG, which
// is a routing key. Measured before the fix: the first such import took
// the empty slug and a second landed on "-2".
effectiveName := data.Workspace.Name
if newName != "" {
effectiveName = newName
}
if verr := validateWorkspaceMintPayload(effectiveName, &data.Workspace.Settings); verr != nil {
writeError(w, http.StatusBadRequest, "bad_request", verr.Error())
return
}
// Owner and source both come from the mint context resolved above, so
// an imported workspace is attributed the same way a created one is
// (BUG-1557 — import previously got no source at all).
userID := mint.OwnerID
// BUG-3032: read the bundle's stale-body marker BEFORE the import, while
// `data` is still the thing the exporter wrote. Nothing after this point can
// recover the fact — the destination has no op-log to evaluate against.
staleBodies := &staleBodyTally{}
staleBodies.Observe(&data)
ws, importReport, err := s.store.ImportWorkspaceWithReport(&data, newName, userID, mint.Source, s.planLimitMintOpts(userID)...)
if err != nil {
if writeStorePlanLimitError(w, r, err, "") {
return
}
// A refusal about the EXPORT the caller supplied is a 400, not a 500
// (BUG-2951). This door answered 500 for every failure, including the
// prefix-grammar refusal whose message tells the caller which
// collection to edit and re-import — an actionable instruction
// delivered under a status that says "the server broke, try later".
// The sibling bundle-import door (handlers_import_bundle.go) already
// answers 400 for this class; this aligns the two.
if v, ok := store.AsValidationError(err); ok {
writeError(w, http.StatusBadRequest, "import_failed", v.Reason)
return
}
if writeWorkspaceSlugContended(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "import_failed", err.Error())
return
}
// Add the importer as workspace owner (mirrors handleCreateWorkspace).
// Same posture as that path, which is now FATAL (BUG-2715): an import that
// fails here used to return 201 for a workspace nobody could administer.
if userID != "" {
if err := s.addOwnerOrCompensate("import workspace", ws.ID, ws.Slug, userID); err != nil {
writeInternalError(w, err)
return
}
}
if repair.Enabled && repair.Replaced > 0 {
slog.Info("workspace import repaired NUL escapes on the operator's instruction",
"workspace_id", ws.ID, "replaced", repair.Replaced)
}
if staleBodies.Count > 0 {
slog.Info("workspace import carried item bodies that were behind their live collaborative documents",
"workspace_id", ws.ID, "stale_bodies", staleBodies.Count)
}
// Post-creation side effects, on the success path only (BUG-2794).
s.finishWorkspaceMint(r, ws.ID)
repair.SetHeader(w)
staleBodies.SetHeader(w)
setImportCollapsedHeader(w, importReport)
writeJSON(w, http.StatusCreated, importedWorkspaceResponse{
Workspace: ws,
DroppedUniqueValues: importReport.DroppedUniqueValues,
})
}
// writeWorkspaceSlugContended answers 409 when every attempt to mint a
// workspace lost its slug to a concurrent create (BUG-3307). That is
// contention, which a retry resolves, not a server fault.
func writeWorkspaceSlugContended(w http.ResponseWriter, err error) bool {
var ce *store.WorkspaceSlugContendedError
if !errors.As(err, &ce) {
return false
}
writeError(w, http.StatusConflict, "conflict", "Too many workspaces with this name are being created at once; try again")
return true
}