- 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>
157 lines
5.3 KiB
Go
157 lines
5.3 KiB
Go
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())
|
|
}
|
|
}
|