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 {

View 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)
}
})
}
}

View File

@@ -0,0 +1,68 @@
package journal_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestJournalTokenTracer mints a journal-scoped personal API token through the
// admin account API and creates a Journal post as that token's owner (16.3-03).
func TestJournalTokenTracer(t *testing.T) {
env := newPostsEnv(t)
jwt := env.admin(t, controllers.PermissionAccessPosts)
adminID := env.lastUserID(t)
create := env.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{
"name": "journal-agent",
"expires": "90d",
"scopes": []string{"journal"},
})
if create.Code != http.StatusCreated {
t.Fatalf("create token: %d %s", create.Code, create.Body.String())
}
var created struct {
Data cabana.AdminAPITokenCreated `json:"data"`
}
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
plain := created.Data.Token
if !strings.HasPrefix(plain, cabana.APITokenPrefix) || len(plain) != len(cabana.APITokenPrefix)+40 {
t.Fatalf("token shape %q", plain)
}
postRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", plain, map[string]any{
"title": "Token-authored post",
"content": "Written with a journal scope token",
})
if postRec.Code != http.StatusCreated {
t.Fatalf("POST /posts with journal token: %d %s", postRec.Code, postRec.Body.String())
}
var postBody struct {
Data struct {
ID uint `json:"id"`
UserID *uint `json:"user_id"`
Slug string `json:"slug"`
} `json:"data"`
}
if err := json.Unmarshal(postRec.Body.Bytes(), &postBody); err != nil {
t.Fatalf("post body: %s err=%v", postRec.Body.String(), err)
}
if postBody.Data.ID == 0 {
t.Fatalf("missing post id: %s", postRec.Body.String())
}
var row models.Post
if err := env.gdb.First(&row, postBody.Data.ID).Error; err != nil {
t.Fatal(err)
}
if row.UserID == nil || *row.UserID != adminID {
t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID)
}
}

View File

@@ -131,6 +131,11 @@ tag:
delete_confirm: Delete this tag?
delete_success: Successfully deleted those tags.
return_to_tags: Return to the journal tag list
scopes:
journal: Journal
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.
journal_read: Journal, read-only
journal_read_description: Read posts, including drafts you may see, without changing anything.
editor:
content_invalid: The content must be HTML text.
image_invalid: The image must be a JPEG, PNG, GIF or WebP file.

View File

@@ -83,6 +83,11 @@ category:
delete_success: Kategorie zostały pomyślnie usunięte.
return_to_categories: Wróć do listy kategorii
reorder: Zmień kolejność kategorii
scopes:
journal: Journal
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.
journal_read: Journal, tylko odczyt
journal_read_description: Czytaj posty, w tym szkice, które możesz zobaczyć, bez wprowadzania zmian.
editor:
content_invalid: Treść musi być tekstem HTML.
image_invalid: Obraz musi być plikiem JPEG, PNG, GIF lub WebP.

26
token_scopes.go Normal file
View File

@@ -0,0 +1,26 @@
package journal
import "git.golem15.com/golem15/summercms/modules/pact"
var _ pact.HasTokenScopes = (*Plugin)(nil)
// TokenScopes registers personal API token scopes for the Journal plugin.
func (p *Plugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "journal",
Label: "golem15.journal::lang.scopes.journal",
Description: "golem15.journal::lang.scopes.journal_description",
Permissions: []string{"golem15.journal.*"},
Methods: all,
},
{
Name: "journal:read",
Label: "golem15.journal::lang.scopes.journal_read",
Description: "golem15.journal::lang.scopes.journal_read_description",
Permissions: []string{"golem15.journal.*"},
Methods: []string{"GET", "HEAD"},
},
}
}