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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
68
journal_api_token_smoke_test.go
Normal file
68
journal_api_token_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
26
token_scopes.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user