Files
sm-journal-plugin/controllers/api/auth.go
Jakub Zych 4ec64a3b35 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>
2026-10-11 02:11:13 +02:00

115 lines
3.5 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
const (
errAuthenticationRequired = "Authentication required"
errInsufficientPerms = "Insufficient permissions"
errPublishPermission = "You do not have permission to publish posts"
errEditPermission = "You do not have permission to edit this post"
errPostNotFound = "Post not found"
errImageNotFound = "Image not found"
errValidationFailed = "Validation failed"
msgPostDeleted = "Post deleted"
msgImageRemoved = "Image removed"
permAccessPosts = "golem15.journal.access_posts"
permAccessOtherPosts = "golem15.journal.access_other_posts"
permAccessPublish = "golem15.journal.access_publish"
journalCover = "golem15.journal.*"
)
func writeAPIError(w http.ResponseWriter, status int, message string) {
wire.WriteJSON(w, status, map[string]any{"error": message})
}
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": errValidationFailed,
"errors": errs,
})
}
func writeAuthRequired(w http.ResponseWriter, _ error) {
writeAPIError(w, http.StatusUnauthorized, errAuthenticationRequired)
}
func hasBearer(r *http.Request) bool {
if r == nil {
return false
}
h := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(h, "Bearer ")
return ok && strings.TrimSpace(token) != ""
}
// 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
}
writeAuthRequired(w, err)
}
// 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
}
out, _, err := cabana.ResolveBackend(app, r, journalCover)
if err != nil || out == nil {
return r
}
return out
}
// 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) {
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
if err != nil || pr == nil {
resolveError(w, err)
return r, nil, false
}
return out, pr, true
}
func isEditor(pr *bouncer.Principal) bool {
return cabana.Allows(pr, []string{permAccessPosts})
}
func requireAccessPosts(w http.ResponseWriter, pr *bouncer.Principal) bool {
if !cabana.Allows(pr, []string{permAccessPosts}) {
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
return false
}
return true
}
func gdb(app *backpack.App) (*gorm.DB, error) {
if app == nil {
return nil, errors.New("journal api: application is not configured")
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("journal api: database is not configured")
}
return db, nil
}