mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 05:49:50 +00:00
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:
@@ -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 }
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user