mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
a1c36fca04
A widened delegated-access row told the owner the change was recorded and granted nothing until delegated access is enabled (TASK-3399), but U5b-2 opened delegated sign-in. appAdmitDelegated grants the lesser of what the person consented to and what the manifest offers now, and a consent is capped at the level offered when it was given, so the row now says that existing sign-ins keep their consented access and people who sign in after approval can consent to the new level. Stale 'refused until TASK-3399' comments in app_api.go and install_clients.go are corrected. Tests pin the detail string and the behaviour behind it (a read consent stays read after the widening; a write consent given after it writes). Mutants: the old string, and admission granting the manifest's level, each go red. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
767 lines
29 KiB
Go
767 lines
29 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)
|
||
}
|
||
s.appstoreState.store = appstore.New(s.store, appstore.Options{PlanLimit: s.cloudMode, ETagKey: key,
|
||
Blobs: blobs, InFlight: s.UploadInFlight()})
|
||
})
|
||
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
|
||
}
|