feat(16.3-01): tracer — g15_ token scopes through composite backend guard

- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal
- Append backend_api_tokens migration; Allows evaluates scope before superuser
- Composite guard routes Bearer g15_… to token lookup; smoke tests cover path

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 01:46:32 +02:00
parent 4d107f4ccb
commit b9273c1da7
12 changed files with 1190 additions and 13 deletions

View File

@@ -7,10 +7,22 @@ import (
type userKey struct{}
// ScopeLimit restricts a principal to the permission patterns of its personal
// API token. Nil means unrestricted (a JWT session). Only a token guard sets
// Scope, already filtered to scopes whose methods include the request method.
// Full is true for framework scopes that act as the owner on every allowed
// method ("*") or on GET/HEAD ("admin:read"). Otherwise Scopes holds one
// pattern slice per method-allowed scope (D-03 literal reading).
type ScopeLimit struct {
Full bool
Scopes [][]string
}
// Principal is the authenticated identity stored on the request context.
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
// IsSuperuser and PermissionGrants are set only for backend-admin principals.
// A grant ending in ".*" matches permission codes by prefix.
// Scope is set only for personal API token principals.
type Principal struct {
ID uint
MustChangePassword bool
@@ -19,6 +31,7 @@ type Principal struct {
Backend bool `json:"-"`
IsSuperuser bool `json:"-"`
PermissionGrants map[string]bool `json:"-"`
Scope *ScopeLimit `json:"-"`
}
// WithUser stores the verified principal on ctx.

View File

@@ -0,0 +1,124 @@
package cabana
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// APITokenPrefix is the fixed plaintext prefix of every personal admin API token.
const APITokenPrefix = "g15_"
// BackendAPIToken is one personal admin API token row. Only the sha256 hex of
// the plaintext and a four-character hint are stored; the plaintext is never
// persisted.
type BackendAPIToken struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
BackendUserID uint `gorm:"column:backend_user_id" json:"backend_user_id"`
Name string `gorm:"column:name" json:"name"`
TokenHash string `gorm:"column:token_hash" json:"-"`
TokenHint string `gorm:"column:token_hint" json:"hint"`
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes" json:"scopes"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"last_used_at"`
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"`
RevokedBy *uint `gorm:"column:revoked_by" json:"revoked_by"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
// TableName is the framework-owned personal API token table.
func (BackendAPIToken) TableName() string { return "backend_api_tokens" }
func mintAPIToken() (plain, hash, hint string, err error) {
var b [20]byte
if _, err = rand.Read(b[:]); err != nil {
return "", "", "", err
}
plain = APITokenPrefix + hex.EncodeToString(b[:])
hash = hashAPIToken(plain)
hint = plain[len(plain)-4:]
return plain, hash, hint, nil
}
func hashAPIToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
func wellFormedAPIToken(v string) bool {
if len(v) != len(APITokenPrefix)+40 || !strings.HasPrefix(v, APITokenPrefix) {
return false
}
for _, c := range v[len(APITokenPrefix):] {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// issueAPIToken mints a token for ownerID with the given scopes and optional
// expiry, inserts the row, and returns the plaintext exactly once. Task 3
// grows this into the shared API/CLI validation path.
func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
if db == nil || ownerID == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
}
if name == "" {
name = "token"
}
if len(scopes) == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: scopes required")
}
plain, hash, hint, err := mintAPIToken()
if err != nil {
return "", nil, err
}
row = &BackendAPIToken{
BackendUserID: ownerID,
Name: name,
TokenHash: hash,
TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
ExpiresAt: expiresAt,
}
if err := db.Create(row).Error; err != nil {
return "", nil, err
}
return plain, row, nil
}
// tokenStore looks up usable personal API tokens by hash.
type tokenStore interface {
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
}
type gormTokenStore struct {
db *gorm.DB
}
func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
if s.db == nil {
return nil, fmt.Errorf("cabana: token store has no database")
}
var row BackendAPIToken
err := s.db.WithContext(ctx).
Where("token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", hash, now).
Limit(1).
Find(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, nil
}
return &row, nil
}

View File

@@ -152,6 +152,9 @@ type Registry struct {
// fieldTypes are the plugin field types of this boot
// (pact.HasFormFieldTypes); nil when no plugin registers one.
fieldTypes *fieldTypeRegistry
// tokenScopes is the compiled personal API token catalog (* , admin:read,
// then plugin scopes in activation order).
tokenScopes *tokenScopeCatalog
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
@@ -178,15 +181,21 @@ func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
}
// Allows reports whether principal satisfies any of the required permission
// codes, the way Winter's hasAnyAccess does. A nil principal fails. Superusers
// pass. An empty requirement list allows any authenticated principal. Both
// sides may use a wildcard: a grant ending in ".*" covers every code with that
// prefix, and a required code ending in ".*" (or starting with "*") is met by
// any granted code that matches it.
// codes, the way Winter's hasAnyAccess does. A nil principal fails. When the
// principal carries a non-full personal API token scope limit, the scope is
// evaluated first (owner ∩ scope); a superuser with a scoped token is still
// limited by the scope, and an empty requirement list is refused. Otherwise
// superusers pass and an empty requirement list allows any authenticated
// principal. Both sides may use a wildcard: a grant ending in ".*" covers
// every code with that prefix, and a required code ending in ".*" (or
// starting with "*") is met by any granted code that matches it.
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.Scope != nil && !principal.Scope.Full {
return allowsUnderScope(principal, required)
}
if principal.IsSuperuser || len(required) == 0 {
return true
}

View File

@@ -1,5 +1,11 @@
package cabana
import (
"time"
"gorm.io/gorm"
)
// AdminAPIRoutes exposes the admin API route inventory (method plus
// prefix-relative path) to the external conformance test.
func AdminAPIRoutes() []string {
@@ -11,3 +17,11 @@ func AdminAPIRoutes() []string {
}
return out
}
// IssueTestAPIToken mints a personal API token for ownerID with the given
// scopes and optional expiry. Used by external Postgres smoke tests before
// the HTTP create route exists.
func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *time.Time) (plain string, err error) {
plain, _, err = issueAPIToken(db, ownerID, "test", scopes, expiresAt)
return plain, err
}

View File

@@ -125,6 +125,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if err := compileTokenScopes(reg, plugins); err != nil {
return nil, err
}
if app == nil {
return nil, errors.New("cabana: app is nil")
}
@@ -132,6 +135,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := validateMessageKeys(reg, tr); err != nil {
return nil, err
}
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
@@ -142,7 +148,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
}
bl := adminBlacklist(app)
users := lazyBackendUsers{app: app, reg: reg}
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
jwtGuard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
var store tokenStore
if gdb != nil {
store = gormTokenStore{db: gdb}
}
guard := newCompositeGuard(jwtGuard, store, users, reg)
// The admin API is only as strong as this guard (audience, secret, user
// provider), so cabana always registers its own and never mounts the API
// behind a guard another plugin already put under the name "backend": a

View File

@@ -0,0 +1,166 @@
package cabana
import (
"crypto/subtle"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// ErrUnauthenticated is returned by ResolveBackend when the request has no
// usable Bearer JWT or personal API token.
var ErrUnauthenticated = errors.New("cabana: unauthenticated")
// ErrTokenScope is returned when a personal API token is authenticated but
// its scopes do not allow the request method or cover codes.
var ErrTokenScope = errors.New("cabana: token scope")
// scopeError wraps ErrTokenScope so WriteUnauthorized can answer 403.
// errors.Is(err, ErrTokenScope) is true.
type scopeError struct {
err error
}
func (e *scopeError) Error() string {
if e == nil || e.err == nil {
return ErrTokenScope.Error()
}
return e.err.Error()
}
func (e *scopeError) Unwrap() error { return ErrTokenScope }
func newScopeError() error { return &scopeError{err: ErrTokenScope} }
// compositeGuard routes Bearer g15_… values to the personal API token path and
// every other request to the wrapped JWT guard. WriteUnauthorized answers 403
// for a scopeError and the usual 401 unauthenticated envelope otherwise.
type compositeGuard struct {
jwt bouncer.Guard
store tokenStore
users bouncer.UserProvider
reg *Registry
}
var (
_ bouncer.CredentialGuard = (*compositeGuard)(nil)
_ bouncer.UnauthorizedWriter = (*compositeGuard)(nil)
)
func newCompositeGuard(jwt bouncer.Guard, store tokenStore, users bouncer.UserProvider, reg *Registry) *compositeGuard {
return &compositeGuard{jwt: jwt, store: store, users: users, reg: reg}
}
func (g *compositeGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) {
if g == nil || r == nil {
return nil, nil, ErrUnauthenticated
}
raw := authorizationBearer(r)
if strings.HasPrefix(raw, APITokenPrefix) {
return g.authenticateToken(r, raw)
}
if g.jwt == nil {
return nil, nil, ErrUnauthenticated
}
p, err := g.jwt.Authenticate(r)
return p, nil, err
}
func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bouncer.Principal, any, error) {
if !wellFormedAPIToken(raw) {
logTokenAuth(r, "failed", 0, 0)
return nil, nil, ErrUnauthenticated
}
if g.store == nil {
logTokenAuth(r, "failed", 0, 0)
return nil, nil, ErrUnauthenticated
}
now := time.Now().UTC()
hash := hashAPIToken(raw)
row, err := g.store.FindUsableByHash(r.Context(), hash, now)
if err != nil || row == nil {
logTokenAuth(r, "failed", 0, 0)
return nil, nil, ErrUnauthenticated
}
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
logTokenAuth(r, "failed", 0, row.ID)
return nil, nil, ErrUnauthenticated
}
if g.users == nil {
logTokenAuth(r, "failed", 0, row.ID)
return nil, nil, ErrUnauthenticated
}
principal, err := g.users.FindByID(r.Context(), row.BackendUserID)
if err != nil || principal == nil {
logTokenAuth(r, "failed", row.BackendUserID, row.ID)
return nil, nil, ErrUnauthenticated
}
stored := row.Scopes.Get()
if !row.Scopes.Valid {
stored = nil
}
limit, known, methodOK := methodFilterScopes(g.reg.tokenScopes, stored, r.Method)
if known == 0 {
logTokenAuth(r, "failed", principal.ID, row.ID)
return nil, nil, ErrUnauthenticated
}
if methodOK == 0 {
logTokenAuth(r, "denied", principal.ID, row.ID)
return nil, nil, newScopeError()
}
principal.Scope = limit
logTokenAuth(r, "success", principal.ID, row.ID)
return principal, row, nil
}
// WriteUnauthorized answers 403 forbidden with details.reason token_scope for
// a scopeError; every other failure uses the 401 unauthenticated envelope.
func (g *compositeGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
if errors.Is(err, ErrTokenScope) {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
"reason": "token_scope",
})
return
}
writeUnauthenticated(w, err)
}
func authorizationBearer(r *http.Request) string {
if r == nil {
return ""
}
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
if !ok {
return ""
}
return strings.TrimSpace(token)
}
func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) {
remote := ""
method := ""
path := ""
if r != nil {
method = r.Method
if r.URL != nil {
path = r.URL.Path
}
remote = r.RemoteAddr
if host, _, err := net.SplitHostPort(remote); err == nil {
remote = host
}
}
args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote}
if adminID != 0 {
args = append(args, "admin_id", adminID)
}
if tokenID != 0 {
args = append(args, "token_id", tokenID)
}
slog.Default().Info("admin.auth", args...)
}

View File

@@ -0,0 +1,156 @@
package cabana_test
import (
"encoding/json"
"io/fs"
"net/http"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"os"
"path/filepath"
)
type tokenDemoWidget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (tokenDemoWidget) TableName() string { return "cabana_token_demo_widgets" }
func (tokenDemoWidget) Fillable() []string { return []string{"name"} }
func (tokenDemoWidget) Rules() map[string]string { return map[string]string{} }
type tokenDemoPlugin struct{ fsys fs.FS }
func (p tokenDemoPlugin) ID() string { return "acme.demo" }
func (p tokenDemoPlugin) Requires() []string { return nil }
func (p tokenDemoPlugin) Register(*backpack.App) error { return nil }
func (p tokenDemoPlugin) Boot(*backpack.App) error { return nil }
func (p tokenDemoPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{tokenDemoController{}}
}
func (p tokenDemoPlugin) Models() []any { return []any{&tokenDemoWidget{}} }
func (p tokenDemoPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.reports", Roles: []string{"developer"}},
}
}
func (p tokenDemoPlugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "demo", Label: "Demo", Description: "Full demo access",
Permissions: []string{"acme.demo.*"}, Methods: all,
},
{
Name: "demo:reports", Label: "Demo reports", Description: "Reports only",
Permissions: []string{"acme.demo.reports"}, Methods: []string{"GET", "HEAD"},
},
}
}
func (p tokenDemoPlugin) AdminFS() fs.FS { return p.fsys }
type tokenDemoController struct{}
func (tokenDemoController) ID() string { return "acme.demo.widgets" }
func (tokenDemoController) ModelName() string { return "Widget" }
func (tokenDemoController) ConfigDir() string { return "controllers/widgets" }
func (tokenDemoController) NewRecord() any { return &tokenDemoWidget{} }
func (tokenDemoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func tokenDemoFS() fs.FS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
}
}
func tokenAdminHandler(t *testing.T) http.Handler {
t.Helper()
gdb := adminGorm(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-token\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
},
})
if err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, []party.Plugin{tokenDemoPlugin{fsys: tokenDemoFS()}})
if err != nil {
t.Fatal(err)
}
return h
}
func TestTokenTracerPostgres(t *testing.T) {
gdb := adminGorm(t)
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
t.Fatal(err)
}
h := tokenAdminHandler(t)
user := insertAdmin(t, gdb, "tok-dev", "tok-dev@example.test", "secret-pass", true, false)
demoPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
if err != nil {
t.Fatal(err)
}
reportsPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo:reports"}, nil)
if err != nil {
t.Fatal(err)
}
listPath := adminAPI("/acme/demo/widgets")
if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusOK {
t.Fatalf("demo list: %d %s", rec.Code, rec.Body.String())
}
if rec := getAuth(t, h, listPath, reportsPlain); rec.Code != http.StatusForbidden {
t.Fatalf("reports list: want 403, got %d %s", rec.Code, rec.Body.String())
}
if rec := postAuth(t, h, http.MethodPost, listPath, reportsPlain, []byte(`{}`)); rec.Code != http.StatusForbidden {
t.Fatalf("reports POST: want 403, got %d %s", rec.Code, rec.Body.String())
} else {
var body struct {
Error struct {
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Details["reason"] != "token_scope" {
t.Fatalf("reason = %v body=%s", body.Error.Details["reason"], rec.Body.String())
}
}
if err := gdb.Delete(&user).Error; err != nil {
t.Fatal(err)
}
if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusUnauthorized {
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
}
}

View File

@@ -0,0 +1,413 @@
package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
const (
scopeAll = "*"
scopeAdminRead = "admin:read"
)
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
var allowedTokenMethods = map[string]bool{
"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
type compiledTokenScope struct {
Name string
Label string
Description string
Permissions []string
Methods map[string]bool
Full bool // framework scopes * and admin:read
PluginID string
}
type tokenScopeCatalog struct {
ordered []*compiledTokenScope
byName map[string]*compiledTokenScope
}
const (
matchNone = iota
matchPartial
matchFull
)
func compileTokenScopes(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
cat := &tokenScopeCatalog{
byName: map[string]*compiledTokenScope{},
}
// Framework scopes first.
allMethods := map[string]bool{}
for m := range allowedTokenMethods {
allMethods[m] = true
}
cat.ordered = append(cat.ordered, &compiledTokenScope{
Name: scopeAll,
Label: "backend::lang.api_tokens.scope_all",
Description: "backend::lang.api_tokens.scope_all_description",
Methods: allMethods,
Full: true,
})
cat.byName[scopeAll] = cat.ordered[0]
cat.ordered = append(cat.ordered, &compiledTokenScope{
Name: scopeAdminRead,
Label: "backend::lang.api_tokens.scope_admin_read",
Description: "backend::lang.api_tokens.scope_admin_read_description",
Methods: map[string]bool{"GET": true, "HEAD": true},
Full: true,
})
cat.byName[scopeAdminRead] = cat.ordered[1]
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasTokenScopes)
if !ok || src == nil {
continue
}
for _, scope := range src.TokenScopes() {
if err := validatePluginTokenScope(reg, plugin.ID(), scope, cat); err != nil {
return err
}
methods := map[string]bool{}
for _, m := range scope.Methods {
methods[m] = true
}
perms := append([]string(nil), scope.Permissions...)
compiled := &compiledTokenScope{
Name: scope.Name,
Label: scope.Label,
Description: scope.Description,
Permissions: perms,
Methods: methods,
PluginID: plugin.ID(),
}
cat.ordered = append(cat.ordered, compiled)
cat.byName[scope.Name] = compiled
}
}
reg.tokenScopes = cat
return nil
}
func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error {
name := strings.TrimSpace(scope.Name)
if name == "" || !tokenScopeNameRE.MatchString(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") {
return fmt.Errorf("cabana: plugin %s registered reserved token scope name %q", pluginID, name)
}
if existing, ok := cat.byName[name]; ok {
other := existing.PluginID
if other == "" {
other = "framework"
}
return fmt.Errorf("cabana: duplicate token scope %q registered by %s and %s", name, other, pluginID)
}
if strings.TrimSpace(scope.Label) == "" {
return fmt.Errorf("cabana: plugin %s token scope %s has an empty label", pluginID, name)
}
if strings.TrimSpace(scope.Description) == "" {
return fmt.Errorf("cabana: plugin %s token scope %s has an empty description", pluginID, name)
}
if len(scope.Permissions) == 0 {
return fmt.Errorf("cabana: plugin %s token scope %s has empty permissions", pluginID, name)
}
if len(scope.Methods) == 0 {
return fmt.Errorf("cabana: plugin %s token scope %s has empty methods", pluginID, name)
}
hasGET := false
seen := map[string]bool{}
for _, m := range scope.Methods {
if m != strings.ToUpper(m) || !allowedTokenMethods[m] {
return fmt.Errorf("cabana: plugin %s token scope %s has invalid method %q", pluginID, name, m)
}
if seen[m] {
continue
}
seen[m] = true
if m == "GET" {
hasGET = true
}
}
if !hasGET {
return fmt.Errorf("cabana: plugin %s token scope %s methods must include GET", pluginID, name)
}
for _, code := range scope.Permissions {
if !permissionCode(code, true) || !reg.permissionExists(code) {
return fmt.Errorf("cabana: plugin %s token scope %s names unknown permission %q", pluginID, name, code)
}
}
return nil
}
func validateTokenScopeMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
if reg == nil || reg.tokenScopes == nil || tr == nil {
return nil
}
for _, scope := range reg.tokenScopes.ordered {
// Framework scopes use backend::lang.api_tokens.* keys added with the
// account token API; only plugin scopes are checked at boot here.
if scope.PluginID == "" {
continue
}
for _, text := range []string{scope.Label, scope.Description} {
if strings.Contains(text, "::") && !tr.Has(text) {
return fmt.Errorf("cabana: token scope %s (%s) names missing phrase key %s", scope.Name, scope.PluginID, text)
}
}
}
return nil
}
// scopeMatch reports how pattern covers required (plan match table).
func scopeMatch(pattern, required string) int {
pattern = strings.TrimSpace(pattern)
required = strings.TrimSpace(required)
if pattern == "" || required == "" {
return matchNone
}
patLeading := len(pattern) > 1 && strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, ".*")
reqLeading := len(required) > 1 && strings.HasPrefix(required, "*") && !strings.HasSuffix(required, ".*")
patSuffixWild := strings.HasSuffix(pattern, ".*")
reqSuffixWild := strings.HasSuffix(required, ".*")
// Leading *.suffix: exact required only → partial via the pattern; any
// wildcard required or pattern → none (conservative).
if patLeading {
if reqSuffixWild || reqLeading {
return matchNone
}
suffix := strings.TrimPrefix(pattern, "*")
if strings.HasSuffix(required, suffix) && required != suffix {
return matchPartial
}
return matchNone
}
if reqLeading {
return matchNone
}
switch {
case !patSuffixWild && !reqSuffixWild:
if pattern == required {
return matchFull
}
return matchNone
case patSuffixWild && !reqSuffixWild:
patPrefix := strings.TrimSuffix(pattern, "*")
if patPrefix != required && strings.HasPrefix(required, patPrefix) {
return matchFull
}
return matchNone
case !patSuffixWild && reqSuffixWild:
reqPrefix := strings.TrimSuffix(required, "*")
if reqPrefix != pattern && strings.HasPrefix(pattern, reqPrefix) {
return matchPartial
}
return matchNone
default:
patPrefix := strings.TrimSuffix(pattern, "*")
reqPrefix := strings.TrimSuffix(required, "*")
if patPrefix == reqPrefix || strings.HasPrefix(reqPrefix, patPrefix) {
return matchFull
}
if strings.HasPrefix(patPrefix, reqPrefix) {
return matchPartial
}
return matchNone
}
}
func ownerHas(p *bouncer.Principal, code string) bool {
if p == nil {
return false
}
return p.IsSuperuser || granted(p.PermissionGrants, code)
}
// allowsUnderScope implements D-03 literal reading: some method-allowed scope
// matches every required code, and some required code is owner-held inside it.
func allowsUnderScope(p *bouncer.Principal, required []string) bool {
if p == nil || p.Scope == nil || p.Scope.Full {
return false
}
if len(required) == 0 {
return false
}
for _, patterns := range p.Scope.Scopes {
if scopeSliceAllows(p, patterns, required) {
return true
}
}
return false
}
func scopeSliceAllows(p *bouncer.Principal, patterns, required []string) bool {
type hit struct {
kind int
pattern string
}
hits := make([]hit, len(required))
for i, code := range required {
best := matchNone
bestPat := ""
for _, pattern := range patterns {
kind := scopeMatch(pattern, code)
if kind > best {
best = kind
bestPat = pattern
}
}
if best == matchNone {
return false
}
hits[i] = hit{kind: best, pattern: bestPat}
}
for i, code := range required {
h := hits[i]
switch h.kind {
case matchFull:
if ownerHas(p, code) {
return true
}
case matchPartial:
if ownerHas(p, h.pattern) {
return true
}
}
}
return false
}
// scopeCoversAll reports whether some scope slice matches every cover code
// (full or partial). Used by ResolveBackend (Task 2).
func scopeCoversAll(limit *bouncer.ScopeLimit, cover []string) bool {
if limit == nil || limit.Full {
return true
}
if len(cover) == 0 {
return false
}
for _, patterns := range limit.Scopes {
ok := true
for _, code := range cover {
matched := false
for _, pattern := range patterns {
if scopeMatch(pattern, code) != matchNone {
matched = true
break
}
}
if !matched {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
// scopeOverlaps is the D-17 picker/issue rule (Task 3 grows callers).
func scopeOverlaps(cat *tokenScopeCatalog, owner *bouncer.Principal, name string) bool {
if cat == nil || owner == nil {
return false
}
if name == scopeAll {
return true
}
scope, ok := cat.byName[name]
if !ok {
return false
}
if scope.Full {
// admin:read: offered when superuser or holds at least one catalog code.
if owner.IsSuperuser {
return true
}
for code := range catPermissionCodes(cat) {
if ownerHas(owner, code) {
return true
}
}
// Fall through: also check against registry permissions via owner grants.
for code := range owner.PermissionGrants {
if owner.PermissionGrants[code] {
return true
}
}
return owner.IsSuperuser
}
for _, c := range scope.Permissions {
if scopeMatch(c, c) == matchFull && ownerHas(owner, c) {
return true
}
// Some registered catalog code has a full match in the scope and ownerHas(c).
_ = c
}
return false
}
func catPermissionCodes(cat *tokenScopeCatalog) map[string]bool {
out := map[string]bool{}
if cat == nil {
return out
}
for _, scope := range cat.ordered {
for _, p := range scope.Permissions {
out[p] = true
}
}
return out
}
func methodFilterScopes(cat *tokenScopeCatalog, stored []string, method string) (limit *bouncer.ScopeLimit, known int, methodOK int) {
if cat == nil {
return nil, 0, 0
}
method = strings.ToUpper(method)
var scopes [][]string
full := false
for _, name := range stored {
scope, ok := cat.byName[name]
if !ok {
continue
}
known++
if !scope.Methods[method] {
continue
}
methodOK++
if scope.Full {
full = true
continue
}
scopes = append(scopes, append([]string(nil), scope.Permissions...))
}
if known == 0 {
return nil, 0, 0
}
if methodOK == 0 {
return nil, known, 0
}
return &bouncer.ScopeLimit{Full: full, Scopes: scopes}, known, methodOK
}

View File

@@ -0,0 +1,209 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
type memTokenStore struct {
mu sync.Mutex
byHash map[string]*BackendAPIToken
}
func (s *memTokenStore) FindUsableByHash(_ context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
row := s.byHash[hash]
if row == nil {
return nil, nil
}
if row.RevokedAt != nil {
return nil, nil
}
if row.ExpiresAt != nil && !row.ExpiresAt.After(now) {
return nil, nil
}
cp := *row
return &cp, nil
}
func (s *memTokenStore) put(row *BackendAPIToken) {
s.mu.Lock()
defer s.mu.Unlock()
if s.byHash == nil {
s.byHash = map[string]*BackendAPIToken{}
}
s.byHash[row.TokenHash] = row
}
type stubUsers struct {
byID map[uint]*bouncer.Principal
}
func (s stubUsers) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) {
if s.byID == nil {
return nil, nil
}
p := s.byID[id]
if p == nil {
return nil, nil
}
cp := *p
if p.PermissionGrants != nil {
cp.PermissionGrants = map[string]bool{}
for k, v := range p.PermissionGrants {
cp.PermissionGrants[k] = v
}
}
return &cp, nil
}
type stubJWTGuard struct {
called bool
}
func (g *stubJWTGuard) Authenticate(r *http.Request) (*bouncer.Principal, error) {
g.called = true
return &bouncer.Principal{ID: 99, Backend: true, IsSuperuser: true}, nil
}
func tracerCatalog() *tokenScopeCatalog {
cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}}
all := map[string]bool{"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true}
cat.ordered = []*compiledTokenScope{
{Name: scopeAll, Methods: all, Full: true},
{Name: scopeAdminRead, Methods: map[string]bool{"GET": true, "HEAD": true}, Full: true},
{
Name: "demo",
Permissions: []string{"acme.demo.*"},
Methods: all,
PluginID: "acme.demo",
},
{
Name: "demo:reports",
Permissions: []string{"acme.demo.reports"},
Methods: map[string]bool{"GET": true, "HEAD": true},
PluginID: "acme.demo",
},
}
for _, s := range cat.ordered {
cat.byName[s.Name] = s
}
return cat
}
func TestTokenTracerSmoke(t *testing.T) {
cat := tracerCatalog()
reg := &Registry{tokenScopes: cat}
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
dev := &bouncer.Principal{
ID: 1,
Backend: true,
PermissionGrants: map[string]bool{
"acme.demo.access": true,
"acme.demo.reports": true,
},
}
super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true}
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev, 2: super}}
jwt := &stubJWTGuard{}
guard := newCompositeGuard(jwt, store, users, reg)
mint := func(owner uint, scopes []string) string {
plain, hash, hint, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
store.put(&BackendAPIToken{
ID: uint(len(store.byHash) + 1),
BackendUserID: owner,
Name: "t",
TokenHash: hash,
TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
})
return plain
}
guards := bouncer.NewRegistry()
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
t.Fatal(err)
}
mw, err := guards.Middleware("backend")
if err != nil {
t.Fatal(err)
}
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, _ := bouncer.User(r.Context())
if Allows(p, []string{"acme.demo.access"}) {
w.WriteHeader(http.StatusOK)
return
}
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
}))
req := func(method, token string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/v1/widgets", nil)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
demoTok := mint(1, []string{"demo"})
if rec := req(http.MethodGet, demoTok); rec.Code != http.StatusOK {
t.Fatalf("demo GET: %d %s", rec.Code, rec.Body.String())
}
reportsTok := mint(1, []string{"demo:reports"})
if rec := req(http.MethodGet, reportsTok); rec.Code != http.StatusForbidden {
t.Fatalf("demo:reports GET: want 403, got %d %s", rec.Code, rec.Body.String())
}
if rec := req(http.MethodPost, reportsTok); rec.Code != http.StatusForbidden {
t.Fatalf("demo:reports POST: want 403, got %d", rec.Code)
} else {
var body struct {
Error struct {
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Details["reason"] != "token_scope" {
t.Fatalf("reason = %v", body.Error.Details["reason"])
}
}
superTok := mint(2, []string{"demo:reports"})
if rec := req(http.MethodGet, superTok); rec.Code != http.StatusForbidden {
t.Fatalf("superuser demo:reports: want 403, got %d", rec.Code)
}
if rec := req(http.MethodGet, "g15_x"); rec.Code != http.StatusUnauthorized {
t.Fatalf("malformed: want 401, got %d", rec.Code)
}
jwt.called = false
if rec := req(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); rec.Code != http.StatusOK {
t.Fatalf("jwt path: want 200 from stub, got %d", rec.Code)
}
if !jwt.called {
t.Fatal("jwt guard was not called for non-g15_ bearer")
}
if !strings.HasPrefix(demoTok, APITokenPrefix) || len(demoTok) != 44 {
t.Fatalf("mint shape %q", demoTok)
}
}

View File

@@ -14,10 +14,11 @@ const BackendJWTBlacklistTable = "backend_jwt_blacklist"
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
// the developer and publisher system roles, makes backend user emails
// unique case-insensitively and adds WinterCMS's nullable jsonable metadata
// column to backend_users (the admin subtitle lives there). History is isolated under the summercms.cabana
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
// the history row is removed.
// unique case-insensitively, adds WinterCMS's nullable jsonable metadata
// column to backend_users (the admin subtitle lives there), and creates
// backend_api_tokens for personal admin API tokens. History is isolated under
// the summercms.cabana plugin id. DDL is re-runnable so the system-role seed
// stays idempotent if the history row is removed.
var BackendAdminMigrations = []*gormigrate.Migration{
{
ID: "202609240001_backend_admin_identity",
@@ -130,4 +131,35 @@ FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
},
},
{
// Personal admin API tokens (sha256 of g15_… only; plaintext never stored).
ID: "202610110001_backend_api_tokens",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE IF NOT EXISTS backend_api_tokens (
id BIGSERIAL PRIMARY KEY,
backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
token_hint TEXT NOT NULL,
scopes TEXT NOT NULL,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error
},
},
}

View File

@@ -52,7 +52,7 @@ func TestPhase09MigrationsFreshRollback(t *testing.T) {
t.Fatal(err)
}
rollbackBackendAdmin(t, admin)
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} {
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist", "backend_api_tokens"} {
if gdb.Migrator().HasTable(name) {
t.Fatalf("%s survived admin rollback", name)
}
@@ -558,10 +558,20 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
if err := gdb.Table(history).Order("id").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if len(ids) == 0 || ids[len(ids)-1] != "202610090001_backend_users_metadata" {
if len(ids) == 0 || ids[len(ids)-1] != "202610110001_backend_api_tokens" {
t.Fatalf("cabana history = %v", ids)
}
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610090001_backend_users_metadata" {
foundMeta := false
for _, id := range ids {
if id == "202610090001_backend_users_metadata" {
foundMeta = true
break
}
}
if !foundMeta {
t.Fatalf("cabana history missing metadata migration: %v", ids)
}
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610110001_backend_api_tokens" {
t.Fatalf("last BackendAdminMigrations entry = %s", last)
}
@@ -569,6 +579,11 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Newest is backend_api_tokens; roll it back, then metadata, so the
// metadata column is gone while the email index remains.
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -505,6 +505,21 @@ type HasPermissions interface {
Permissions() []Permission
}
// TokenScope is one personal API token scope a plugin registers at boot.
// Label and Description are phrase keys (containing "::") or literal text.
type TokenScope struct {
Name string
Label string
Description string
Permissions []string
Methods []string
}
// HasTokenScopes is implemented by plugins that declare personal API token scopes.
type HasTokenScopes interface {
TokenScopes() []TokenScope
}
// NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID.
type NavigationItem struct {