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:
@@ -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 {
|
||||
|
||||
70
controllers/api/auth_test.go
Normal file
70
controllers/api/auth_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
func TestResolveErrorMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "ErrTokenScope",
|
||||
err: cabana.ErrTokenScope,
|
||||
status: http.StatusForbidden,
|
||||
body: `{"error":"Insufficient permissions"}`,
|
||||
},
|
||||
{
|
||||
name: "wrapped ErrTokenScope",
|
||||
err: fmt.Errorf("cover: %w", cabana.ErrTokenScope),
|
||||
status: http.StatusForbidden,
|
||||
body: `{"error":"Insufficient permissions"}`,
|
||||
},
|
||||
{
|
||||
name: "ErrUnauthenticated",
|
||||
err: cabana.ErrUnauthenticated,
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"Authentication required"}`,
|
||||
},
|
||||
{
|
||||
name: "generic error",
|
||||
err: errors.New("boom"),
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"Authentication required"}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
resolveError(rec, tc.err)
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.status, rec.Body.String())
|
||||
}
|
||||
got := rec.Body.Bytes()
|
||||
var wantObj, gotObj map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.body), &wantObj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(got, &gotObj); err != nil {
|
||||
t.Fatalf("body json: %s err=%v", got, err)
|
||||
}
|
||||
wantRaw, _ := json.Marshal(wantObj)
|
||||
gotRaw, _ := json.Marshal(gotObj)
|
||||
if string(wantRaw) != string(gotRaw) {
|
||||
t.Fatalf("body = %s, want %s", got, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user