mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
4f277f836a
* fix(apps): an app upload is charged against the plan's storage cap (BUG-3653) App uploads were capped only per install (2,000 files, 1 GiB), so on Pad Cloud an app could add bytes past the workspace owner's storage_bytes limit that a human upload would be refused for. - FenceSpec.StorageLimit makes BeginFenced take the owner's storage plan-limit lock before its install read, as the lock's ordering rule requires. FencedTx.CheckStorageLimit charges bytes under it and returns ErrAppStorageLimit, which carries no totals, like the item cap. - Admission checks the cap at the declared size before any byte is read, so the common refusal writes no blob. The row insert charges it authoritatively, and an owner change mid-check is retried (ErrStorageOwnerMoved, now exported for this caller). - The server answers 403 storage_limit_reached. - appstore sets the flag from Options.PlanLimit, so self-hosted is unchanged. - The boundary allow-list takes the read-only storage helpers and the advisory lock, each with what it writes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(apps): the fence's lock-order comments name the storage lock an app upload takes first (BUG-3653) Codex round 1: two comments said the install FOR SHARE (fenced_tx.go) and the seq lock (fenced_attachments.go) come first, which a StorageLimit spec now precedes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): an app upload whose row never commits reclaims its blob (BUG-3652, BUG-3653) With #2118 merged, the app door gets the same reclaim as the human doors. PendingUpload.Close, when the row never committed (its insert was refused, as by the storage cap after an admission another upload raced, or the caller's re-check refused it before Insert), hands the canonical blob to Options.Reclaimer while the hash guard is still held. The server implements it with reclaimRefusedBlob on the FS backend, which keeps the blob when another row or writer shares the hash. Options.Reclaimer is an interface, not a func field: the appstore boundary guard refuses function values in app code outright (rule 9), and refuses interface calls only where an internal/store type could answer them (rule 4). The server's implementation reads row existence for the hash and deletes a blob file; it writes no row. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): a pending upload attempts its blob reclaim once, cleared before the call (BUG-3652) Codex round: the storage key was cleared after the reclaimer returned. It is now captured and cleared first, so a second Close can never retry the reclaim after the hash guard was released, whatever the first attempt did. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
782 lines
30 KiB
Go
782 lines
30 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/PerpetualSoftware/pad/internal/appstore"
|
||
"github.com/PerpetualSoftware/pad/internal/attachments"
|
||
"github.com/PerpetualSoftware/pad/internal/models"
|
||
"github.com/PerpetualSoftware/pad/internal/store"
|
||
)
|
||
|
||
// The installed-app API (SPEC-6 §4, U6a, TASK-3401): /api/app/v1, a router of
|
||
// its own, mounted outside TokenAuth and RequireAuth so no session or PAT can
|
||
// ever authenticate it, and no app token can reach /api/v1.
|
||
//
|
||
// A request passes, in order:
|
||
// 1. appTokenAuth: an active app ACCESS token (introspectAppToken: audience,
|
||
// binding, install active, client enabled, epoch current); the install's
|
||
// own bot as the actor for a service token, or the person for a
|
||
// delegated one (appAdmitDelegated, TASK-3399).
|
||
// 2. per route, the access check: a "write" row refuses a read token with
|
||
// 403 before anything is resolved or looked up.
|
||
// 3. per route, requireAppWorkspace: {ws} must be the token's workspace by
|
||
// ID (R3), else the same 404 a missing workspace gets; then every context
|
||
// key the shared helpers read is set from the grant, and checked.
|
||
// 4. the handler, which authorizes through the shared helpers under the
|
||
// ceiling and encodes a fixed DTO.
|
||
|
||
// appRoute is one row of the route table. The table builds the router and
|
||
// drives the tests; adding a route is a spec amendment.
|
||
type appRoute struct {
|
||
Method string
|
||
Template string // under /api/app/v1/workspaces/{ws}
|
||
Name string
|
||
Handler func(*Server, http.ResponseWriter, *http.Request)
|
||
Auth string // "service", "delegated" or "either"
|
||
// Access is "read", "write" or "stream". A stream row is a read whose
|
||
// body is NOT buffered (an attachment download, lead ruling for U6c): the
|
||
// handler re-admits the request itself, immediately before its first
|
||
// byte, and then streams.
|
||
Access string
|
||
}
|
||
|
||
// appRoutes is the table: U6a's reads, U6b's writes and U6c's attachments.
|
||
var appRoutes = []appRoute{
|
||
{"POST", "/items/{itemID}/attachments", "appUploadAttachment", (*Server).appUploadAttachment, "either", "write"},
|
||
{"GET", "/attachments/{attachmentID}", "appGetAttachment", (*Server).appGetAttachment, "either", "read"},
|
||
{"GET", "/attachments/{attachmentID}/content", "appDownloadAttachment", (*Server).appDownloadAttachment, "either", "stream"},
|
||
// HEAD probes size and type through the same gate (ServeContent writes
|
||
// no body for it), as the regular download allows.
|
||
{"HEAD", "/attachments/{attachmentID}/content", "appHeadAttachment", (*Server).appDownloadAttachment, "either", "stream"},
|
||
{"POST", "/collections/{collSlug}/items", "appCreateItem", (*Server).appCreateItem, "either", "write"},
|
||
{"PATCH", "/items/{itemID}", "appUpdateItem", (*Server).appUpdateItem, "either", "write"},
|
||
{"POST", "/items/{itemID}/comments", "appCreateComment", (*Server).appCreateComment, "either", "write"},
|
||
{"PATCH", "/items/{itemID}/comments/{commentID}", "appUpdateComment", (*Server).appUpdateComment, "either", "write"},
|
||
{"DELETE", "/items/{itemID}/comments/{commentID}", "appDeleteComment", (*Server).appDeleteComment, "either", "write"},
|
||
{"GET", "/collections", "appListCollections", (*Server).appListCollections, "either", "read"},
|
||
{"GET", "/collections/{collSlug}", "appGetCollection", (*Server).appGetCollection, "either", "read"},
|
||
{"GET", "/collections/{collSlug}/items", "appListItems", (*Server).appListItems, "either", "read"},
|
||
{"GET", "/items/{itemID}", "appGetItem", (*Server).appGetItem, "either", "read"},
|
||
{"GET", "/items/{itemID}/comments", "appListComments", (*Server).appListComments, "either", "read"},
|
||
{"GET", "/me", "appMe", (*Server).appMe, "either", "read"},
|
||
}
|
||
|
||
// appAPIPrefix is where the app router is mounted.
|
||
const appAPIPrefix = "/api/app/v1"
|
||
|
||
// appCtxKey holds the immutable app context of a request.
|
||
type appCtxKey struct{}
|
||
|
||
// appContext is what the token and workspace steps established. It is set
|
||
// once and never modified; every app handler reads it.
|
||
type appContext struct {
|
||
Grant *AppTokenGrant
|
||
InstallID string
|
||
WorkspaceID string
|
||
WorkspaceSlug string
|
||
Access string // "read" or "write"
|
||
AuthKind string // "service" (the bot) or "delegated" (a person, TASK-3399)
|
||
Actor *models.User
|
||
ReadCeiling []string // companion ∪ system collection ids
|
||
Companions []string // companion collection ids (the write set)
|
||
Role string // the role the shared helpers see
|
||
|
||
// token is the presented access token, kept so the request can be
|
||
// re-admitted before its response is sent. Never serialized or logged.
|
||
token string
|
||
}
|
||
|
||
func appContextFrom(r *http.Request) *appContext {
|
||
c, _ := r.Context().Value(appCtxKey{}).(*appContext)
|
||
return c
|
||
}
|
||
|
||
// appFenceSpec is the appstore fence for this request.
|
||
func (c *appContext) appFenceSpec() store.FenceSpec {
|
||
return store.FenceSpec{InstallID: c.InstallID, WorkspaceID: c.WorkspaceID, Epoch: c.Grant.AuthEpoch, Companions: c.Companions}
|
||
}
|
||
|
||
// appStoreState lazily builds the server's one appstore. One per Server,
|
||
// because the upload reservations live in process (U6c wires the blobs).
|
||
type appStoreState struct {
|
||
once sync.Once
|
||
store *appstore.Store
|
||
err error
|
||
}
|
||
|
||
// appETagLabel names the HKDF purpose of the etag key: server-only,
|
||
// mandatory and persistent (lead ruling R5 on TASK-3401).
|
||
const appETagLabel = "pad-app-etag-v1"
|
||
|
||
func (s *Server) appStore() (*appstore.Store, error) {
|
||
s.appstoreState.once.Do(func() {
|
||
key, err := s.store.DeriveServerKey(appETagLabel)
|
||
if err != nil {
|
||
s.appstoreState.err = err
|
||
return
|
||
}
|
||
// Uploads write through the filesystem blob store the human uploads
|
||
// use, with constant work, under the same hash guard. With no FS
|
||
// backend wired the app store refuses uploads and downloads
|
||
// (ErrAppNoAttachmentStore). Resolved once, like the store itself,
|
||
// which holds the in-process upload reservations: SetAttachments runs
|
||
// at startup, before any request.
|
||
var blobs *attachments.FSStore
|
||
if s.attachments != nil {
|
||
blobs, _ = s.attachments.Backends()[attachments.FSPrefix].(*attachments.FSStore)
|
||
}
|
||
var reclaimer appstore.BlobReclaimer
|
||
if blobs != nil {
|
||
reclaimer = appBlobReclaimer{s: s, backend: blobs}
|
||
}
|
||
s.appstoreState.store = appstore.New(s.store, appstore.Options{PlanLimit: s.cloudMode, ETagKey: key,
|
||
Blobs: blobs, InFlight: s.UploadInFlight(), Reclaimer: reclaimer})
|
||
})
|
||
return s.appstoreState.store, s.appstoreState.err
|
||
}
|
||
|
||
// appUnscopedRoutes are mounted at the router's root, not under
|
||
// /workspaces/{ws}: the workspace is the token's, injected before the same
|
||
// per-route stack runs. U11's redeem (§6: /context/redeem) is one, so a code
|
||
// sent with any workspace in its path cannot be refused before it is
|
||
// consumed (codex r1 on U11).
|
||
var appUnscopedRoutes = []appRoute{
|
||
{"POST", "/context/redeem", "appRedeemContext", (*Server).appRedeemContext, "service", "read"},
|
||
// The token's own binding (BUG-3416): where an app learns its install
|
||
// and its workspace, the {ws} of every scoped route. A delegated token
|
||
// never sees the install-code redeem, so this is its only way in. It
|
||
// reads the binding the token step already resolved and nothing else,
|
||
// after the same workspace, access and re-admission stack as any read.
|
||
{"GET", "/me", "appInstallMe", (*Server).appInstallMe, "either", "read"},
|
||
}
|
||
|
||
// registerAppAPIRoutes mounts the app router. The router sets its own
|
||
// Content-Type (jsonContentType matches only /api/v1), and RateLimit runs
|
||
// AFTER the token step, so it keys on the actor rather than the address.
|
||
func (s *Server) registerAppAPIRoutes(r chi.Router) {
|
||
r.Route(appAPIPrefix, func(r chi.Router) {
|
||
r.Use(appJSONContentType)
|
||
r.Use(s.requireAppsAvailable)
|
||
r.Use(s.appTokenAuth)
|
||
r.Use(s.RateLimit)
|
||
build := func(rt appRoute) http.Handler {
|
||
handler := rt.Handler
|
||
access := rt.Access
|
||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if s.appBeforeHandler != nil {
|
||
s.appBeforeHandler()
|
||
}
|
||
// Writes are NOT re-validated after commit (lead ruling R2,
|
||
// U6b): a write's authority is decided inside its FencedTx
|
||
// under the epoch fence, and refusing the response AFTER a
|
||
// commit would hide a write that happened and invite a
|
||
// duplicate retry. Re-admission before send is for reads.
|
||
if access == "write" {
|
||
// A write registers its re-checks too, so it can re-admit
|
||
// itself after reading its body and before its write
|
||
// (appReadmitBeforeWrite, codex U5b-2 r1). It is never
|
||
// re-validated after commit (R2).
|
||
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
|
||
return
|
||
}
|
||
// A stream row registers its re-checks like any read, but
|
||
// re-admits itself before its first byte (appStreamGate)
|
||
// instead of being buffered: a download is too large to
|
||
// hold, and nothing is sent before the gate passes.
|
||
if access == "stream" {
|
||
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
|
||
return
|
||
}
|
||
// The handler writes into a buffer; nothing reaches the
|
||
// app until the grant is re-validated (codex r1 P1).
|
||
buf := newAppResponseBuffer()
|
||
r = r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{}))
|
||
handler(s, buf, r)
|
||
if s.appAfterHandler != nil {
|
||
s.appAfterHandler()
|
||
}
|
||
if err := s.appRevalidate(r); err != nil {
|
||
writeAppRevalidateError(w, err)
|
||
return
|
||
}
|
||
buf.flushTo(w)
|
||
})
|
||
return s.requireAppAccess(rt.Access, s.requireAppWorkspace(inner))
|
||
}
|
||
r.Route("/workspaces/{ws}", func(r chi.Router) {
|
||
for _, rt := range appRoutes {
|
||
r.Method(rt.Method, rt.Template, build(rt))
|
||
}
|
||
})
|
||
for _, rt := range appUnscopedRoutes {
|
||
r.Method(rt.Method, rt.Template, s.appTokenWorkspace(build(rt)))
|
||
}
|
||
})
|
||
}
|
||
|
||
func appJSONContentType(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// requireAppsAvailable answers every app route with the same JSON 404 while
|
||
// installed apps are off on this server.
|
||
func (s *Server) requireAppsAvailable(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
on, err := s.appsAvailableChecked()
|
||
if err != nil {
|
||
// A failure to read the setting is a fault, not "apps off"
|
||
// (codex U6c r6).
|
||
writeInternalError(w, err)
|
||
return
|
||
}
|
||
if !on {
|
||
writeError(w, http.StatusNotFound, "not_found", "Not found")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func writeAppUnauthorized(w http.ResponseWriter) {
|
||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "A valid app token is required")
|
||
}
|
||
|
||
// appBearer reads the token from the Authorization header only: the app API
|
||
// takes no token from a query string or a form.
|
||
func appBearer(r *http.Request) string {
|
||
auth := r.Header.Get("Authorization")
|
||
if len(auth) < 8 || !strings.EqualFold(auth[:7], "bearer ") {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(auth[7:])
|
||
}
|
||
|
||
// appTokenAuth is step 1. It establishes the grant and the actor, and stores
|
||
// a partial app context; the workspace step completes it.
|
||
func (s *Server) appTokenAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ac, err := s.appAdmitToken(r.Context(), appBearer(r))
|
||
if err != nil {
|
||
if errors.Is(err, errAppAdmitInternal) {
|
||
writeInternalError(w, err)
|
||
return
|
||
}
|
||
writeAppUnauthorized(w)
|
||
return
|
||
}
|
||
ctx := context.WithValue(r.Context(), appCtxKey{}, ac)
|
||
ctx = WithCurrentUser(ctx, ac.Actor)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
var (
|
||
errAppAdmit = errors.New("app request not admitted")
|
||
errAppAdmitInternal = errors.New("app admission failed")
|
||
)
|
||
|
||
// appAdmitToken is the token half of admission, shared by the door and the
|
||
// re-admission before a response is sent: the token is introspected (active,
|
||
// not expired, audience, binding, install, client, epoch); a delegated grant
|
||
// goes to appAdmitDelegated; for a service grant the install's service access
|
||
// is read, and the install's own bot must be the subject, live and enabled.
|
||
func (s *Server) appAdmitToken(ctx context.Context, tok string) (*appContext, error) {
|
||
if tok == "" {
|
||
return nil, errAppAdmit
|
||
}
|
||
grant, err := s.introspectAppToken(ctx, tok)
|
||
if err != nil {
|
||
// A store failure while reading the token's state is a fault, not a
|
||
// refusal (codex U6c r4): a 401 invalid_token would tell the app to
|
||
// discard a credential that is fine. Every named refusal stays a
|
||
// denial, and so does the OAuth library's own introspection error,
|
||
// which does not distinguish a storage fault from an unknown token
|
||
// and therefore fails closed.
|
||
if !isAppTokenDenial(err) {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
return nil, errAppAdmit
|
||
}
|
||
inst, err := s.store.GetInstallAPIState(grant.InstallID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if inst == nil || inst.State != "active" || inst.WorkspaceID != grant.WorkspaceID {
|
||
return nil, errAppAdmit
|
||
}
|
||
switch grant.AuthKind {
|
||
case "service":
|
||
case "delegated":
|
||
return s.appAdmitDelegated(grant, inst, tok)
|
||
default:
|
||
return nil, errAppAdmit
|
||
}
|
||
if inst.ServiceAccess != "read" && inst.ServiceAccess != "write" {
|
||
return nil, errAppAdmit // no service access granted
|
||
}
|
||
bot, err := s.store.AppPrincipalForInstall(grant.InstallID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if bot == nil || bot.IsDisabled() || bot.ID != grant.Subject || (inst.BotUserID != "" && inst.BotUserID != bot.ID) {
|
||
return nil, errAppAdmit
|
||
}
|
||
return &appContext{Grant: grant, InstallID: grant.InstallID, WorkspaceID: grant.WorkspaceID,
|
||
Access: inst.ServiceAccess, AuthKind: grant.AuthKind, Actor: bot, token: tok}, nil
|
||
}
|
||
|
||
// appAdmitDelegated admits a delegated token (TASK-3399, U5b-2): an app
|
||
// acting as a PERSON, read fresh on every request (§8 "removing a delegated
|
||
// user's membership stops their token at the next request"):
|
||
// - the subject is the binding's person: a live HUMAN account whose
|
||
// credential epoch is the one the grant was issued under (a disable or
|
||
// claim since bumped it);
|
||
// - their membership of the install's workspace is the one the grant was
|
||
// issued under (its created_at; a removal ended the grant, and a re-add
|
||
// does not revive it), in a live workspace;
|
||
// - the access is the LESSER of what the person consented to and what the
|
||
// manifest offers now (§4 step 4): an upgrade that narrowed delegated
|
||
// access narrows every live grant at once.
|
||
//
|
||
// The role and the ceiling come from appAdmitWorkspace, as for the bot: the
|
||
// person's membership role (owner capped to editor, read access to viewer)
|
||
// and the install's companion ∪ system collections, intersected with what
|
||
// the person may see at every resolver (U6a's request ceiling).
|
||
func (s *Server) appAdmitDelegated(grant *AppTokenGrant, inst *store.InstallAPIState, tok string) (*appContext, error) {
|
||
// Second layer: the issuance barrier already binds every persistence to
|
||
// the person its code named (U5b-2 mutant D6 survives for that reason).
|
||
if grant.DelegatedUserID == "" || grant.Subject != grant.DelegatedUserID {
|
||
return nil, errAppAdmit
|
||
}
|
||
person, err := s.store.GetUser(grant.Subject)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if person == nil || person.IsDisabled() || person.IsApp() || person.CredentialEpoch != grant.DelegatedCredentialEpoch {
|
||
return nil, errAppAdmit
|
||
}
|
||
since, err := s.store.WorkspaceMemberSince(grant.WorkspaceID, person.ID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if since == "" || since != grant.DelegatedMemberSince {
|
||
return nil, errAppAdmit
|
||
}
|
||
access := lesserAppAccess(grant.DelegatedAccess, inst.DelegatedAccess)
|
||
if access == "" {
|
||
return nil, errAppAdmit
|
||
}
|
||
return &appContext{Grant: grant, InstallID: grant.InstallID, WorkspaceID: grant.WorkspaceID,
|
||
Access: access, AuthKind: grant.AuthKind, Actor: person, token: tok}, nil
|
||
}
|
||
|
||
// lesserAppAccess is the lesser of two accesses ("read" < "write"); "" when
|
||
// either grants none.
|
||
func lesserAppAccess(a, b string) string {
|
||
valid := func(x string) bool { return x == "read" || x == "write" }
|
||
if !valid(a) || !valid(b) {
|
||
return ""
|
||
}
|
||
if a == "read" || b == "read" {
|
||
return "read"
|
||
}
|
||
return "write"
|
||
}
|
||
|
||
// requireAppAccess is step 2: a write row refuses a read token, before any
|
||
// resolution or lookup.
|
||
func (s *Server) requireAppAccess(need string, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ac := appContextFrom(r)
|
||
if ac == nil {
|
||
writeAppUnauthorized(w)
|
||
return
|
||
}
|
||
if need == "write" && ac.Access != "write" {
|
||
writeError(w, http.StatusForbidden, "insufficient_access", "This app's access is read-only")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// appTokenWorkspace sets {ws} to the token's own workspace, for a route
|
||
// mounted outside /workspaces/{ws}, so requireAppWorkspace admits it the
|
||
// same way.
|
||
func (s *Server) appTokenWorkspace(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ac := appContextFrom(r)
|
||
if ac == nil {
|
||
writeAppUnauthorized(w)
|
||
return
|
||
}
|
||
rctx := chi.RouteContext(r.Context())
|
||
if rctx == nil {
|
||
rctx = chi.NewRouteContext()
|
||
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||
}
|
||
rctx.URLParams.Add("ws", ac.WorkspaceID)
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// requireAppWorkspace is step 3 (RequireAppWorkspaceAccess). {ws} must be the
|
||
// token's workspace, by ID (lead ruling R3): a slug, another workspace, or a
|
||
// deleted one all get the workspace 404. It then sets every context key the
|
||
// shared helpers read, from the grant and real membership, and refuses the
|
||
// request if any is missing.
|
||
func (s *Server) requireAppWorkspace(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ac := appContextFrom(r)
|
||
if ac == nil {
|
||
writeAppUnauthorized(w)
|
||
return
|
||
}
|
||
if chi.URLParam(r, "ws") != ac.WorkspaceID {
|
||
writeWorkspaceNotFound(w, "Workspace not found")
|
||
return
|
||
}
|
||
full, err := s.appAdmitWorkspace(ac)
|
||
if err != nil {
|
||
if errors.Is(err, errAppAdmitInternal) {
|
||
writeInternalError(w, err)
|
||
return
|
||
}
|
||
writeWorkspaceNotFound(w, "Workspace not found")
|
||
return
|
||
}
|
||
r = r.WithContext(appRequestContext(r.Context(), full))
|
||
if err := appContextSelfCheck(r); err != nil {
|
||
slog.Error("app API: context self-check failed", "error", err)
|
||
writeInternalError(w, err)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// appAdmitWorkspace is the workspace half of admission, shared by the door
|
||
// and the re-admission: the workspace is live, the bot is a member, and the
|
||
// role, read ceiling and companion set are computed from the store as they
|
||
// stand now.
|
||
func (s *Server) appAdmitWorkspace(ac *appContext) (*appContext, error) {
|
||
ws, err := s.store.GetWorkspaceByID(ac.WorkspaceID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if ws == nil {
|
||
return nil, errAppAdmit
|
||
}
|
||
member, err := s.store.GetWorkspaceMember(ac.WorkspaceID, ac.Actor.ID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
if member == nil {
|
||
return nil, errAppAdmit
|
||
}
|
||
ceiling, err := s.store.InstallReadCeilingQ(s.store.DB(), ac.InstallID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
companions, err := s.store.InstallCompanionCollectionIDsQ(s.store.DB(), ac.InstallID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %w", errAppAdmitInternal, err)
|
||
}
|
||
// The role the shared helpers see: the membership's, capped by the
|
||
// token's access. A read token never acts as more than a viewer.
|
||
role := member.Role
|
||
if role == "owner" {
|
||
role = "editor" // a bot is never an owner (TASK-3392); defence in depth
|
||
}
|
||
if ac.Access == "read" {
|
||
role = "viewer"
|
||
}
|
||
full := *ac
|
||
full.WorkspaceSlug = ws.Slug
|
||
full.ReadCeiling = ceiling
|
||
full.Companions = companions
|
||
full.Role = role
|
||
return &full, nil
|
||
}
|
||
|
||
// appRequestContext sets every key the shared helpers read from a complete
|
||
// app context.
|
||
func appRequestContext(parent context.Context, ac *appContext) context.Context {
|
||
ctx := context.WithValue(parent, appCtxKey{}, ac)
|
||
ctx = WithCurrentUser(ctx, ac.Actor)
|
||
ctx = context.WithValue(ctx, ctxResolvedWorkspaceID, ac.WorkspaceID)
|
||
ctx = context.WithValue(ctx, ctxWorkspaceRole, ac.Role)
|
||
ctx = WithAPITokenAuth(ctx)
|
||
ctx = WithTokenScopes(ctx, ac.Access)
|
||
return WithTokenAllowedWorkspaces(ctx, []string{ac.WorkspaceSlug})
|
||
}
|
||
|
||
// appContextSelfCheck refuses a request whose context lacks any key the
|
||
// shared helpers read (§4 step 5).
|
||
func appContextSelfCheck(r *http.Request) error {
|
||
ac := appContextFrom(r)
|
||
switch {
|
||
case ac == nil || ac.Grant == nil || ac.Actor == nil || ac.InstallID == "" || ac.WorkspaceSlug == "":
|
||
return errors.New("app context incomplete")
|
||
case ac.ReadCeiling == nil || ac.Companions == nil:
|
||
return errors.New("app ceiling not set")
|
||
case currentUser(r) == nil || currentUser(r).ID != ac.Actor.ID:
|
||
return errors.New("current user is not the app actor")
|
||
case workspaceRole(r) == "":
|
||
return errors.New("workspace role not set")
|
||
case r.Context().Value(ctxResolvedWorkspaceID) != ac.WorkspaceID:
|
||
return errors.New("resolved workspace is not the token's")
|
||
case !isAPITokenAuth(r):
|
||
return errors.New("bearer status not set")
|
||
case TokenScopesFromContext(r.Context()) == "":
|
||
return errors.New("scopes not set")
|
||
}
|
||
allowed := TokenAllowedWorkspacesFromContext(r.Context())
|
||
if len(allowed) != 1 || allowed[0] != ac.WorkspaceSlug {
|
||
return errors.New("token allow-list is not exactly the install workspace")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// appCeilingAllows reports whether a collection is inside the request's app
|
||
// read ceiling. Outside the app API (no app context) everything passes.
|
||
func appCeilingAllows(r *http.Request, collectionID string) bool {
|
||
ac := appContextFrom(r)
|
||
if ac == nil {
|
||
return true
|
||
}
|
||
for _, id := range ac.ReadCeiling {
|
||
if id == collectionID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// appResponseBuffer holds a handler's response until the grant is
|
||
// re-validated (codex r1 P1).
|
||
type appResponseBuffer struct {
|
||
header http.Header
|
||
status int
|
||
body []byte
|
||
}
|
||
|
||
func newAppResponseBuffer() *appResponseBuffer { return &appResponseBuffer{header: http.Header{}} }
|
||
|
||
func (b *appResponseBuffer) Header() http.Header { return b.header }
|
||
|
||
func (b *appResponseBuffer) WriteHeader(code int) {
|
||
if b.status == 0 {
|
||
b.status = code
|
||
}
|
||
}
|
||
|
||
func (b *appResponseBuffer) Write(p []byte) (int, error) {
|
||
if b.status == 0 {
|
||
b.status = http.StatusOK
|
||
}
|
||
b.body = append(b.body, p...)
|
||
return len(p), nil
|
||
}
|
||
|
||
func (b *appResponseBuffer) flushTo(w http.ResponseWriter) {
|
||
for k, v := range b.header {
|
||
w.Header()[k] = v
|
||
}
|
||
if b.status == 0 {
|
||
b.status = http.StatusOK
|
||
}
|
||
w.WriteHeader(b.status)
|
||
_, _ = w.Write(b.body)
|
||
}
|
||
|
||
// appRecheckKey holds the request's re-checks: one per resource a handler
|
||
// authorized, replayed before the response is sent.
|
||
type appRecheckKey struct{}
|
||
|
||
type appRechecks struct{ fns []func(*http.Request) error }
|
||
|
||
// appAddRecheck registers a re-check of an authorization the handler made.
|
||
// It is run against the store as it stands when the response is about to be
|
||
// sent, under the re-admitted context.
|
||
func appAddRecheck(r *http.Request, fn func(*http.Request) error) {
|
||
if rc, ok := r.Context().Value(appRecheckKey{}).(*appRechecks); ok {
|
||
rc.fns = append(rc.fns, fn)
|
||
}
|
||
}
|
||
|
||
// appRevalidate re-admits the request from scratch before its response is
|
||
// sent (codex r1 and r2): the token is introspected again (revocation,
|
||
// expiry, binding, epoch, install, client), the install's access and bot are
|
||
// read again, and the workspace, membership, role and ceiling are recomputed.
|
||
// The result must match what the request was admitted under, and then every
|
||
// authorization the handler made is replayed under the re-admitted context.
|
||
// A change to anything that authorized the response, committed while the
|
||
// request was in flight, therefore withholds it: every response is ordered
|
||
// against revocation at this check. App mutations are ordered by their
|
||
// FencedTx; this orders the reads.
|
||
func (s *Server) appRevalidate(r *http.Request) error {
|
||
ac := appContextFrom(r)
|
||
if ac == nil || ac.Grant == nil {
|
||
return errors.New("no app context")
|
||
}
|
||
// A route without the re-check holder cannot have registered its
|
||
// authorizations, so it fails closed rather than passing with none.
|
||
rc, ok := r.Context().Value(appRecheckKey{}).(*appRechecks)
|
||
if !ok {
|
||
return errors.New("no re-check holder")
|
||
}
|
||
// The gate every new request passes (codex r3): apps turned off while a
|
||
// read was in flight withholds it too.
|
||
if on, err := s.appsAvailableChecked(); err != nil {
|
||
return appFault(err)
|
||
} else if !on {
|
||
return errors.New("apps are not available")
|
||
}
|
||
tokAC, err := s.appAdmitToken(r.Context(), ac.token)
|
||
if err != nil {
|
||
return appCredentialDenial(err)
|
||
}
|
||
if tokAC.Grant.RequestID != ac.Grant.RequestID || tokAC.Grant.AuthEpoch != ac.Grant.AuthEpoch ||
|
||
tokAC.InstallID != ac.InstallID || tokAC.WorkspaceID != ac.WorkspaceID ||
|
||
tokAC.Access != ac.Access || tokAC.Actor.ID != ac.Actor.ID {
|
||
return fmt.Errorf("%w: the grant changed", errAppCredential)
|
||
}
|
||
fresh, err := s.appAdmitWorkspace(tokAC)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if fresh.Role != ac.Role || fresh.WorkspaceSlug != ac.WorkspaceSlug {
|
||
return errors.New("the membership changed")
|
||
}
|
||
r2 := r.WithContext(context.WithValue(appRequestContext(r.Context(), fresh), appRecheckMemoKey{}, &appRecheckMemo{collections: map[string]error{}}))
|
||
for _, fn := range rc.fns {
|
||
if err := fn(r2); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if s.appAfterRechecks != nil {
|
||
s.appAfterRechecks()
|
||
}
|
||
// The credential once more, LAST (codex U6c r1 P1): the checks above take
|
||
// time, and a revocation, disable or rotation committed while they ran
|
||
// would otherwise pass. Re-admission is a sequence of reads, not one
|
||
// snapshot, so it orders the response against a change committed before
|
||
// its final step: this one, for the credential and the install, and each
|
||
// re-check's own read for the resource it covers.
|
||
last, err := s.appAdmitToken(r.Context(), ac.token)
|
||
if err != nil {
|
||
return appCredentialDenial(err)
|
||
}
|
||
if last.Grant.AuthEpoch != ac.Grant.AuthEpoch || last.Access != ac.Access || last.InstallID != ac.InstallID {
|
||
return fmt.Errorf("%w: the grant changed", errAppCredential)
|
||
}
|
||
// And the workspace and role once more (codex U5b-2 r3): a person's role
|
||
// is not part of the credential, so a downgrade or a workspace delete
|
||
// committed while the re-checks ran would otherwise pass this step.
|
||
lastWS, err := s.appAdmitWorkspace(last)
|
||
if err != nil {
|
||
if errors.Is(err, errAppAdmitInternal) {
|
||
return err
|
||
}
|
||
return fmt.Errorf("the workspace or membership changed: %w", err)
|
||
}
|
||
if lastWS.Role != ac.Role || lastWS.WorkspaceSlug != ac.WorkspaceSlug {
|
||
return errors.New("the membership changed")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// errAppCredential marks a re-admission failure that is about the
|
||
// credential itself (revoked, expired, rotated, the install disabled), as
|
||
// opposed to the subject's permissions. A read answers every denial with
|
||
// 401; a write that must tell the two apart (the upload's pre-row check,
|
||
// codex U6c r5) answers this one with 401 and the rest with 403.
|
||
var errAppCredential = errors.New("app credential no longer valid")
|
||
|
||
// appCredentialDenial passes a fault through and marks anything else from
|
||
// token admission as a credential denial.
|
||
func appCredentialDenial(err error) error {
|
||
if errors.Is(err, errAppAdmitInternal) {
|
||
return err
|
||
}
|
||
return fmt.Errorf("%w: %w", errAppCredential, err)
|
||
}
|
||
|
||
// isAppTokenDenial reports whether an introspection error is one of its named
|
||
// refusals, rather than a failure reading the token's state.
|
||
func isAppTokenDenial(err error) bool {
|
||
for _, d := range []error{errAppTokenInactive, errAppTokenAudience, errAppTokenUnbound, errAppTokenMismatch,
|
||
errAppTokenInstall, errAppTokenEpoch, errAppTokenDisabled, errAppTokenNoBackend} {
|
||
if errors.Is(err, d) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// appFault marks an error from re-admission as a server fault (a store or
|
||
// database failure), not a denial: the response is withheld either way, but
|
||
// a fault answers 500, never the 401 invalid_token that tells an app to
|
||
// discard a credential that is fine (codex U6c r2).
|
||
func appFault(err error) error { return fmt.Errorf("%w: %w", errAppAdmitInternal, err) }
|
||
|
||
// writeAppRevalidateError answers a failed re-admission: 500 for a fault,
|
||
// 401 for a denial.
|
||
func writeAppRevalidateError(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, errAppAdmitInternal) {
|
||
writeInternalError(w, err)
|
||
return
|
||
}
|
||
writeAppUnauthorized(w)
|
||
}
|
||
|
||
// appRecheckMemoKey holds one re-validation's memo of collection re-checks:
|
||
// a list replays the same collection for every item, so each collection is
|
||
// re-checked once per re-validation. The re-checks run sequentially, not as an
|
||
// atomic snapshot; the memo only stops repeating one collection's check. It is
|
||
// fresh per re-validation and not shared between goroutines.
|
||
type appRecheckMemoKey struct{}
|
||
|
||
type appRecheckMemo struct{ collections map[string]error }
|
||
|
||
// appWriteAllows reports whether a collection is one the app may write: a
|
||
// companion. Outside the app API everything passes.
|
||
func appWriteAllows(r *http.Request, collectionID string) bool {
|
||
ac := appContextFrom(r)
|
||
if ac == nil {
|
||
return true
|
||
}
|
||
for _, id := range ac.Companions {
|
||
if id == collectionID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// appBlobReclaimer gives app uploads the human doors' refused-blob reclaim
|
||
// (BUG-3652), on the filesystem backend app uploads write through.
|
||
type appBlobReclaimer struct {
|
||
s *Server
|
||
backend attachments.AttachmentStore
|
||
}
|
||
|
||
func (r appBlobReclaimer) ReclaimRefusedBlob(ctx context.Context, hash, storageKey string) {
|
||
r.s.reclaimRefusedBlob(ctx, r.backend, hash, storageKey)
|
||
}
|