Files
pad/internal/server/workspace_mint.go
T
xarmian 551a338cbd fix(auth): a user-less legacy workspace token reaches only its own workspace (BUG-3353) (#1734)
A legacy workspace token (an API token with no user) carries one workspace
and nobody's authority, but RequireAuth admitted it on every route. On
global routes it acted with no user:
- POST /workspaces minted a workspace with any slug, attributed to the
  body's owner_id, with no plan limits and no member row. That let it plant
  workspaces on a victim and fill their cloud quota.
- POST /workspaces/import minted unowned workspaces without limit.
- GET /workspaces fell into the fresh-install branch and listed every
  tenant on the instance.

The fix is deny-by-default. RequireAuth admits such a token only on
workspace-scoped routes, where RequireWorkspaceAccess pins it to its own
workspace, and on a short allowlist of global routes that are either
confined to the token's workspace (GET /workspaces, /search, /events,
/collab) or static. Anything else answers 403 legacy_token_not_allowed.
TestBUG3353_EveryGlobalRouteRefusesALegacyToken walks the router, so a
route added later fails closed by test.

- GET /workspaces with such a token lists its own workspace only. The
  no-user bootstrap branch now needs a user count that reads zero.
- Minting: the owner is always the authenticated user and a body owner_id
  is ignored. beginWorkspaceMint refuses a user-less caller unless the
  instance has no users and there is no token. That settles the open
  question BUG-2809's trail left.
- A token pointed at a foreign workspace now answers exactly what a missing
  one does: the workspace 404 on RequireWorkspaceAccess and /events, and
  the item 404 on collab. The 403 told the token which slugs and items
  exist. (Real users' BUG-3069 contract is unchanged, per the lead's
  ruling.)
- TokenAuth never downgrades a user-owned token to a user-less one: a user
  that cannot be read is a 500, one that is gone a 401. Before, the request
  went on with only the token's workspace, which is a legacy token's reach.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 16:06:11 -04:00

153 lines
7.0 KiB
Go

package server
import (
"errors"
"fmt"
"net/http"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// Workspace minting has TWO doors — handleCreateWorkspace and
// handleImportWorkspace, the latter with a second body shape behind it
// (handleImportWorkspaceBundle) — and both reach store.CreateWorkspace.
// Every precondition one enforces, the other must enforce, FROM HERE
// (BUG-2809).
//
// The record is why this exists rather than a note asking people to
// remember: two preconditions diverged and were fixed one at a time, each
// found by a reviewer rather than by the door that lacked it — the OAuth
// consent grant (IDEA-2756) and the user-scoped workspace plan limit
// (BUG-2793). A third divergence was live when this landed: import
// accepted an empty workspace name, and an empty name slugifies to an
// EMPTY SLUG, which is a routing key. Measured on that tip: the first such
// import took the empty slug and a second one landed on "-2".
//
// The preconditions split by WHEN they can run, and that split is
// load-bearing rather than tidy:
//
// - beginWorkspaceMint runs BEFORE the body is read, so a refused caller
// never uploads a bundle, and so a refusal cannot be probed by body
// shape. It also resolves the two things a mint is attributed with.
// - validateWorkspaceMintPayload runs AFTER, because its subject is the
// payload. It returns an error rather than writing one: the JSON doors
// answer 400 `bad_request` and the bundle door answers 400 `bad_bundle`
// through importStatusError, so the RULE is shared and the envelope
// stays each door's own.
// workspaceMintAuth carries what a mint door resolves from the REQUEST, as
// opposed to from its payload. Both fields are authoritative here and must
// never be read from a request body: Source decides UI affordances
// (BUG-1557) and OwnerID decides who the workspace belongs to.
type workspaceMintAuth struct {
// OwnerID is the authenticated user, or "" for a caller TokenAuth
// admits with no resolved user (a legacy workspace token, or the
// fresh-install window). See the quota note in beginWorkspaceMint.
OwnerID string
// Source is derived from the auth shape: "cli" for bearer/api-token
// callers, "web" for cookie sessions.
Source string
}
// beginWorkspaceMint runs every precondition that does not need the body,
// and returns false having already written the response when one refuses.
//
// Call it FIRST in any handler that mints a workspace — above the body
// read, and for the import door above the Content-Type dispatch, so one
// line covers both body shapes.
func (s *Server) beginWorkspaceMint(w http.ResponseWriter, r *http.Request) (workspaceMintAuth, bool) {
// Consent: the OAuth connection's may_create_workspaces grant
// (IDEA-2756). First, so a refusal never depends on body validity.
if !s.requireWorkspaceCreationConsent(w, r) {
return workspaceMintAuth{}, false
}
userID := currentUserID(r)
// A mint with no user is the fresh-install setup flow and nothing else
// (BUG-3353, settling BUG-2809's open question): a user-less legacy
// workspace token minted unowned, unlimited workspaces with any slug.
// RequireAuth already refuses such a token on this route; this is the
// door's own answer, so a new route onto it cannot reopen the hole.
if userID == "" {
count, err := s.userCount()
if err != nil || count != 0 || tokenWorkspaceID(r) != "" {
writeError(w, http.StatusForbidden, "legacy_token_not_allowed",
"Creating a workspace requires a signed-in user")
return workspaceMintAuth{}, false
}
}
// Plan limit, user-scoped (BUG-2793). An import IS a new workspace and
// counts, with no exemption for re-importing something you previously
// owned — export provenance is not trustworthy enough to gate billing
// on, and the at-limit case that deserves relief (undoing a delete) is
// served by the restore endpoint, which mints nothing.
//
// The `userID != ""` guard is not defensive padding: past the check
// above, an empty userID is the fresh-install mint, which has no plan to
// charge.
if userID != "" {
if !s.enforceUserPlanLimit(w, r, userID, "workspaces") {
return workspaceMintAuth{}, false
}
}
_, source := actorFromRequest(r)
return workspaceMintAuth{OwnerID: userID, Source: source}, true
}
// errWorkspaceNameRequired is the empty-name refusal, exported as a value so
// a door can tell it from a settings failure without matching on prose.
var errWorkspaceNameRequired = errors.New("Name is required")
// validateWorkspaceMintPayload enforces the preconditions whose subject is
// the payload, and NORMALIZES the settings in place when they are valid.
//
// name is the EFFECTIVE name — for an import that is the ?name= override
// when one was given, otherwise the bundle's own — because the effective
// name is what becomes the slug.
//
// settings is a pointer so a valid blob is written back normalized; pass
// nil when the door has no settings to offer. Rejecting malformed settings
// HERE is what makes the two doors agree on the status code: the store
// normalizes too (createWorkspaceQ), but a store error surfaces as 500
// import_failed, and caller-supplied malformed JSON is a 400 on the create
// door. Same input, same answer, whichever door it arrives at.
func validateWorkspaceMintPayload(name string, settings *string) error {
if name == "" {
return errWorkspaceNameRequired
}
if settings != nil && *settings != "" {
normalized, err := models.NormalizeWorkspaceSettings(*settings)
if err != nil {
return fmt.Errorf("invalid settings JSON: %w", err)
}
*settings = normalized
}
return nil
}
// finishWorkspaceMint runs every side effect a mint owes AFTER the workspace
// exists: adding it to the calling OAuth connection's allow-list
// (maybeAutoAddCreatorConnection), so an agent that minted a workspace can use
// it without re-authorising, and telling everyone who now reaches it (at mint,
// the owner just attached) through workspace_access_changed (TASK-3272).
//
// It is the back half of beginWorkspaceMint, and exists for the same reason
// (BUG-2794). The import doors used to skip this step, so an OAuth connection
// with an explicit allow-list got a 201 for an imported workspace it could not
// then see. Every door that mints calls it, on its SUCCESS path only, after
// the owner is attached and before the 201. The bundle door's workspace row
// commits before its items do, so a failed bundle import can leave a partial
// workspace behind; that one must not join the connection's allow-list, and a
// call here on the error path would put it there.
//
// Best-effort, like the step it wraps: the workspace exists either way, so a
// failure logs and never fails the response.
func (s *Server) finishWorkspaceMint(r *http.Request, workspaceID string) {
s.maybeAutoAddCreatorConnection(r, workspaceID)
s.publishWorkspaceAccessChangedFromRequest(r, workspaceID, watchevents.AccessGained, s.workspaceAccessUsers(workspaceID)...)
}