mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
551a338cbd
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
153 lines
7.0 KiB
Go
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)...)
|
|
}
|