feat(16.3-03): journal scopes and ResolveBackend token auth

- Register journal and journal:read via pact.HasTokenScopes
- Switch API auth to cabana.ResolveBackend with golem15.journal.* cover
- Map ErrTokenScope to PHP Insufficient permissions bodies
- Add TestResolveErrorMapping and TestJournalTokenTracer

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 02:11:13 +02:00
parent 5eea57fde1
commit 4ec64a3b35
6 changed files with 194 additions and 36 deletions

View File

@@ -1,7 +1,6 @@
package api
import (
"database/sql"
"errors"
"net/http"
"strings"
@@ -28,7 +27,7 @@ const (
permAccessOtherPosts = "golem15.journal.access_other_posts"
permAccessPublish = "golem15.journal.access_publish"
backendJWTBlacklistTable = "backend_jwt_blacklist"
journalCover = "golem15.journal.*"
)
func writeAPIError(w http.ResponseWriter, status int, message string) {
@@ -55,55 +54,40 @@ func hasBearer(r *http.Request) bool {
return ok && strings.TrimSpace(token) != ""
}
func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard {
secret := ""
if app != nil && app.Config != nil {
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
// resolveError maps cabana.ResolveBackend errors to the PHP Journal API bodies.
func resolveError(w http.ResponseWriter, err error) {
if errors.Is(err, cabana.ErrTokenScope) {
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
return
}
var gdb *gorm.DB
var sqlDB *sql.DB
var reg *cabana.Registry
if app != nil {
gdb, _ = app.Lookup[*gorm.DB]()
sqlDB, _ = app.Lookup[*sql.DB]()
reg, _ = app.Lookup[*cabana.Registry]()
if sqlDB == nil && gdb != nil {
if db, err := gdb.DB(); err == nil {
sqlDB = db
}
}
}
var bl bouncer.BlacklistStore
if sqlDB != nil {
bl = bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable)
}
return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)
writeAuthRequired(w, err)
}
// optionalBackendPrincipal verifies a backend JWT only when Authorization
// Bearer is present. Failure is anonymous: public GET must not 401.
// optionalBackendPrincipal verifies a backend JWT or personal API token only
// when Authorization Bearer is present. Failure is anonymous: public GET must
// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*.
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
if r == nil || !hasBearer(r) {
return r
}
pr, err := newBackendGuard(app, nil).Authenticate(r)
if err != nil || pr == nil {
out, _, err := cabana.ResolveBackend(app, r, journalCover)
if err != nil || out == nil {
return r
}
return r.WithContext(bouncer.WithUser(r.Context(), pr))
return out
}
// requireBackendPrincipal authenticates a backend-audience JWT and writes the
// PHP {error:"Authentication required"} body when the token is missing or
// invalid (D-15). It does not use cabana's admin unauthenticated envelope.
// requireBackendPrincipal authenticates a backend JWT or personal API token
// and writes the PHP {error:"Authentication required"} or
// {error:"Insufficient permissions"} body (D-15). It does not use cabana's
// admin unauthenticated envelope.
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
if err != nil || pr == nil {
writeAuthRequired(w, err)
resolveError(w, err)
return r, nil, false
}
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
return r, pr, true
return out, pr, true
}
func isEditor(pr *bouncer.Principal) bool {