feat(apps): the installed-app API door, ceiling and read routes (TASK-3401, SPEC-6 U6a) (#1771)

* feat(store): a bot's visibility is its install's ceiling, at every caller (TASK-3401)

* feat(server): the app API door, route table, ceiling, and read routes (TASK-3401, U6a)

* test(server): U6a door, ceiling, DTO census and visibility-caller census (TASK-3401)

* test(server): controls for the cross-workspace refusals; role follows access; cross-item parent projects null (TASK-3401)

* fix(server): app responses are re-validated against revocation before they are sent; one users read in the bot ceiling (TASK-3401, codex r1)

* fix(server): re-admit an app request and replay its authorizations before the response is sent (TASK-3401, codex r2)

* fix(server): re-admission rechecks apps availability and fails closed without a re-check holder; collection re-checks memoized (TASK-3401, codex r3)

* test(server): re-validation fails closed without a re-check holder (TASK-3401)

* docs(server): the re-check memo is sequential, not a snapshot (TASK-3401, codex r4)

* chore(server): drop the unused write-rule helper until U6b's first caller (TASK-3401)

* style(server): gofmt after dropping the helper (TASK-3401)

* fix(server): app item lists page by window (has_more, next_offset), not by page length (TASK-3401, Rook review)
This commit is contained in:
xarmian
2026-10-04 09:51:29 -04:00
committed by GitHub
parent ca158c22d8
commit 282a42f69f
10 changed files with 2157 additions and 1 deletions
+508
View File
@@ -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 }
+457
View File
@@ -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()})
}
+6
View File
@@ -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))
}
+4
View File
@@ -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
+26 -1
View File
@@ -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)
+773
View File
@@ -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))
}
}
+235
View File
@@ -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()
}
+23
View File
@@ -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
}
+113
View File
@@ -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)
}
}
+12
View File
@@ -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