diff --git a/internal/server/app_api.go b/internal/server/app_api.go new file mode 100644 index 000000000..b49a10f3c --- /dev/null +++ b/internal/server/app_api.go @@ -0,0 +1,508 @@ +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/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; delegated tokens refused until 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 string // "read" or "write" +} + +// appRoutes is U6a's table: the reads. U6b adds the writes, U6c the +// attachments. +var appRoutes = []appRoute{ + {"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" (delegated arrives with 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 + } + s.appstoreState.store = appstore.New(s.store, appstore.Options{PlanLimit: s.cloudMode, ETagKey: key}) + }) + return s.appstoreState.store, s.appstoreState.err +} + +// 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) + r.Route("/workspaces/{ws}", func(r chi.Router) { + for _, rt := range appRoutes { + handler := rt.Handler + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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 { + writeAppUnauthorized(w) + return + } + buf.flushTo(w) + }) + h := s.requireAppAccess(rt.Access, s.requireAppWorkspace(inner)) + r.Method(rt.Method, rt.Template, h) + } + }) + }) +} + +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) { + if !s.appsAvailable() { + 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), delegated grants +// are refused until TASK-3399, 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 { + return nil, errAppAdmit + } + // Delegated tokens are refused until TASK-3399 enables them (lead + // ruling R4): U5b must turn them on, never inherit them. + if grant.AuthKind != "service" { + 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 + } + 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 +} + +// 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) + }) +} + +// 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 !s.appsAvailable() { + return errors.New("apps are not available") + } + tokAC, err := s.appAdmitToken(r.Context(), ac.token) + if err != nil { + return 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 errors.New("the grant changed") + } + 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 + } + } + return nil +} + +// 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 } diff --git a/internal/server/app_api_read.go b/internal/server/app_api_read.go new file mode 100644 index 000000000..7dbfad886 --- /dev/null +++ b/internal/server/app_api_read.go @@ -0,0 +1,457 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// The app API's read handlers and response DTOs (SPEC-6 §4, U6a). Each +// handler authorizes through the shared helpers under the ceiling and encodes +// a FIXED DTO: anything a DTO does not name is never sent, whatever the store +// row carries. The DTO census test pins every key, nested ones included. + +// AppSchemaField is one field of AppCollection's schema projection: declared +// non-relation fields only. Never a target collection; a default only for a +// scalar, non-reference type. +type AppSchemaField struct { + Key string `json:"key"` + Type string `json:"type"` + Label string `json:"label"` + Options []string `json:"options,omitempty"` + Required bool `json:"required"` + Default any `json:"default,omitempty"` +} + +// AppCollectionSchema is the projection of a collection's schema. +type AppCollectionSchema struct { + Fields []AppSchemaField `json:"fields"` +} + +// AppCollection never carries settings, traits, counts, relation fields or +// any target collection slug. +type AppCollection struct { + Slug string `json:"slug"` + Name string `json:"name"` + Icon string `json:"icon"` + IsSystem bool `json:"is_system"` + Schema AppCollectionSchema `json:"schema"` +} + +// AppItem never carries slug, ref, seq, parent, lease, decisions, moved_to, +// relation_targets, assignee, role, github_pr, implementation notes or the +// decision log. +type AppItem struct { + ID string `json:"id"` + Collection string `json:"collection"` + Title string `json:"title"` + Content string `json:"content"` + Fields map[string]any `json:"fields"` + ETag string `json:"etag"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByDisplay string `json:"created_by_display"` + ViaApp string `json:"via_app"` +} + +// AppComment never carries reactions or user_id. +type AppComment struct { + ID string `json:"id"` + ItemID string `json:"item_id"` + Body string `json:"body"` + AuthorDisplay string `json:"author_display"` + AuthorKind string `json:"author_kind"` + ParentCommentID *string `json:"parent_comment_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Edited bool `json:"edited"` + Deleted bool `json:"deleted"` +} + +// AppMe never carries the email, platform role, TOTP or billing. +type AppMe struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + IsApp bool `json:"is_app"` +} + +// scalarFieldTypes are the types whose default an app may see. +var scalarFieldTypes = map[string]bool{"text": true, "url": true, "number": true, "checkbox": true, "date": true, "select": true, "multi_select": true} + +func appSchemaProjection(c *models.Collection) AppCollectionSchema { + out := AppCollectionSchema{Fields: []AppSchemaField{}} + var schema models.CollectionSchema + if c.Schema == "" || models.UnmarshalItemFieldSchema([]byte(c.Schema), &schema) != nil { + return out + } + for _, f := range schema.Fields { + if f.Type == "relation" || f.IsMultiRelation() { + continue + } + af := AppSchemaField{Key: f.Key, Type: f.Type, Label: f.Label, Options: f.Options, Required: f.Required} + if scalarFieldTypes[f.Type] { + af.Default = f.Default + } + out.Fields = append(out.Fields, af) + } + return out +} + +// appFieldProjection keeps only the item's values for the collection's +// declared non-relation keys. +func appFieldProjection(fieldsJSON string, c *models.Collection) map[string]any { + out := map[string]any{} + allowed := map[string]bool{} + for _, f := range appSchemaProjection(c).Fields { + allowed[f.Key] = true + } + dec := json.NewDecoder(bytes.NewReader([]byte(fieldsJSON))) + dec.UseNumber() + var raw map[string]any + if fieldsJSON == "" || dec.Decode(&raw) != nil { + return out + } + for k, v := range raw { + if allowed[k] { + out[k] = v + } + } + return out +} + +func writeAppJSON(w http.ResponseWriter, status int, v any) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeAppNotFound(w http.ResponseWriter, what string) { + writeError(w, http.StatusNotFound, "not_found", what+" not found") +} + +// appVisibleCollection resolves a collection by EXACT slug in the token's +// workspace, inside the ceiling and the actor's visibility, or reports false +// after answering the same 404 a missing collection gets. +func (s *Server) appVisibleCollection(w http.ResponseWriter, r *http.Request, slug string) (*models.Collection, bool) { + ac := appContextFrom(r) + c, err := s.store.GetCollectionBySlug(ac.WorkspaceID, slug) + if err != nil { + writeInternalError(w, err) + return nil, false + } + if c == nil || c.DeletedAt != nil || c.Slug != slug { + writeAppNotFound(w, "Collection") + return nil, false + } + ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID) + if err != nil { + writeInternalError(w, err) + return nil, false + } + if !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) { + writeAppNotFound(w, "Collection") + return nil, false + } + id := c.ID + appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckCollection(r2, id) }) + return c, true +} + +var errAppRecheck = errors.New("app authorization no longer holds") + +// appRecheckCollection replays a collection's authorization against the +// store as it stands: live, in the workspace, visible to the actor, inside +// the ceiling. +func (s *Server) appRecheckCollection(r *http.Request, collectionID string) error { + if memo, ok := r.Context().Value(appRecheckMemoKey{}).(*appRecheckMemo); ok { + if err, seen := memo.collections[collectionID]; seen { + return err + } + err := s.appRecheckCollectionUncached(r, collectionID) + memo.collections[collectionID] = err + return err + } + return s.appRecheckCollectionUncached(r, collectionID) +} + +func (s *Server) appRecheckCollectionUncached(r *http.Request, collectionID string) error { + ac := appContextFrom(r) + c, err := s.store.GetCollection(collectionID) + if err != nil { + return err + } + if c == nil || c.DeletedAt != nil || c.WorkspaceID != ac.WorkspaceID { + return errAppRecheck + } + ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID) + if err != nil { + return err + } + if !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) { + return errAppRecheck + } + return nil +} + +// appRecheckItem replays an item's authorization: live, in the workspace and +// collection it was read from, inside the ceiling, visible to the actor. +func (s *Server) appRecheckItem(r *http.Request, itemID, collectionID string) error { + ac := appContextFrom(r) + it, err := s.store.GetItem(itemID) + if err != nil { + return err + } + if it == nil || it.DeletedAt != nil || it.WorkspaceID != ac.WorkspaceID || it.CollectionID != collectionID { + return errAppRecheck + } + if !appCeilingAllows(r, it.CollectionID) { + return errAppRecheck + } + ok, err := s.checkItemVisible(ac.WorkspaceID, it, currentUser(r), workspaceRole(r), isBearerAuth(r)) + if err != nil { + return err + } + if !ok { + return errAppRecheck + } + return s.appRecheckCollection(r, collectionID) +} + +func (s *Server) appCollectionDTO(c *models.Collection) AppCollection { + return AppCollection{Slug: c.Slug, Name: c.Name, Icon: c.Icon, IsSystem: c.IsSystem, Schema: appSchemaProjection(c)} +} + +func (s *Server) appListCollections(w http.ResponseWriter, r *http.Request) { + ac := appContextFrom(r) + all, err := s.store.ListCollections(ac.WorkspaceID) + if err != nil { + writeInternalError(w, err) + return + } + ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID) + if err != nil { + writeInternalError(w, err) + return + } + out := []AppCollection{} + for i := range all { + c := &all[i] + if c.DeletedAt != nil || !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) { + continue + } + out = append(out, s.appCollectionDTO(c)) + id := c.ID + appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckCollection(r2, id) }) + } + writeAppJSON(w, http.StatusOK, map[string]any{"collections": out}) +} + +func (s *Server) appGetCollection(w http.ResponseWriter, r *http.Request) { + c, ok := s.appVisibleCollection(w, r, chi.URLParam(r, "collSlug")) + if !ok { + return + } + writeAppJSON(w, http.StatusOK, s.appCollectionDTO(c)) +} + +// appItemDTOs builds the item DTOs, etag and app metadata included. +func (s *Server) appItemDTOs(r *http.Request, items []models.Item, c *models.Collection) ([]AppItem, error) { + ac := appContextFrom(r) + as, err := s.appStore() + if err != nil { + return nil, err + } + ids := make([]string, len(items)) + for i := range items { + ids[i] = items[i].ID + } + meta, err := s.store.ItemsAppReadMeta(ids) + if err != nil { + return nil, err + } + spec := ac.appFenceSpec() + out := make([]AppItem, 0, len(items)) + for i := range items { + it := &items[i] + etag, err := as.ETag(spec, it) + if err != nil { + return nil, err + } + m := meta[it.ID] + out = append(out, AppItem{ + ID: it.ID, Collection: c.Slug, Title: it.Title, Content: it.Content, + Fields: appFieldProjection(it.Fields, c), ETag: etag, + CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt, + CreatedByDisplay: m.CreatorDisplay, ViaApp: m.ViaApp, + }) + } + return out, nil +} + +// appListLimit bounds a list: default 50, at most 200. +func appListLimit(r *http.Request) (int, int, bool) { + limit, offset := 50, 0 + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 200 { + return 0, 0, false + } + limit = n + } + if v := r.URL.Query().Get("offset"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return 0, 0, false + } + offset = n + } + return limit, offset, true +} + +func (s *Server) appListItems(w http.ResponseWriter, r *http.Request) { + c, ok := s.appVisibleCollection(w, r, chi.URLParam(r, "collSlug")) + if !ok { + return + } + limit, offset, ok := appListLimit(r) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be 1-200 and offset >= 0") + return + } + ac := appContextFrom(r) + // One row past the window decides has_more exactly. Item visibility + // filters AFTER the window, so a page can come back short; paging must + // therefore follow the window (next_offset), never the page's length + // (Rook's review of #1771). + items, err := s.store.ListItems(ac.WorkspaceID, models.ItemListParams{ + ScopeCollectionID: c.ID, CollectionIDs: []string{c.ID}, Limit: limit + 1, Offset: offset, + }) + if err != nil { + writeInternalError(w, err) + return + } + hasMore := len(items) > limit + if hasMore { + items = items[:limit] + } + // Item-level visibility, as every human list applies it. + visible := items[:0] + for i := range items { + ok, err := s.checkItemVisible(ac.WorkspaceID, &items[i], currentUser(r), workspaceRole(r), isBearerAuth(r)) + if err != nil { + writeInternalError(w, err) + return + } + if ok && items[i].CollectionID == c.ID { + visible = append(visible, items[i]) + id, coll := items[i].ID, c.ID + appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckItem(r2, id, coll) }) + } + } + dtos, err := s.appItemDTOs(r, visible, c) + if err != nil { + writeInternalError(w, err) + return + } + writeAppJSON(w, http.StatusOK, map[string]any{"items": dtos, "has_more": hasMore, "next_offset": offset + limit}) +} + +// appVisibleItem resolves an item by ID in the token's workspace, inside the +// ceiling and the actor's visibility, or answers the same 404 a missing item +// gets. Apps never address items by slug or ref. +func (s *Server) appVisibleItem(w http.ResponseWriter, r *http.Request) (*models.Item, *models.Collection, bool) { + ac := appContextFrom(r) + it, err := s.store.GetItem(chi.URLParam(r, "itemID")) + if err != nil { + writeInternalError(w, err) + return nil, nil, false + } + if it == nil || it.WorkspaceID != ac.WorkspaceID || it.DeletedAt != nil { + writeAppNotFound(w, "Item") + return nil, nil, false + } + if !s.requireItemVisible(w, r, ac.WorkspaceID, it) { + return nil, nil, false + } + c, err := s.store.GetCollection(it.CollectionID) + if err != nil { + writeInternalError(w, err) + return nil, nil, false + } + if c == nil || c.DeletedAt != nil { + writeAppNotFound(w, "Item") + return nil, nil, false + } + id, coll := it.ID, it.CollectionID + appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckItem(r2, id, coll) }) + return it, c, true +} + +func (s *Server) appGetItem(w http.ResponseWriter, r *http.Request) { + it, c, ok := s.appVisibleItem(w, r) + if !ok { + return + } + dtos, err := s.appItemDTOs(r, []models.Item{*it}, c) + if err != nil { + writeInternalError(w, err) + return + } + writeAppJSON(w, http.StatusOK, dtos[0]) +} + +func (s *Server) appListComments(w http.ResponseWriter, r *http.Request) { + it, _, ok := s.appVisibleItem(w, r) + if !ok { + return + } + comments, err := s.store.ListComments(it.ID) + if err != nil { + writeInternalError(w, err) + return + } + userIDs := []string{} + onItem := map[string]bool{} + for _, c := range comments { + onItem[c.ID] = true + if c.UserID != "" { + userIDs = append(userIDs, c.UserID) + } + } + kinds, err := s.store.UserKinds(userIDs) + if err != nil { + writeInternalError(w, err) + return + } + out := make([]AppComment, 0, len(comments)) + for _, c := range comments { + kind := c.CreatedBy + if kinds[c.UserID] == models.UserKindApp { + kind = "app" + } else if kind != "agent" { + kind = "user" + } + dto := AppComment{ID: c.ID, ItemID: c.ItemID, Body: c.Body, AuthorDisplay: c.Author, AuthorKind: kind, + CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, Edited: c.IsEdited(), Deleted: c.Deleted} + // A parent that is not a comment on this same item projects to null: + // legacy rows can point across items (the FK does not prevent it). + if c.ParentID != "" && onItem[c.ParentID] { + p := c.ParentID + dto.ParentCommentID = &p + } + out = append(out, dto) + } + writeAppJSON(w, http.StatusOK, map[string]any{"comments": out}) +} + +func (s *Server) appMe(w http.ResponseWriter, r *http.Request) { + ac := appContextFrom(r) + writeAppJSON(w, http.StatusOK, AppMe{UserID: ac.Actor.ID, DisplayName: ac.Actor.Name, Role: workspaceRole(r), IsApp: ac.Actor.IsApp()}) +} diff --git a/internal/server/authz_cross_workspace.go b/internal/server/authz_cross_workspace.go index 0d7f180ac..0afc046b6 100644 --- a/internal/server/authz_cross_workspace.go +++ b/internal/server/authz_cross_workspace.go @@ -524,6 +524,12 @@ func (s *Server) authorizeCrossWorkspace(r *http.Request, workspaceSlugOrID stri a.collectionScoped = scope.collectionID != "" return a } + // An installed app never reaches across workspaces (SPEC-6 §4, U6a): no + // app handler calls this, and it refuses outright for the one that + // someday would. + if appContextFrom(r) != nil { + return verdict(CrossWorkspaceAccess{Reason: CrossWorkspaceTokenNotAllowed}) + } return verdict(s.authorizeCrossWorkspaceInner(r, workspaceSlugOrID, scope, needEdit)) } diff --git a/internal/server/item_moved_to.go b/internal/server/item_moved_to.go index 8a8467015..6f450280a 100644 --- a/internal/server/item_moved_to.go +++ b/internal/server/item_moved_to.go @@ -191,6 +191,10 @@ func (s *Server) movedToDestinations(r *http.Request, item *models.Item) []model if item == nil || item.DeletedAt == nil || item.ID == "" { return nil } + // An installed app never learns where an item went (SPEC-6 §4, U6a). + if appContextFrom(r) != nil { + return nil + } itemID := item.ID // Precondition. workspaceRole(r) below describes the workspace the request diff --git a/internal/server/server.go b/internal/server/server.go index 8974b6036..bf1161dc9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -94,6 +94,11 @@ type Server struct { // deterministic. TestVisibleCollectionIDsFaultIsNilInProduction holds New // to leaving it nil. Same synchronisation rule as afterItemPreRead. visibleCollectionIDsFault func() error + // appstoreState holds the one appstore the app API uses (SPEC-6 U6a). + appstoreState appStoreState + // appAfterHandler is a TEST-ONLY seam, nil in production: it runs after + // an app handler and before its response is re-validated and sent. + appAfterHandler func() // userCountFault is a TEST-ONLY seam, nil in production (BUG-3334). When // set, userCount calls it first and returns its error. The fresh-install @@ -1548,6 +1553,9 @@ func (s *Server) setupRouter() { // Mounted on every install and gated per request (PLAN-2310 DR-5) — // see registerMCPRoutes. s.registerMCPRoutes(r) + // The installed-app API (SPEC-6 U6a): its own router, outside TokenAuth + // and RequireAuth, like the MCP mount above. + s.registerAppAPIRoutes(r) s.registerChatGPTMCPRoutes(r) // OAuth 2.1 authorization-server flow endpoints (PLAN-943 @@ -3015,7 +3023,17 @@ func (s *Server) visibleCollectionIDs(r *http.Request, workspaceID string) ([]st if user == nil || (user.Role == "admin" && !isBearerAuth(r)) { return nil, nil // No filtering for admins (cookie session) or unauthenticated } - return s.store.VisibleCollectionIDs(workspaceID, user.ID) + ids, err := s.store.VisibleCollectionIDs(workspaceID, user.ID) + if err != nil { + return nil, err + } + // An app request sees no further than its token's ceiling (SPEC-6 U6a), + // whoever the actor is: the store already ceilings a bot, and a delegated + // person (TASK-3399) is ceilinged here, from the request. + if ac := appContextFrom(r); ac != nil { + return store.IntersectCollectionIDs(ids, ac.ReadCeiling), nil + } + return ids, nil } // requireCollectionFullyVisible checks that the collection is visible to the @@ -3065,6 +3083,13 @@ func (s *Server) requireCollectionFullyVisible(w http.ResponseWriter, r *http.Re // (e.g. handlers_ref_resolver.go) should use checkItemVisible directly with // a manually-derived role. func (s *Server) requireItemVisible(w http.ResponseWriter, r *http.Request, workspaceID string, item *models.Item) bool { + // The app ceiling first (SPEC-6 U6a): checkItemVisible reads the store's + // visibility directly, so a ceiling only in visibleCollectionIDs would + // not reach it. Same 404 as any invisible item. + if item != nil && !appCeilingAllows(r, item.CollectionID) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return false + } visible, err := s.checkItemVisible(workspaceID, item, currentUser(r), workspaceRole(r), isBearerAuth(r)) if err != nil { writeInternalError(w, err) diff --git a/internal/server/task3401_app_api_test.go b/internal/server/task3401_app_api_test.go new file mode 100644 index 000000000..691ebbe22 --- /dev/null +++ b/internal/server/task3401_app_api_test.go @@ -0,0 +1,773 @@ +package server + +import ( + "context" + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TASK-3401 (SPEC-6 U6a): the app API's door, ceiling and read routes. + +const task3401Schema = `{"fields":[ + {"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open"}, + {"key":"size","label":"Size","type":"text"}, + {"key":"owner","label":"Owner","type":"relation","collection":"private"} +]}` + +type appAPIFix struct { + srv *Server + in testInstall + token string + ws *models.Workspace + companion, system, private *models.Collection + item, privateItem *models.Item + comment *models.Comment +} + +func appAPIFixture(t *testing.T, access string) appAPIFix { + t.Helper() + return appAPIFixtureOn(t, access, true) +} + +// appAPIFixtureOn builds the fixture on Pad Cloud, or on a self-host with +// apps enabled (cloud=false). +func appAPIFixtureOn(t *testing.T, access string, cloud bool) appAPIFix { + t.Helper() + srv := appOAuthServer(t, cloud) + if !cloud { + if err := srv.store.SetPlatformSetting(settingAppsEnabled, "true"); err != nil { + t.Fatal(err) + } + } + srv.store.SetEncryptionKey(bytes32ForTest()) + in := newTestInstall(t, srv, "inst-api") + ws, err := srv.store.GetWorkspaceByID(in.wsID) + if err != nil || ws == nil { + t.Fatal(err) + } + f := appAPIFix{srv: srv, in: in, ws: ws} + mk := func(name, slug, schema string) *models.Collection { + c, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{Name: name, Slug: slug, Schema: schema}) + if err != nil { + t.Fatal(err) + } + return c + } + f.companion = mk("Requests", "requests", task3401Schema) + f.system = mk("Conventions App", "conventions-app", `{"fields":[]}`) + f.private = mk("Private", "private", `{"fields":[]}`) + exec := func(q string, args ...any) { + t.Helper() + if _, err := srv.store.DB().Exec(q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + exec(`UPDATE collections SET via_app = ? WHERE id = ?`, in.id, f.companion.ID) + exec(`UPDATE collections SET is_system = 1 WHERE id = ?`, f.system.ID) + exec(`UPDATE app_installs SET bot_user_id = ?, service_access = ? WHERE id = ?`, in.bot.ID, access, in.id) + // The bot is a member with "all" access: the ceiling must not rely on + // U8b narrowing it. + exec(`INSERT INTO workspace_members (workspace_id, user_id, role, collection_access, created_at) VALUES (?, ?, 'editor', 'all', ?)`, + ws.ID, in.bot.ID, time.Now().UTC().Format(time.RFC3339)) + + human := createTestUserDirect(t, srv, "adjunct-3401@example.com") + f.privateItem, err = srv.store.CreateItem(ws.ID, f.private.ID, models.ItemCreate{Title: "Secret", ActorUserID: human.ID}) + if err != nil { + t.Fatal(err) + } + // An item laden with every adjunct a DTO must never carry: a relation + // value, a parent, a lease, a reaction on its comment. + f.item, err = srv.store.CreateItem(ws.ID, f.companion.ID, models.ItemCreate{Title: "Login broken", Content: "It fails.", + Fields: `{"status":"open","size":"L","owner":"` + f.privateItem.ID + `","ghost":"undeclared"}`, ActorUserID: human.ID}) + if err != nil { + t.Fatal(err) + } + exec(`UPDATE items SET via_app = ?, created_via_app = ? WHERE id = ?`, in.id, in.id, f.item.ID) + if _, err := srv.store.CreateItemLink(ws.ID, models.ItemLinkCreate{TargetID: f.privateItem.ID, LinkType: "parent"}, f.item.ID); err != nil { + t.Fatalf("parent link: %v", err) + } + if _, err := srv.store.ClaimItemLease(f.item.ID, "someone", human.ID, time.Hour); err != nil { + t.Fatalf("lease: %v", err) + } + f.comment, err = srv.store.CreateComment(ws.ID, f.item.ID, human.ID, models.CommentCreate{Body: "seen", Author: "Ada", CreatedBy: "user"}) + if err != nil { + t.Fatal(err) + } + if _, err := srv.store.AddReaction(f.comment.ID, human.ID, "user", "👍"); err != nil { + t.Fatalf("reaction: %v", err) + } + f.token = mintServiceToken(t, srv, in) + return f +} + +func createTestUserDirect(t *testing.T, srv *Server, email string) *models.User { + t.Helper() + u, err := srv.store.CreateUser(models.UserCreate{Email: email, Name: "Ada", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatal(err) + } + return u +} + +func appGet(srv *Server, path, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.RemoteAddr = "192.0.2.1:1234" + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr +} + +func (f appAPIFix) path(p string) string { return appAPIPrefix + "/workspaces/" + f.ws.ID + p } + +// Every row of the route table is reachable with a valid token, and every +// response's key set is exactly its DTO's, nested keys included (the census). +func TestTask3401_RoutesAndDTOCensus(t *testing.T) { + f := appAPIFixture(t, "read") + paths := map[string]string{ + "appListCollections": "/collections", + "appGetCollection": "/collections/requests", + "appListItems": "/collections/requests/items", + "appGetItem": "/items/" + f.item.ID, + "appListComments": "/items/" + f.item.ID + "/comments", + "appMe": "/me", + } + if len(paths) != len(appRoutes) { + t.Fatalf("the census covers %d routes, the table has %d", len(paths), len(appRoutes)) + } + for _, rt := range appRoutes { + p, ok := paths[rt.Name] + if !ok { + t.Errorf("route %s has no census path", rt.Name) + continue + } + rr := appGet(f.srv, f.path(p), f.token) + if rr.Code != http.StatusOK { + t.Errorf("%s: %d %s", rt.Name, rr.Code, rr.Body.String()) + continue + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("%s: Content-Type %q", rt.Name, ct) + } + var body any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("%s: %v", rt.Name, err) + } + checkAppDTOKeys(t, rt.Name, body) + } +} + +// The key sets of each DTO, nested ones included. A field added to a DTO +// without a spec amendment fails here. +var appDTOKeys = map[string][]string{ + "collection": {"icon", "is_system", "name", "schema", "slug"}, + "schema": {"fields"}, + "schema_field": {"key", "label", "options", "required", "type", "default"}, // options/default only when set + "item": {"collection", "content", "created_at", "created_by_display", "etag", "fields", "id", "title", "updated_at", "via_app"}, + "comment": {"author_display", "author_kind", "body", "created_at", "deleted", "edited", "id", "item_id", "parent_comment_id", "updated_at"}, + "me": {"display_name", "is_app", "role", "user_id"}, +} + +func appKeysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func assertKeysWithin(t *testing.T, where string, got map[string]any, dto string, required []string) { + t.Helper() + allowed := map[string]bool{} + for _, k := range appDTOKeys[dto] { + allowed[k] = true + } + for _, k := range appKeysOf(got) { + if !allowed[k] { + t.Errorf("%s: key %q is not in the %s DTO", where, k, dto) + } + } + for _, k := range required { + if _, ok := got[k]; !ok { + t.Errorf("%s: key %q missing from the %s DTO", where, k, dto) + } + } +} + +func checkCollection(t *testing.T, where string, c map[string]any) { + assertKeysWithin(t, where, c, "collection", appDTOKeys["collection"]) + schema, _ := c["schema"].(map[string]any) + assertKeysWithin(t, where+".schema", schema, "schema", appDTOKeys["schema"]) + fields, _ := schema["fields"].([]any) + for _, fd := range fields { + m := fd.(map[string]any) + assertKeysWithin(t, where+".schema.fields[]", m, "schema_field", []string{"key", "label", "required", "type"}) + if m["type"] == "relation" || m["type"] == "multi_relation" { + t.Errorf("%s: a relation field is in the schema projection", where) + } + } +} + +func checkItem(t *testing.T, where string, it map[string]any) { + assertKeysWithin(t, where, it, "item", appDTOKeys["item"]) + fields, _ := it["fields"].(map[string]any) + for k := range fields { + if k != "status" && k != "size" { + t.Errorf("%s.fields carries %q: only declared non-relation keys may appear", where, k) + } + } +} + +func checkAppDTOKeys(t *testing.T, route string, body any) { + t.Helper() + m, _ := body.(map[string]any) + switch route { + case "appListCollections": + if strings.Join(appKeysOf(m), ",") != "collections" { + t.Errorf("%s: top-level keys %v", route, appKeysOf(m)) + } + for _, c := range m["collections"].([]any) { + checkCollection(t, route+".collections[]", c.(map[string]any)) + } + case "appGetCollection": + checkCollection(t, route, m) + case "appListItems": + if strings.Join(appKeysOf(m), ",") != "has_more,items,next_offset" { + t.Errorf("%s: top-level keys %v", route, appKeysOf(m)) + } + for _, it := range m["items"].([]any) { + checkItem(t, route+".items[]", it.(map[string]any)) + } + case "appGetItem": + checkItem(t, route, m) + case "appListComments": + for _, c := range m["comments"].([]any) { + assertKeysWithin(t, route+".comments[]", c.(map[string]any), "comment", appDTOKeys["comment"]) + } + case "appMe": + assertKeysWithin(t, route, m, "me", appDTOKeys["me"]) + } +} + +// The request DTO shapes are reflected, so a Go field added without a JSON +// key, or with an unlisted one, fails here too. +func TestTask3401_ResponseDTOFieldSets(t *testing.T) { + jsonKeys := func(v any) []string { + var out []string + tp := reflect.TypeOf(v) + for i := 0; i < tp.NumField(); i++ { + name := strings.Split(tp.Field(i).Tag.Get("json"), ",")[0] + out = append(out, name) + } + sort.Strings(out) + return out + } + check := func(name string, v any, want []string) { + w := append([]string(nil), want...) + sort.Strings(w) + if got := jsonKeys(v); strings.Join(got, ",") != strings.Join(w, ",") { + t.Errorf("%s fields = %v, want %v", name, got, w) + } + } + check("AppCollection", AppCollection{}, appDTOKeys["collection"]) + check("AppSchemaField", AppSchemaField{}, appDTOKeys["schema_field"]) + check("AppItem", AppItem{}, appDTOKeys["item"]) + check("AppComment", AppComment{}, appDTOKeys["comment"]) + check("AppMe", AppMe{}, appDTOKeys["me"]) +} + +// The ceiling: a bot with "all" membership reads only its companions and the +// system collections, wherever it asks. +func TestTask3401_TheCeiling(t *testing.T) { + f := appAPIFixture(t, "read") + rr := appGet(f.srv, f.path("/collections"), f.token) + var list struct { + Collections []AppCollection `json:"collections"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &list) + var slugs []string + for _, c := range list.Collections { + slugs = append(slugs, c.Slug) + } + sort.Strings(slugs) + if strings.Join(slugs, ",") != "conventions-app,requests" { + t.Errorf("collections = %v, want the companion and the system collection only", slugs) + } + for _, p := range []string{"/collections/private", "/collections/private/items", "/items/" + f.privateItem.ID, "/items/" + f.privateItem.ID + "/comments"} { + if rr := appGet(f.srv, f.path(p), f.token); rr.Code != http.StatusNotFound { + t.Errorf("%s outside the ceiling: %d %s, want 404", p, rr.Code, rr.Body.String()) + } + } + if rr := appGet(f.srv, f.path("/collections/requests/items"), f.token); !strings.Contains(rr.Body.String(), f.item.ID) { + t.Errorf("the companion item is not listed: %s", rr.Body.String()) + } +} + +// The door: every refusal before a handler runs. +func TestTask3401_TheDoorRefuses(t *testing.T) { + f := appAPIFixture(t, "read") + me := f.path("/me") + if rr := appGet(f.srv, me, ""); rr.Code != http.StatusUnauthorized { + t.Errorf("no token: %d", rr.Code) + } + if rr := appGet(f.srv, me, "garbage"); rr.Code != http.StatusUnauthorized { + t.Errorf("garbage token: %d", rr.Code) + } + // An MCP token and an MCP refresh token are not app tokens. + sess := newOAuthSession(t, f.srv) + mcpTok, code := mintWithResource(t, f.srv, sess, testCanonicalAudience) + if code != http.StatusOK { + t.Fatalf("mcp mint: %d", code) + } + for name, tok := range map[string]string{"an MCP token": mcpTok, "a refresh token": lastRefresh} { + if rr := appGet(f.srv, me, tok); rr.Code != http.StatusUnauthorized { + t.Errorf("%s: %d, want 401", name, rr.Code) + } + } + // A slug in {ws}, or another workspace's id: the workspace 404. + if rr := appGet(f.srv, appAPIPrefix+"/workspaces/"+f.ws.Slug+"/me", f.token); rr.Code != http.StatusNotFound { + t.Errorf("{ws} as a slug: %d, want 404", rr.Code) + } + if rr := appGet(f.srv, appAPIPrefix+"/workspaces/00000000-0000-0000-0000-000000000000/me", f.token); rr.Code != http.StatusNotFound { + t.Errorf("another {ws}: %d, want 404", rr.Code) + } + // An app token never reaches the human API. + if rr := appGet(f.srv, "/api/v1/workspaces", f.token); rr.Code != http.StatusUnauthorized { + t.Errorf("an app token on /api/v1: %d, want 401", rr.Code) + } + // No service access, a delegated binding, a stale epoch: refused. + exec := func(q string, args ...any) { + if _, err := f.srv.store.DB().Exec(q, args...); err != nil { + t.Fatal(err) + } + } + exec(`UPDATE app_installs SET service_access = NULL WHERE id = ?`, f.in.id) + if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized { + t.Errorf("no service access: %d, want 401", rr.Code) + } + exec(`UPDATE app_installs SET service_access = 'read' WHERE id = ?`, f.in.id) + exec(`UPDATE app_token_bindings SET auth_kind = 'delegated' WHERE install_id = ?`, f.in.id) + if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized { + t.Errorf("a delegated binding before TASK-3399: %d, want 401", rr.Code) + } + exec(`UPDATE app_token_bindings SET auth_kind = 'service' WHERE install_id = ?`, f.in.id) + if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusOK { + t.Fatalf("control: %d %s", rr.Code, rr.Body.String()) + } + exec(`UPDATE app_installs SET auth_epoch = auth_epoch + 1 WHERE id = ?`, f.in.id) + if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized { + t.Errorf("a stale epoch: %d, want 401", rr.Code) + } +} + +// A write row refuses a read token before anything is resolved: the access +// step runs first, on every write row (U6b adds them). +func TestTask3401_AWriteRowRefusesAReadToken(t *testing.T) { + f := appAPIFixture(t, "read") + reached := false + h := f.srv.requireAppAccess("write", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + req := httptest.NewRequest("POST", "/x", nil) + req = req.WithContext(withAppContextForTest(req, &appContext{Access: "read"})) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden || reached { + t.Errorf("read token on a write row: %d reached=%v, want 403 and no handler", rr.Code, reached) + } + rr = httptest.NewRecorder() + req = req.WithContext(withAppContextForTest(req, &appContext{Access: "write"})) + h.ServeHTTP(rr, req) + if !reached { + t.Error("control: a write token did not reach the handler") + } +} + +// The self-check refuses a request missing any key the helpers read. +func TestTask3401_ContextSelfCheck(t *testing.T) { + f := appAPIFixture(t, "read") + var seen *http.Request + h := f.srv.requireAppWorkspace(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { seen = r })) + grant := &AppTokenGrant{InstallID: f.in.id, WorkspaceID: f.ws.ID, AuthKind: "service", Subject: f.in.bot.ID} + req := httptest.NewRequest("GET", f.path("/me"), nil) + rctx := newChiCtx(map[string]string{"ws": f.ws.ID}) + req = req.WithContext(withChiCtx(req, rctx)) + req = req.WithContext(withAppContextForTest(req, &appContext{Grant: grant, InstallID: f.in.id, WorkspaceID: f.ws.ID, Access: "read", AuthKind: "service", Actor: f.in.bot})) + req = req.WithContext(WithCurrentUser(req.Context(), f.in.bot)) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if seen == nil { + t.Fatalf("a complete request was refused: %d %s", rr.Code, rr.Body.String()) + } + if err := appContextSelfCheck(seen); err != nil { + t.Fatalf("a complete context fails the self-check: %v", err) + } + // Each key, removed, fails it. + ac := appContextFrom(seen) + broken := *ac + broken.ReadCeiling = nil + if appContextSelfCheck(seen.WithContext(withAppContextForTest(seen, &broken))) == nil { + t.Error("a missing ceiling passed the self-check") + } + if appContextSelfCheck(seen.WithContext(WithTokenAllowedWorkspaces(seen.Context(), nil))) == nil { + t.Error("a missing allow-list passed the self-check") + } + if appContextSelfCheck(seen.WithContext(WithCurrentUser(seen.Context(), &models.User{ID: "someone-else"}))) == nil { + t.Error("a current user other than the actor passed the self-check") + } +} + +// Cross-workspace helpers refuse outright under app context. Each leg has a +// control without the app context that IS allowed, so the refusal is the +// app context's, not some other denial. +func TestTask3401_CrossWorkspaceHelpersRefuseApps(t *testing.T) { + mf := newMovedToFixture(t) + mf.record(mf.destB, true, 1) + mf.archiveSource() + base := func() *http.Request { + req := httptest.NewRequest("GET", "/x", nil) + ctx := WithCurrentUser(req.Context(), mf.owner) + ctx = context.WithValue(ctx, ctxResolvedWorkspaceID, mf.wsA.ID) + ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner") + return req.WithContext(ctx) + } + asApp := func(r *http.Request) *http.Request { + return r.WithContext(withAppContextForTest(r, &appContext{InstallID: "inst-x"})) + } + if a := mf.srv.AuthorizeCrossWorkspaceRead(base(), mf.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()); !a.Allowed { + t.Fatalf("control: the owner's cross-workspace read was refused: %v", a.Reason) + } + if a := mf.srv.AuthorizeCrossWorkspaceRead(asApp(base()), mf.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()); a.Allowed { + t.Error("a cross-workspace read was allowed under app context") + } + now := time.Now() + source := &models.Item{ID: mf.source.ID, WorkspaceID: mf.wsA.ID, CollectionID: mf.collA.ID, DeletedAt: &now} + if got := mf.srv.movedToDestinations(base(), source); len(got) == 0 { + t.Fatal("control: the moved item names no destination") + } + if got := mf.srv.movedToDestinations(asApp(base()), source); got != nil { + t.Errorf("moved_to under app context: %v", got) + } +} + +// A read token acts as a viewer whatever the bot's membership; a write token +// keeps its membership's role. +func TestTask3401_TheRoleFollowsTheAccess(t *testing.T) { + for access, want := range map[string]string{"read": "viewer", "write": "editor"} { + f := appAPIFixture(t, access) + var me AppMe + rr := appGet(f.srv, f.path("/me"), f.token) + _ = json.Unmarshal(rr.Body.Bytes(), &me) + if me.Role != want { + t.Errorf("%s token: role %q, want %q", access, me.Role, want) + } + } +} + +// A comment whose parent is on ANOTHER item projects parent_comment_id null. +func TestTask3401_ACrossItemParentProjectsToNull(t *testing.T) { + f := appAPIFixture(t, "read") + human := createTestUserDirect(t, f.srv, "elsewhere-3401@example.com") + elsewhere, err := f.srv.store.CreateComment(f.ws.ID, f.privateItem.ID, human.ID, models.CommentCreate{Body: "other", Author: "B", CreatedBy: "user"}) + if err != nil { + t.Fatal(err) + } + if _, err := f.srv.store.DB().Exec(`UPDATE comments SET parent_id = ? WHERE id = ?`, elsewhere.ID, f.comment.ID); err != nil { + t.Fatal(err) + } + reply, err := f.srv.store.CreateComment(f.ws.ID, f.item.ID, human.ID, models.CommentCreate{Body: "reply", Author: "C", CreatedBy: "user", ParentID: f.comment.ID}) + if err != nil { + t.Fatal(err) + } + var out struct { + Comments []AppComment `json:"comments"` + } + _ = json.Unmarshal(appGet(f.srv, f.path("/items/"+f.item.ID+"/comments"), f.token).Body.Bytes(), &out) + got := map[string]*string{} + for _, c := range out.Comments { + got[c.ID] = c.ParentCommentID + } + if p, ok := got[f.comment.ID]; !ok || p != nil { + t.Errorf("a cross-item parent projected as %v, want null", p) + } + if p := got[reply.ID]; p == nil || *p != f.comment.ID { + t.Errorf("control: a same-item parent projected as %v", p) + } +} + +// Lead ruling R2: every caller of the store's visibility, listed with how +// the ceiling reaches it. A new caller fails until it is reviewed. +func TestTask3401_EveryVisibilityCallerIsCeilinged(t *testing.T) { + reviewed := map[string]string{ + "server.go::visibleCollectionIDs": "applies the request's app ceiling; the store ceilings a bot", + "server.go::checkItemVisibleQ": "reached from app routes only through requireItemVisible, which checks the app ceiling first; the store ceilings a bot", + "handlers_me.go::handleGetMe": "not an app route (TokenAuth refuses an app token); the store ceilings a bot", + "handlers_reports.go::reportVisibleCollections": "not an app route; the store ceilings a bot", + "handlers_collab.go::authorizeCollabAccess": "not an app route; the store ceilings a bot", + "handlers_events.go::computeSSEVisibility": "not an app route; the store ceilings a bot", + } + fset := token.NewFileSet() + files, _ := filepath.Glob("*.go") + found := map[string]bool{} + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + af, err := parser.ParseFile(fset, file, src, 0) + if err != nil { + t.Fatal(err) + } + for _, d := range af.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "VisibleCollectionIDs", "VisibleCollectionIDsQ", "GuestVisibleCollectionIDs": + found[file+"::"+fn.Name.Name] = true + } + return true + }) + } + } + for site := range found { + if _, ok := reviewed[site]; !ok { + t.Errorf("%s calls the store's visibility and is not reviewed for the app ceiling", site) + } + } + for site := range reviewed { + if !found[site] { + t.Errorf("%s is reviewed but no longer calls the store's visibility; remove it", site) + } + } +} + +// The etag key is server-only and mandatory: no encryption key, no app store. +func TestTask3401_ETagKeyIsMandatory(t *testing.T) { + srv := testServer(t) + if _, err := srv.appStore(); err == nil { + t.Error("an app store was built with no encryption key") + } + f := appAPIFixture(t, "read") + a := appGet(f.srv, f.path("/items/"+f.item.ID), f.token) + b := appGet(f.srv, f.path("/items/"+f.item.ID), f.token) + var ia, ib AppItem + _ = json.Unmarshal(a.Body.Bytes(), &ia) + _ = json.Unmarshal(b.Body.Bytes(), &ib) + if ia.ETag == "" || ia.ETag != ib.ETag { + t.Errorf("etag unstable or empty: %q %q", ia.ETag, ib.ETag) + } +} + +var _ = url.Values{} + +func withAppContextForTest(r *http.Request, ac *appContext) context.Context { + return context.WithValue(r.Context(), appCtxKey{}, ac) +} + +func newChiCtx(params map[string]string) *chi.Context { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return rctx +} + +func withChiCtx(r *http.Request, rctx *chi.Context) context.Context { + return context.WithValue(r.Context(), chi.RouteCtxKey, rctx) +} + +// The request-level ceiling, which a delegated token (TASK-3399) will rely +// on: the actor is a PERSON with every collection visible, and the app +// context alone narrows it, in visibleCollectionIDs and in item visibility. +func TestTask3401_TheRequestCeilingBindsAPersonActor(t *testing.T) { + f := appAPIFixture(t, "read") + person := createTestUserDirect(t, f.srv, "delegate-3401@example.com") + if err := f.srv.store.AddWorkspaceMember(f.ws.ID, person.ID, "editor"); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/x", nil) + ctx := withAppContextForTest(req, &appContext{InstallID: f.in.id, ReadCeiling: []string{f.companion.ID, f.system.ID}}) + ctx = WithCurrentUser(ctx, person) + ctx = WithAPITokenAuth(ctx) + req = req.WithContext(context.WithValue(ctx, ctxWorkspaceRole, "editor")) + ids, err := f.srv.visibleCollectionIDs(req, f.ws.ID) + if err != nil { + t.Fatal(err) + } + sort.Strings(ids) + want := []string{f.companion.ID, f.system.ID} + sort.Strings(want) + if strings.Join(ids, ",") != strings.Join(want, ",") { + t.Errorf("visible under a request ceiling = %v, want %v", ids, want) + } + rr := httptest.NewRecorder() + if f.srv.requireItemVisible(rr, req, f.ws.ID, f.privateItem) { + t.Error("an item outside the request ceiling was visible to a person actor") + } + if !f.srv.requireItemVisible(httptest.NewRecorder(), req, f.ws.ID, f.item) { + t.Error("control: the companion item is not visible") + } +} + +// codex r1 P1: a revocation that commits while a read is in flight withholds +// its data. The response is re-validated against the install (epoch, state, +// service access, client) before it is sent. +func TestTask3401_ARevocationDuringAReadWithholdsItsData(t *testing.T) { + // Each change is keyed by the install id; the ones that act on other + // rows find them through it (codex r2: the token, the membership, the + // resources themselves). + cases := map[string]string{ + "rotate (epoch bump)": `UPDATE app_installs SET auth_epoch = auth_epoch + 1 WHERE id = ?`, + "disable": `UPDATE app_installs SET state = 'disabling' WHERE id = ?`, + "service access removed": `UPDATE app_installs SET service_access = NULL WHERE id = ?`, + "access lowered": `UPDATE app_installs SET service_access = 'read' WHERE id = ?`, + "token family revoked": `UPDATE oauth_access_tokens SET active = 0 WHERE request_id IN (SELECT request_id FROM app_token_bindings WHERE install_id = ?)`, + "collection access emptied": `UPDATE workspace_members SET collection_access = 'specific' + WHERE user_id = (SELECT bot_user_id FROM app_installs WHERE id = ?)`, + "bot role lowered": `UPDATE workspace_members SET role = 'viewer' + WHERE user_id = (SELECT bot_user_id FROM app_installs WHERE id = ?)`, + "companion collection deleted": `UPDATE collections SET deleted_at = '2026-01-01T00:00:00Z' WHERE via_app = ?`, + "companion released": `UPDATE collections SET via_app = NULL WHERE via_app = ?`, + "workspace deleted": `UPDATE workspaces SET deleted_at = '2026-01-01T00:00:00Z' + WHERE id = (SELECT workspace_id FROM app_installs WHERE id = ?)`, + "item deleted": `UPDATE items SET deleted_at = '2026-01-01T00:00:00Z' WHERE created_via_app = ?`, + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + f := appAPIFixture(t, "write") + f.srv.appAfterHandler = func() { + if _, err := f.srv.store.DB().Exec(change, f.in.id); err != nil { + t.Error(err) + } + } + rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token) + if rr.Code != http.StatusUnauthorized { + t.Errorf("%s mid-read: %d, want 401", name, rr.Code) + } + if strings.Contains(rr.Body.String(), "Login broken") || strings.Contains(rr.Body.String(), f.item.ID) { + t.Errorf("%s mid-read: the item leaked: %s", name, rr.Body.String()) + } + }) + } + // Control: nothing changes mid-read. + f := appAPIFixture(t, "write") + f.srv.appAfterHandler = func() {} + if rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token); rr.Code != http.StatusOK { + t.Errorf("control: %d %s", rr.Code, rr.Body.String()) + } +} + +// The seam is nil in production. +func TestTask3401_AppAfterHandlerSeamIsNilInProduction(t *testing.T) { + if New(nil).appAfterHandler != nil { + t.Fatal("appAfterHandler is set in a new Server") + } +} + +// codex r3 P2: on a self-host, an admin turning apps off while a read is in +// flight withholds its data, as the gate refuses any new request. +func TestTask3401_AppsTurnedOffMidReadWithholdsTheData(t *testing.T) { + f := appAPIFixtureOn(t, "read", false) + if rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token); rr.Code != http.StatusOK { + t.Fatalf("control: %d %s", rr.Code, rr.Body.String()) + } + f.srv.appAfterHandler = func() { + if err := f.srv.store.SetPlatformSetting(settingAppsEnabled, "false"); err != nil { + t.Error(err) + } + } + rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token) + if rr.Code == http.StatusOK || strings.Contains(rr.Body.String(), "Login broken") { + t.Errorf("apps off mid-read: %d %s", rr.Code, rr.Body.String()) + } +} + +// codex r3: a request with no re-check holder could not have registered its +// authorizations, so re-validation refuses it rather than passing with none. +func TestTask3401_RevalidateFailsClosedWithoutARecheckHolder(t *testing.T) { + f := appAPIFixture(t, "read") + ac, err := f.srv.appAdmitToken(context.Background(), f.token) + if err != nil { + t.Fatal(err) + } + full, err := f.srv.appAdmitWorkspace(ac) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/x", nil) + req = req.WithContext(appRequestContext(req.Context(), full)) + if err := f.srv.appRevalidate(req); err == nil { + t.Error("re-validation passed a request with no re-check holder") + } + withHolder := req.WithContext(context.WithValue(req.Context(), appRecheckKey{}, &appRechecks{})) + if err := f.srv.appRevalidate(withHolder); err != nil { + t.Errorf("control: a request with a holder failed re-validation: %v", err) + } +} + +// Rook's review: item visibility filters AFTER the LIMIT, so a page can come +// back short. Paging must not depend on the page's length: next_offset +// advances by the rows the window covered, and has_more is decided from the +// raw window, so a short page never ends an app's paging early. +func TestTask3401_ItemListPagingIsByWindowNotByCount(t *testing.T) { + f := appAPIFixture(t, "read") + human := createTestUserDirect(t, f.srv, "pager-3401@example.com") + for i := 0; i < 2; i++ { + if _, err := f.srv.store.CreateItem(f.ws.ID, f.companion.ID, models.ItemCreate{Title: "More " + string(rune('A'+i)), ActorUserID: human.ID}); err != nil { + t.Fatal(err) + } + } + type page struct { + Items []AppItem `json:"items"` + HasMore bool `json:"has_more"` + NextOffset int `json:"next_offset"` + } + get := func(q string) page { + var p page + rr := appGet(f.srv, f.path("/collections/requests/items"+q), f.token) + if rr.Code != http.StatusOK { + t.Fatalf("%s: %d %s", q, rr.Code, rr.Body.String()) + } + _ = json.Unmarshal(rr.Body.Bytes(), &p) + return p + } + p1 := get("?limit=2") + if !p1.HasMore || p1.NextOffset != 2 || len(p1.Items) != 2 { + t.Errorf("page 1: has_more=%v next_offset=%d items=%d, want true/2/2", p1.HasMore, p1.NextOffset, len(p1.Items)) + } + p2 := get("?limit=2&offset=2") + if p2.HasMore || p2.NextOffset != 4 || len(p2.Items) != 1 { + t.Errorf("page 2: has_more=%v next_offset=%d items=%d, want false/4/1", p2.HasMore, p2.NextOffset, len(p2.Items)) + } +} diff --git a/internal/store/app_ceiling.go b/internal/store/app_ceiling.go new file mode 100644 index 000000000..1e903bf27 --- /dev/null +++ b/internal/store/app_ceiling.go @@ -0,0 +1,235 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// The app ceiling (SPEC-6 §4, U6a, TASK-3401). +// +// An installed app reads at most its install's companion collections plus +// the workspace's system collections, and writes at most its companions. +// For the install's BOT (a service actor) the read ceiling is applied here, in +// VisibleCollectionIDsQ itself, so every caller of the store's visibility +// (item visibility, SSE, workspace /me, collab, reports, and any added later) +// gets it by construction, whatever the bot's membership grants. For a +// DELEGATED token (U5b) the actor is a person, whose ceiling belongs to the +// token, not the user; the app API applies it from the request context. + +// AppPrincipalInstallIDQ returns the install a bot acts for: the install +// whose bot_user_id it is, else the one its address names. "" for a person, +// or a bot whose install is gone. +func (s *Store) AppPrincipalInstallIDQ(q Queryer, userID string) (string, error) { + id, _, err := s.appPrincipalInstallQ(q, userID) + return id, err +} + +// appPrincipalInstallQ is AppPrincipalInstallIDQ that also reports whether +// the user is a bot at all, from the same single users read (codex r1 P3). +func (s *Store) appPrincipalInstallQ(q Queryer, userID string) (string, bool, error) { + var kind, email string + err := q.QueryRow(s.q(`SELECT kind, email FROM users WHERE id = ?`), userID).Scan(&kind, &email) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("read principal: %w", err) + } + if kind != models.UserKindApp { + return "", false, nil + } + var id string + err = q.QueryRow(s.q(`SELECT id FROM app_installs WHERE bot_user_id = ?`), userID).Scan(&id) + if err == nil { + return id, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", true, fmt.Errorf("read principal install: %w", err) + } + if fromAddr, ok := appPrincipalInstallID(email); ok { + var bot sql.NullString + err := q.QueryRow(s.q(`SELECT bot_user_id FROM app_installs WHERE id = ?`), fromAddr).Scan(&bot) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", true, nil + case err != nil: + return "", true, fmt.Errorf("read principal install: %w", err) + case bot.Valid && bot.String != "" && bot.String != userID: + // The install names a different bot: this one is not its. + return "", true, nil + } + return fromAddr, true, nil + } + return "", true, nil +} + +// InstallCompanionCollectionIDsQ returns the install's companion collections: +// the live collections in its workspace stamped via_app = the install. +func (s *Store) InstallCompanionCollectionIDsQ(q Queryer, installID string) ([]string, error) { + return s.collectIDs(q, ` + SELECT c.id FROM collections c + JOIN app_installs i ON i.id = c.via_app AND i.workspace_id = c.workspace_id + WHERE c.via_app = ? AND c.deleted_at IS NULL + ORDER BY c.id`, installID) +} + +// InstallReadCeilingQ is the install's read ceiling: its companion +// collections and the workspace's system collections. +func (s *Store) InstallReadCeilingQ(q Queryer, installID string) ([]string, error) { + return s.collectIDs(q, ` + SELECT c.id FROM collections c + JOIN app_installs i ON i.workspace_id = c.workspace_id + WHERE i.id = ? AND c.deleted_at IS NULL AND (c.is_system OR c.via_app = i.id) + ORDER BY c.id`, installID) +} + +func (s *Store) collectIDs(q Queryer, query string, args ...any) ([]string, error) { + rows, err := q.Query(s.q(query), args...) + if err != nil { + return nil, fmt.Errorf("read collection ids: %w", err) + } + defer rows.Close() + out := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// applyAppPrincipalCeilingQ narrows visible to a bot's install ceiling. +// visible nil means "every collection", which under a ceiling becomes the +// ceiling itself; the result for a bot is never nil, so no caller can read +// it as "unrestricted". A person's visibility is returned unchanged. +func (s *Store) applyAppPrincipalCeilingQ(q Queryer, workspaceID, userID string, visible []string) ([]string, error) { + installID, isBot, err := s.appPrincipalInstallQ(q, userID) + if err != nil { + return nil, err + } + if !isBot { + return visible, nil + } + if installID == "" { + return []string{}, nil // a bot with no install sees nothing + } + ceiling, err := s.InstallReadCeilingQ(q, installID) + if err != nil { + return nil, err + } + return IntersectCollectionIDs(visible, ceiling), nil +} + +// IntersectCollectionIDs narrows visible (nil = every collection) to ceiling, +// never returning nil. +func IntersectCollectionIDs(visible, ceiling []string) []string { + if visible == nil { + out := make([]string, len(ceiling)) + copy(out, ceiling) + return out + } + in := make(map[string]bool, len(ceiling)) + for _, id := range ceiling { + in[id] = true + } + out := []string{} + for _, id := range visible { + if in[id] { + out = append(out, id) + } + } + return out +} + +// InstallAPIState is what the app API reads about an install per request. +type InstallAPIState struct { + WorkspaceID string + State string + ServiceAccess string // "read", "write", or "" (none) + BotUserID string +} + +// GetInstallAPIState reads an install's workspace, state, service access and +// bot, or nil when there is no such install. +func (s *Store) GetInstallAPIState(installID string) (*InstallAPIState, error) { + var st InstallAPIState + var access, bot sql.NullString + err := s.db.QueryRow(s.q(`SELECT workspace_id, state, service_access, bot_user_id FROM app_installs WHERE id = ?`), installID). + Scan(&st.WorkspaceID, &st.State, &access, &bot) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read install: %w", err) + } + st.ServiceAccess, st.BotUserID = access.String, bot.String + return &st, nil +} + +// AppItemReadMeta is what the app API's item DTO needs beyond models.Item. +type AppItemReadMeta struct { + ViaApp string + CreatorDisplay string +} + +// ItemsAppReadMeta returns each item's via_app (the last install that wrote +// it) and its creator's display name, in one query. +func (s *Store) ItemsAppReadMeta(itemIDs []string) (map[string]AppItemReadMeta, error) { + out := map[string]AppItemReadMeta{} + if len(itemIDs) == 0 { + return out, nil + } + ph := make([]string, len(itemIDs)) + args := make([]any, len(itemIDs)) + for i, id := range itemIDs { + ph[i], args[i] = "?", id + } + rows, err := s.db.Query(s.q(`SELECT i.id, COALESCE(i.via_app, ''), COALESCE(u.name, '') + FROM items i LEFT JOIN users u ON u.id = i.created_by_user_id + WHERE i.id IN (`+strings.Join(ph, ",")+`)`), args...) + if err != nil { + return nil, fmt.Errorf("read item app meta: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + var m AppItemReadMeta + if err := rows.Scan(&id, &m.ViaApp, &m.CreatorDisplay); err != nil { + return nil, err + } + out[id] = m + } + return out, rows.Err() +} + +// UserKinds returns each user's kind ("human" or "app"). +func (s *Store) UserKinds(userIDs []string) (map[string]string, error) { + out := map[string]string{} + if len(userIDs) == 0 { + return out, nil + } + ph := make([]string, len(userIDs)) + args := make([]any, len(userIDs)) + for i, id := range userIDs { + ph[i], args[i] = "?", id + } + rows, err := s.db.Query(s.q(`SELECT id, kind FROM users WHERE id IN (`+strings.Join(ph, ",")+`)`), args...) + if err != nil { + return nil, fmt.Errorf("read user kinds: %w", err) + } + defer rows.Close() + for rows.Next() { + var id, kind string + if err := rows.Scan(&id, &kind); err != nil { + return nil, err + } + out[id] = kind + } + return out, rows.Err() +} diff --git a/internal/store/encryption.go b/internal/store/encryption.go index 21583d369..4377858f2 100644 --- a/internal/store/encryption.go +++ b/internal/store/encryption.go @@ -4,10 +4,13 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" "fmt" "io" "strings" + + "golang.org/x/crypto/hkdf" ) const encryptedPrefix = "enc:" @@ -252,3 +255,23 @@ func (s *Store) EncryptWebhookSecretsAtRest() (int, error) { return len(toEncrypt), nil } + +// ErrNoEncryptionKey means no encryption key is configured. +var ErrNoEncryptionKey = fmt.Errorf("no encryption key is configured") + +// DeriveServerKey derives a 32-byte server-only key for one purpose from the +// deployment encryption key (HKDF-SHA256, the label as info). Every supported +// deployment has the encryption key at startup (SQLite generates and persists +// one; Postgres refuses to start without PAD_ENCRYPTION_KEY, shared by every +// replica), so a derived key is mandatory, persistent and the same on every +// instance. Rotating the encryption key rotates every derived key. +func (s *Store) DeriveServerKey(label string) ([]byte, error) { + if !s.HasEncryptionKey() { + return nil, ErrNoEncryptionKey + } + out := make([]byte, 32) + if _, err := io.ReadFull(hkdf.New(sha256.New, s.encryptionKey, nil, []byte(label)), out); err != nil { + return nil, fmt.Errorf("derive server key: %w", err) + } + return out, nil +} diff --git a/internal/store/task3401_app_ceiling_test.go b/internal/store/task3401_app_ceiling_test.go new file mode 100644 index 000000000..34e55dc6f --- /dev/null +++ b/internal/store/task3401_app_ceiling_test.go @@ -0,0 +1,113 @@ +package store + +import ( + "sort" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TASK-3401 (SPEC-6 U6a): the store applies a bot's install ceiling inside +// VisibleCollectionIDsQ, so every visibility caller gets it. + +type task3401Fix struct { + s *Store + ws *models.Workspace + installID string + bot *models.User + companion, system, other *models.Collection +} + +func task3401Fixture(t *testing.T, access string) task3401Fix { + t.Helper() + s := testStore(t) + ws := createTestWorkspace(t, s, "Ceiling") + f := task3401Fix{s: s, ws: ws, installID: "inst-ceiling"} + task3392Install(t, s, ws.ID, f.installID, "active") + f.companion = createTestCollection(t, s, ws.ID, "Requests") + f.system = createTestCollection(t, s, ws.ID, "Conventions Sys") + f.other = createTestCollection(t, s, ws.ID, "Private") + if _, err := s.db.Exec(s.q(`UPDATE collections SET via_app = ? WHERE id = ?`), f.installID, f.companion.ID); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(s.q(`UPDATE collections SET is_system = ? WHERE id = ?`), true, f.system.ID); err != nil { + t.Fatal(err) + } + f.bot = task3392Bot(t, s, f.installID) + if _, err := s.db.Exec(s.q(`UPDATE app_installs SET bot_user_id = ? WHERE id = ?`), f.bot.ID, f.installID); err != nil { + t.Fatal(err) + } + task3392Member(t, s, ws.ID, f.bot.ID, "editor") + if _, err := s.db.Exec(s.q(`UPDATE workspace_members SET collection_access = ? WHERE workspace_id = ? AND user_id = ?`), access, ws.ID, f.bot.ID); err != nil { + t.Fatal(err) + } + return f +} + +func task3401Sorted(ids []string) string { + out := append([]string(nil), ids...) + sort.Strings(out) + return strings.Join(out, ",") +} + +// A bot with "all" access (as a member) still sees only its companions and +// the system collections: the ceiling does not rely on its membership. +func TestTask3401_ABotsVisibilityIsItsInstallCeiling(t *testing.T) { + f := task3401Fixture(t, "all") + got, err := f.s.VisibleCollectionIDsQ(f.s.db, f.ws.ID, f.bot.ID) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("a bot's visibility came back nil (unrestricted)") + } + want := task3401Sorted([]string{f.companion.ID, f.system.ID}) + if task3401Sorted(got) != want { + t.Errorf("visible = %v, want only the companion and the system collection", got) + } + // The narrower membership wins inside the ceiling. + if _, err := f.s.db.Exec(f.s.q(`UPDATE workspace_members SET collection_access = 'specific' WHERE workspace_id = ? AND user_id = ?`), f.ws.ID, f.bot.ID); err != nil { + t.Fatal(err) + } + if _, err := f.s.db.Exec(f.s.q(`INSERT INTO member_collection_access (workspace_id, user_id, collection_id, created_at) VALUES (?, ?, ?, ?)`), f.ws.ID, f.bot.ID, f.companion.ID, now()); err != nil { + t.Fatal(err) + } + got, err = f.s.VisibleCollectionIDsQ(f.s.db, f.ws.ID, f.bot.ID) + if err != nil { + t.Fatal(err) + } + if task3401Sorted(got) != f.companion.ID { + t.Errorf("specific access over the companion: visible = %v", got) + } +} + +// A bot with no install sees nothing; a person's visibility is untouched. +func TestTask3401_OrphanBotSeesNothingAndPeopleAreUnchanged(t *testing.T) { + f := task3401Fixture(t, "all") + if _, err := f.s.db.Exec(f.s.q(`UPDATE app_installs SET bot_user_id = NULL WHERE id = ?`), f.installID); err != nil { + t.Fatal(err) + } + // Still found through its address, so still ceilinged. + got, err := f.s.VisibleCollectionIDsQ(f.s.db, f.ws.ID, f.bot.ID) + if err != nil || got == nil || task3401Sorted(got) != task3401Sorted([]string{f.companion.ID, f.system.ID}) { + t.Errorf("found by address: %v %v", got, err) + } + // An install that names a different bot disowns this one. + other := task3392Bot(t, f.s, "inst-other-bot") + if _, err := f.s.db.Exec(f.s.q(`UPDATE app_installs SET bot_user_id = ? WHERE id = ?`), other.ID, f.installID); err != nil { + t.Fatal(err) + } + got, err = f.s.VisibleCollectionIDsQ(f.s.db, f.ws.ID, f.bot.ID) + if err != nil || got == nil || len(got) != 0 { + t.Errorf("a disowned bot sees %v (%v), want nothing", got, err) + } + human := createTestUser(t, f.s, "person@test.com", "Person", "password123") + if err := f.s.AddWorkspaceMember(f.ws.ID, human.ID, "editor"); err != nil { + t.Fatal(err) + } + got, err = f.s.VisibleCollectionIDsQ(f.s.db, f.ws.ID, human.ID) + if err != nil || got != nil { + t.Errorf("a person with all access: %v %v, want nil (unrestricted)", got, err) + } +} diff --git a/internal/store/workspace_members.go b/internal/store/workspace_members.go index 6b018a173..9e4d02196 100644 --- a/internal/store/workspace_members.go +++ b/internal/store/workspace_members.go @@ -341,6 +341,18 @@ func (s *Store) VisibleCollectionIDs(workspaceID, userID string) ([]string, erro // VisibleCollectionIDsQ is VisibleCollectionIDs parameterized over its // executor (see Queryer). func (s *Store) VisibleCollectionIDsQ(q Queryer, workspaceID, userID string) ([]string, error) { + ids, err := s.membershipVisibleCollectionIDsQ(q, workspaceID, userID) + if err != nil { + return nil, err + } + // An installed app's bot sees no further than its install's ceiling, + // whatever its membership says, at EVERY caller (SPEC-6 U6a, TASK-3401). + return s.applyAppPrincipalCeilingQ(q, workspaceID, userID, ids) +} + +// membershipVisibleCollectionIDsQ is the visibility a membership (or a +// guest's grants) gives, before any app ceiling. +func (s *Store) membershipVisibleCollectionIDsQ(q Queryer, workspaceID, userID string) ([]string, error) { member, err := s.GetWorkspaceMemberQ(q, workspaceID, userID) if err != nil { return nil, err