Multi-user groundwork: request-scoped user identity

Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.

New internal/auth moves it into the request context:

  - Middleware(Resolver) resolves the caller once per API request
  - handlers read auth.UserID(r.Context()) instead of naming a user
  - Resolver is the seam an Authentik session check drops into
  - StaticResolver(db.LocalUserID) keeps Petal single-user today

Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.

main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.

Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:

  - setStatus (accept/dismiss) updated a suggestion by bare id with no
    ownership check at all
  - listForDoc/fetchPending read a document's suggestions by doc_id
    alone; a suggestion quotes the sentence it corrects, so that leaked
    the source prose

Both now scope through documents.user_id.

Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.

Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.

Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
This commit is contained in:
prosolis
2026-07-26 21:42:37 -07:00
parent 61b3c6cd62
commit 6901cdbbe4
21 changed files with 692 additions and 122 deletions
+77
View File
@@ -0,0 +1,77 @@
// Package auth answers one question for every API request: who is asking?
//
// Until now Petal ran as a single hardcoded user and every query passed
// db.LocalUserID directly. That made the identity of the caller a compile-time
// constant scattered across ~35 call sites — nothing a real login could ever
// replace without touching all of them. This package moves that identity into
// the request context, resolved once by [Middleware], so handlers read the
// current user instead of naming one.
//
// The identity itself still comes from [StaticResolver] today, which returns
// the same local user for everyone. Swapping in Authentik later means writing
// one Resolver (validate the session cookie → user id) and changing the single
// line in main.go that constructs it. No handler changes.
package auth
import (
"context"
"net/http"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// ctxKey is unexported so no other package can plant a user id in the context
// without going through [WithUser].
type ctxKey struct{}
// WithUser returns a copy of ctx carrying userID as the authenticated caller.
// Handlers never call this; [Middleware] does, and tests use it to build a
// request that looks authenticated.
func WithUser(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, ctxKey{}, userID)
}
// UserID returns the authenticated user id carried by ctx, or "" if the request
// never passed through [Middleware].
//
// Returning "" rather than panicking keeps an unauthenticated request failing
// *closed*: every query in Petal is scoped `WHERE user_id = ?`, so an empty id
// matches no rows — a missing middleware leaks nothing, it just returns empty
// results. Handlers may therefore use the value directly without checking it.
func UserID(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
// Resolver maps an inbound request to the id of the user making it. Returning
// an error, or an empty id, rejects the request with a 401.
//
// This is the seam a real identity provider drops into: an Authentik resolver
// validates the session cookie and returns the user id it maps to.
type Resolver interface {
Resolve(r *http.Request) (string, error)
}
// StaticResolver resolves every request to the same user id, ignoring the
// request entirely. It is how Petal runs today — a single-user app whose one
// user now arrives through the same path a logged-in user eventually will.
type StaticResolver string
// Resolve implements [Resolver].
func (s StaticResolver) Resolve(*http.Request) (string, error) { return string(s), nil }
// Middleware resolves the caller with res and stores the result in the request
// context for [UserID]. Requests the resolver rejects — or resolves to an empty
// id — never reach the handler; they get a 401 instead.
func Middleware(res Resolver) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, err := res.Resolve(r)
if err != nil || userID == "" {
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
return
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), userID)))
})
}
}