feat(09-11): add permissioned admin metadata and settings

This commit is contained in:
Jakub Zych
2026-09-26 23:06:22 +02:00
parent 8b02fff028
commit 10ca7a02e3
12 changed files with 947 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ type Principal struct {
MustChangePassword bool MustChangePassword bool
PreferredLocale string PreferredLocale string
TokensValidAfter time.Time TokensValidAfter time.Time
Backend bool `json:"-"`
IsSuperuser bool `json:"-"` IsSuperuser bool `json:"-"`
PermissionGrants map[string]bool `json:"-"` PermissionGrants map[string]bool `json:"-"`
} }

View File

@@ -30,7 +30,8 @@ const (
// BackendUsers loads activated backend principals. It never reads frontend users. // BackendUsers loads activated backend principals. It never reads frontend users.
type BackendUsers struct { type BackendUsers struct {
DB *gorm.DB DB *gorm.DB
Registry *Registry
} }
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
@@ -48,12 +49,23 @@ func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal
if !user.IsActivated { if !user.IsActivated {
return nil, nil return nil, nil
} }
return principalFrom(user), nil principal := principalFrom(user)
for code, allowed := range p.Registry.rolePermissions(user.Role.Code) {
if !allowed {
continue
}
if principal.PermissionGrants == nil {
principal.PermissionGrants = map[string]bool{}
}
principal.PermissionGrants[code] = true
}
return principal, nil
} }
func principalFrom(user BackendUser) *bouncer.Principal { func principalFrom(user BackendUser) *bouncer.Principal {
principal := &bouncer.Principal{ principal := &bouncer.Principal{
ID: user.ID, ID: user.ID,
Backend: true,
IsSuperuser: user.IsSuperuser, IsSuperuser: user.IsSuperuser,
PermissionGrants: parseGrants(user.Role.Permissions), PermissionGrants: parseGrants(user.Role.Permissions),
} }

View File

@@ -205,6 +205,13 @@ func TestAdminAuthLogging(t *testing.T) {
gdb := adminGorm(t) gdb := adminGorm(t)
h := adminHandler(t, gdb, nil) h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false) user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
var publisherID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
}
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
t.Fatal(err)
}
var buf bytes.Buffer var buf bytes.Buffer
prev := slog.Default() prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
@@ -436,6 +443,9 @@ func (p demoPlugin) Boot(*backpack.App) error { return nil }
func (p demoPlugin) AdminControllers() []pact.AdminController { func (p demoPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{demoController{}} return []pact.AdminController{demoController{}}
} }
func (p demoPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
}
func (p demoPlugin) AdminFS() fs.FS { return p.fsys } func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
type demoController struct{} type demoController struct{}

View File

@@ -75,7 +75,11 @@ type CompiledController struct {
// Registry is the immutable controller map keyed by controller ID. // Registry is the immutable controller map keyed by controller ID.
type Registry struct { type Registry struct {
byID map[string]*CompiledController byID map[string]*CompiledController
permissions map[string]pact.Permission
roleGrants map[string]map[string]bool
navigation []pact.NavigationItem
settings map[string]*CompiledSetting
} }
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller). // Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
@@ -87,6 +91,15 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
return cc, ok && cc != nil return cc, ok && cc != nil
} }
// Setting returns one compiled singleton setting by stable code.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil {
return nil, false
}
setting, ok := r.settings[code]
return setting, ok && setting != nil
}
// Allows reports whether principal satisfies every required permission code. // Allows reports whether principal satisfies every required permission code.
// A nil principal fails. Superusers pass. An empty requirement list allows // A nil principal fails. Superusers pass. An empty requirement list allows
// any authenticated principal. Grants ending in ".*" match by prefix. // any authenticated principal. Grants ending in ".*" match by prefix.

View File

@@ -394,13 +394,14 @@ func hookFixture(t *testing.T) (CRUDService, *service, *CompiledController, *gor
func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context { func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context {
ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log) ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log)
if principal != nil { if principal != nil {
principal.Backend = true
ctx = bouncer.WithUser(ctx, principal) ctx = bouncer.WithUser(ctx, principal)
} }
return ctx return ctx
} }
func superUser() *bouncer.Principal { func superUser() *bouncer.Principal {
return &bouncer.Principal{ID: 1, IsSuperuser: true} return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
} }
func crudCall(svc *service, method, id string, body []byte, ctx context.Context) *httptest.ResponseRecorder { func crudCall(svc *service, method, id string, body []byte, ctx context.Context) *httptest.ResponseRecorder {

View File

@@ -58,6 +58,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if app == nil { if app == nil {
return nil, errors.New("cabana: app is nil") return nil, errors.New("cabana: app is nil")
} }
@@ -69,7 +72,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
} }
} }
bl := adminBlacklist(app) bl := adminBlacklist(app)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated) guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
if _, err := guards.Middleware("backend"); err != nil { if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil { if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err return nil, err
@@ -102,6 +105,7 @@ func writeUnauthenticated(w http.ResponseWriter, _ error) {
type lazyBackendUsers struct { type lazyBackendUsers struct {
app *backpack.App app *backpack.App
reg *Registry
} }
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
@@ -112,7 +116,7 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
if !ok || db == nil { if !ok || db == nil {
return nil, errors.New("cabana: database is not configured") return nil, errors.New("cabana: database is not configured")
} }
return (BackendUsers{DB: db}).FindByID(ctx, id) return (BackendUsers{DB: db, Registry: p.reg}).FindByID(ctx, id)
} }
func (s *service) mount(r pact.Router) { func (s *service) mount(r pact.Router) {
@@ -124,6 +128,14 @@ func (s *service) mount(r pact.Router) {
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", s.logout) g.Post("/auth/logout", s.logout)
g.Get("/auth/me", s.me) g.Get("/auth/me", s.me)
g.Get("/navigation", s.navigation)
g.Get("/settings", s.settingsList)
g.Get("/settings/{code}/schema", s.settingsSchema)
constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet)
constrainSetting(g)
g.Put("/settings/{code}", s.settingsPut)
constrainSetting(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g) constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema) g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
@@ -164,6 +176,96 @@ func constrainRelation(g pact.Router) {
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
} }
func constrainSetting(g pact.Router) {
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
}
func (s *service) navigation(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
navigation, _ := s.reg.Metadata(r.Context(), principal, s.translator())
WriteData(w, http.StatusOK, navigation, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
}
func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
_, settings := s.reg.Metadata(r.Context(), principal, s.translator())
WriteData(w, http.StatusOK, settings, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
}
func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
view, err := setting.Form.Localize(r.Context(), s.translator(), nil)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, view, map[string]any{"locale": view.Meta.Locale})
})
}
func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db}).Get(r.Context(), setting)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func (s *service) protectSetting(w http.ResponseWriter, r *http.Request, fn func(*CompiledSetting)) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
setting, exists := s.reg.Setting(r.PathValue("code"))
if !exists {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !Allows(principal, setting.Item.Permissions) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fn(setting)
}
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) { func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) {
cr, err := relationOf(cc, r.PathValue("name")) cr, err := relationOf(cc, r.PathValue("name"))
@@ -495,7 +597,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
return return
} }
principal, _ := bouncer.User(r.Context()) principal, _ := bouncer.User(r.Context())
if principal == nil { if principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return return
} }

View File

@@ -0,0 +1,213 @@
package cabana
import (
"context"
"errors"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
"gorm.io/gorm"
)
type contributionPlugin struct {
id string
permissions []pact.Permission
navigation []pact.NavigationItem
settings []pact.SettingsItem
fsys fs.FS
}
func (p contributionPlugin) ID() string { return p.id }
func (p contributionPlugin) Requires() []string { return nil }
func (p contributionPlugin) Register(*backpack.App) error { return nil }
func (p contributionPlugin) Boot(*backpack.App) error { return nil }
func (p contributionPlugin) Permissions() []pact.Permission { return p.permissions }
func (p contributionPlugin) Navigation() []pact.NavigationItem { return p.navigation }
func (p contributionPlugin) Settings() []pact.SettingsItem { return p.settings }
func (p contributionPlugin) AdminFS() fs.FS { return p.fsys }
type metadataController struct{ id string }
func (c metadataController) ID() string { return c.id }
func (metadataController) ModelName() string { return "Metadata" }
func (metadataController) ConfigDir() string { return "controllers/metadata" }
func (metadataController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
type singletonSetting struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (singletonSetting) TableName() string { return "cabana_singleton_settings" }
func (singletonSetting) Fillable() []string { return []string{"enabled"} }
func (singletonSetting) Rules() map[string]string { return map[string]string{"enabled": "boolean"} }
func TestMetadataPermissionRegistry(t *testing.T) {
plugin := metadataPlugin()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
t.Fatal(err)
}
grants := reg.rolePermissions("developer")
if !grants["acme.demo.access"] || !grants["acme.demo.manage_settings"] {
t.Fatalf("developer grants = %#v", grants)
}
if grants := reg.rolePermissions("publisher"); len(grants) != 0 {
t.Fatalf("publisher grants = %#v", grants)
}
bad := metadataRegistry()
bad.byID["acme.demo.widgets"].Controller = metadataController{id: "acme.demo.widgets"}
bad.byID["acme.demo.widgets"].Controller = unknownPermissionController{}
if err := compileContributions(bad, []party.Plugin{plugin}); err == nil || !strings.Contains(err.Error(), "unknown permission") {
t.Fatalf("unknown permission error = %v", err)
}
duplicate := contributionPlugin{id: "other.demo", permissions: plugin.permissions}
if err := compileContributions(metadataRegistry(), []party.Plugin{plugin, duplicate}); err == nil || !strings.Contains(err.Error(), "duplicate permission") {
t.Fatalf("duplicate permission error = %v", err)
}
}
func TestMetadataFiltering(t *testing.T) {
plugin := metadataPlugin()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
t.Fatal(err)
}
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
navigation, settings := reg.Metadata(context.Background(), developer, nil)
if len(navigation) != 1 || len(navigation[0].SideMenu) != 1 || navigation[0].Code != "demo" || navigation[0].SideMenu[0].Code != "widgets" {
t.Fatalf("developer navigation = %#v", navigation)
}
if len(settings) != 1 || settings[0].Code != "demo" || !reflect.DeepEqual(settings[0].Keywords, []string{"demo", "settings"}) {
t.Fatalf("developer settings = %#v", settings)
}
denied := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{}}
navigation, settings = reg.Metadata(context.Background(), denied, nil)
if navigation == nil || settings == nil || len(navigation) != 0 || len(settings) != 0 {
t.Fatalf("denied metadata = %#v %#v", navigation, settings)
}
frontend := &bouncer.Principal{ID: 1, PermissionGrants: reg.rolePermissions("developer")}
navigation, settings = reg.Metadata(context.Background(), frontend, nil)
if len(navigation) != 0 || len(settings) != 0 {
t.Fatalf("frontend principal leaked metadata = %#v %#v", navigation, settings)
}
}
func TestSettingsSingletonLifecycle(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&singletonSetting{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&singletonSetting{}); err != nil {
t.Fatal(err)
}
setting, err := compileSetting("acme.demo", metadataPlugin().settings[0], metadataFS())
if err != nil {
t.Fatal(err)
}
svc := SettingsService{DB: db}
missing, err := svc.Get(context.Background(), setting)
if err != nil || missing.Exists || missing.Data["enabled"] != false {
t.Fatalf("missing = %#v err=%v", missing, err)
}
assertSettingCount(t, db, 0)
if _, err := svc.Put(context.Background(), setting, map[string]any{}); err == nil {
t.Fatal("missing required setting succeeded")
}
created, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true, "id": 99})
if err != nil || !created.Exists || created.Data["enabled"] != true {
t.Fatalf("created = %#v err=%v", created, err)
}
assertSettingCount(t, db, 1)
var before singletonSetting
if err := db.First(&before, 1).Error; err != nil {
t.Fatal(err)
}
replayed, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true})
if err != nil || replayed.Data["enabled"] != true {
t.Fatalf("replay = %#v err=%v", replayed, err)
}
var after singletonSetting
if err := db.First(&after, 1).Error; err != nil {
t.Fatal(err)
}
if !after.UpdatedAt.Equal(before.UpdatedAt) || after.ID != 1 {
t.Fatalf("replay mutated singleton before=%+v after=%+v", before, after)
}
if _, err := svc.Put(context.Background(), setting, map[string]any{"enabled": "not-bool"}); err == nil {
t.Fatal("invalid boolean succeeded")
} else {
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("invalid boolean error = %T %v", err, err)
}
}
var preserved singletonSetting
if err := db.First(&preserved, 1).Error; err != nil || !preserved.Enabled {
t.Fatalf("failed update was not rolled back: %+v err=%v", preserved, err)
}
}
type unknownPermissionController struct{}
func (unknownPermissionController) ID() string { return "acme.demo.widgets" }
func (unknownPermissionController) ModelName() string { return "Metadata" }
func (unknownPermissionController) ConfigDir() string { return "controllers/metadata" }
func (unknownPermissionController) RequiredPermissions() []string {
return []string{"acme.demo.unknown"}
}
func metadataRegistry() *Registry {
return &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: metadataController{id: "acme.demo.widgets"}, Relations: map[string]*CompiledRelation{}},
}}
}
func metadataPlugin() contributionPlugin {
return contributionPlugin{
id: "acme.demo",
permissions: []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.manage_settings", Roles: []string{"developer"}},
},
navigation: []pact.NavigationItem{{
Code: "demo", Label: "Demo", Icon: "icon", Order: 20, Controller: "acme.demo.widgets",
Permissions: []string{"acme.demo.*"},
SideMenu: []pact.NavigationItem{{Code: "widgets", Label: "Widgets", Controller: "acme.demo.widgets", Permissions: []string{"acme.demo.access"}}},
}},
settings: []pact.SettingsItem{{
Code: "demo", Label: "Settings", Description: "Description", Category: "Demo", Icon: "icon-cog",
Model: "Settings", Order: 20, Keywords: []string{"demo", "settings"}, Permissions: []string{"acme.demo.manage_settings"},
Form: "models/settings/fields.yaml", NewModel: func() any { return &singletonSetting{} },
}},
fsys: metadataFS(),
}
}
func metadataFS() fs.FS {
return fstest.MapFS{"models/settings/fields.yaml": {Data: []byte("fields:\n enabled:\n type: switch\n default: 0\n required: true\n")}}
}
func assertSettingCount(t *testing.T, db *gorm.DB, want int64) {
t.Helper()
var got int64
if err := db.Model(&singletonSetting{}).Count(&got).Error; err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("settings count=%d want=%d", got, want)
}
}

94
cabana/navigation.go Normal file
View File

@@ -0,0 +1,94 @@
package cabana
import (
"context"
"sort"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/phrasebook"
)
// NavigationEntry is one permission-filtered backend navigation item.
type NavigationEntry struct {
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Order int `json:"order"`
Controller string `json:"controller"`
SideMenu []NavigationEntry `json:"sideMenu"`
}
// SettingsEntry is one permission-filtered settings-list item.
type SettingsEntry struct {
Code string `json:"code"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Icon string `json:"icon"`
Order int `json:"order"`
Keywords []string `json:"keywords"`
Model string `json:"model"`
}
// Metadata returns only entries the backend principal may open. Denied
// entries are removed before any response value is constructed.
func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, tr *phrasebook.Translator) ([]NavigationEntry, []SettingsEntry) {
navigation := make([]NavigationEntry, 0)
settings := make([]SettingsEntry, 0)
if r == nil || principal == nil || !principal.Backend {
return navigation, settings
}
for _, item := range r.navigation {
children := make([]NavigationEntry, 0)
for _, child := range item.SideMenu {
if !Allows(principal, child.Permissions) {
continue
}
children = append(children, navigationView(ctx, tr, child, nil))
}
if !Allows(principal, item.Permissions) && len(children) == 0 {
continue
}
navigation = append(navigation, navigationView(ctx, tr, item, children))
}
sort.SliceStable(navigation, func(i, j int) bool {
if navigation[i].Order == navigation[j].Order {
return navigation[i].Code < navigation[j].Code
}
return navigation[i].Order < navigation[j].Order
})
for _, compiled := range r.settings {
item := compiled.Item
if !Allows(principal, item.Permissions) {
continue
}
keywords := append([]string(nil), item.Keywords...)
if keywords == nil {
keywords = []string{}
}
settings = append(settings, SettingsEntry{
Code: item.Code, Label: translateKey(ctx, tr, item.Label),
Description: translateKey(ctx, tr, item.Description),
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
Order: item.Order, Keywords: keywords, Model: item.Model,
})
}
sort.SliceStable(settings, func(i, j int) bool {
if settings[i].Order == settings[j].Order {
return settings[i].Code < settings[j].Code
}
return settings[i].Order < settings[j].Order
})
return navigation, settings
}
func navigationView(ctx context.Context, tr *phrasebook.Translator, item pact.NavigationItem, children []NavigationEntry) NavigationEntry {
if children == nil {
children = []NavigationEntry{}
}
return NavigationEntry{
Code: item.Code, Label: translateKey(ctx, tr, item.Label), Icon: item.Icon,
Order: item.Order, Controller: item.Controller, SideMenu: children,
}
}

View File

@@ -71,3 +71,185 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
} }
return &Registry{byID: byID}, nil return &Registry{byID: byID}, nil
} }
func compileContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
reg.permissions = map[string]pact.Permission{}
reg.roleGrants = map[string]map[string]bool{}
reg.settings = map[string]*CompiledSetting{}
seenNavigation := map[string]struct{}{}
for _, plugin := range plugins {
if plugin == nil {
continue
}
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
for _, permission := range src.Permissions() {
if !permissionCode(permission.Code, false) {
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
}
if _, exists := reg.permissions[permission.Code]; exists {
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
}
reg.permissions[permission.Code] = permission
for _, role := range permission.Roles {
role = strings.TrimSpace(role)
if role == "" {
return fmt.Errorf("cabana: permission %s has an empty role", permission.Code)
}
if reg.roleGrants[role] == nil {
reg.roleGrants[role] = map[string]bool{}
}
reg.roleGrants[role][permission.Code] = true
}
}
}
if src, ok := plugin.(pact.HasSettings); ok && src != nil {
assets, hasAssets := plugin.(pact.AdminAssets)
for _, item := range src.Settings() {
if !identifier(item.Code) {
return fmt.Errorf("cabana: plugin %s registered invalid setting %q", plugin.ID(), item.Code)
}
if _, exists := reg.settings[item.Code]; exists {
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
}
if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
}
compiled, err := compileSetting(plugin.ID(), item, assets.AdminFS())
if err != nil {
return err
}
reg.settings[item.Code] = compiled
}
}
if src, ok := plugin.(pact.HasNavigation); ok && src != nil {
for _, item := range src.Navigation() {
if err := validateNavigationShape(plugin.ID(), item, seenNavigation); err != nil {
return err
}
reg.navigation = append(reg.navigation, item)
}
}
}
for id, controller := range reg.byID {
if err := reg.validatePermissions("controller "+id, requiredOf(controller.Controller)); err != nil {
return err
}
for name, relation := range controller.Relations {
if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil {
return err
}
}
}
for _, item := range reg.navigation {
if err := reg.validateNavigation(item); err != nil {
return err
}
}
for code, setting := range reg.settings {
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
return err
}
}
return nil
}
func permissionCode(code string, wildcard bool) bool {
parts := strings.Split(code, ".")
if len(parts) < 2 {
return false
}
for i, part := range parts {
if wildcard && i == len(parts)-1 && part == "*" {
return true
}
if !identifier(part) {
return false
}
}
return true
}
func (r *Registry) validatePermissions(owner string, permissions []string) error {
for _, code := range permissions {
if !permissionCode(code, true) || !r.permissionExists(code) {
return fmt.Errorf("cabana: %s references unknown permission %s", owner, code)
}
}
return nil
}
func (r *Registry) permissionExists(code string) bool {
if r == nil {
return false
}
if _, ok := r.permissions[code]; ok {
return true
}
if !strings.HasSuffix(code, ".*") {
return false
}
prefix := strings.TrimSuffix(code, "*")
for candidate := range r.permissions {
if strings.HasPrefix(candidate, prefix) {
return true
}
}
return false
}
func validateNavigationShape(pluginID string, item pact.NavigationItem, seen map[string]struct{}) error {
if !identifier(item.Code) {
return fmt.Errorf("cabana: plugin %s registered invalid navigation code %q", pluginID, item.Code)
}
if _, exists := seen[item.Code]; exists {
return fmt.Errorf("cabana: duplicate navigation code %s", item.Code)
}
seen[item.Code] = struct{}{}
childSeen := map[string]struct{}{}
for _, child := range item.SideMenu {
if !identifier(child.Code) {
return fmt.Errorf("cabana: navigation %s has invalid child code %q", item.Code, child.Code)
}
if _, exists := childSeen[child.Code]; exists {
return fmt.Errorf("cabana: navigation %s has duplicate child %s", item.Code, child.Code)
}
childSeen[child.Code] = struct{}{}
}
return nil
}
func (r *Registry) validateNavigation(item pact.NavigationItem) error {
if _, ok := r.byID[item.Controller]; !ok {
return fmt.Errorf("cabana: navigation %s references unknown controller %s", item.Code, item.Controller)
}
if err := r.validatePermissions("navigation "+item.Code, item.Permissions); err != nil {
return err
}
for _, child := range item.SideMenu {
if _, ok := r.byID[child.Controller]; !ok {
return fmt.Errorf("cabana: navigation %s.%s references unknown controller %s", item.Code, child.Code, child.Controller)
}
if err := r.validatePermissions("navigation "+item.Code+"."+child.Code, child.Permissions); err != nil {
return err
}
}
return nil
}
func (r *Registry) rolePermissions(role string) map[string]bool {
out := map[string]bool{}
if r == nil {
return out
}
for code, allowed := range r.roleGrants[role] {
if allowed {
out[code] = true
}
}
return out
}

View File

@@ -82,6 +82,7 @@ func controllerRequest(principal *bouncer.Principal) *http.Request {
req.SetPathValue("plugin", "demo") req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets") req.SetPathValue("controller", "widgets")
if principal != nil { if principal != nil {
principal.Backend = true
req = req.WithContext(bouncer.WithUser(req.Context(), principal)) req = req.WithContext(bouncer.WithUser(req.Context(), principal))
} }
return req return req

308
cabana/settings.go Normal file
View File

@@ -0,0 +1,308 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"reflect"
"strings"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CompiledSetting is a singleton settings registration after strict schema
// compilation and writable-field binding.
type CompiledSetting struct {
PluginID string
Item pact.SettingsItem
Form *FormSchema
Writable []WritableField
}
// SettingsResult is the explicit missing/existing singleton response.
type SettingsResult struct {
Exists bool `json:"exists"`
Data map[string]any `json:"data"`
}
// SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB }
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
}
model := item.NewModel()
rv := reflect.ValueOf(model)
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("cabana: setting %s model must be a non-nil struct pointer", item.Code)
}
if _, ok := model.(lagoon.HasFillable); !ok {
return nil, fmt.Errorf("cabana: setting %s model has no Fillable", item.Code)
}
if _, ok := model.(hasRules); !ok {
return nil, fmt.Errorf("cabana: setting %s model has no Rules", item.Code)
}
formPath := strings.Trim(path.Clean(item.Form), "/")
if formPath == "." || strings.HasPrefix(formPath, "..") {
return nil, fmt.Errorf("cabana: setting %s form escapes the plugin", item.Code)
}
raw, err := readAsset(fsys, formPath)
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}
fields, err := decodeFields(raw)
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
columns := modelColumns(model)
fillable := map[string]struct{}{}
for _, key := range model.(lagoon.HasFillable).Fillable() {
fillable[key] = struct{}{}
}
writable := make([]WritableField, 0, len(fields))
for _, field := range fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
continue
}
if _, ok := columns[field.Name]; !ok {
return nil, fmt.Errorf("cabana: setting %s field %s is not a model column", item.Code, field.Name)
}
if _, ok := fillable[field.Name]; !ok {
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name)
}
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
}
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
}
// Get returns default-valued data without creating a missing singleton.
func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (SettingsResult, error) {
if s.DB == nil {
return SettingsResult{}, errors.New("cabana: database is not configured")
}
model, err := newSettingModel(setting)
if err != nil {
return SettingsResult{}, err
}
if err := applySettingDefaults(setting, model); err != nil {
return SettingsResult{}, err
}
err = s.DB.WithContext(ctx).Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: 1}).Take(model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsResult{Exists: false, Data: projectSetting(setting, model)}, nil
}
if err != nil {
return SettingsResult{}, err
}
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
}
// Put validates and persists exactly one settings row. Identical replay is a
// successful no-op, including no updated_at churn.
func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body map[string]any) (SettingsResult, error) {
if s.DB == nil {
return SettingsResult{}, errors.New("cabana: database is not configured")
}
if err := requireSettingInputs(setting, body); err != nil {
return SettingsResult{}, err
}
var result SettingsResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
model, err := newSettingModel(setting)
if err != nil {
return err
}
pk := primaryColumn(model)
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.Eq{Column: clause.Column{Name: pk}, Value: 1}).Take(model).Error
exists := err == nil
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if !exists {
if err := applySettingDefaults(setting, model); err != nil {
return err
}
if err := setColumn(model, pk, uint(1)); err != nil {
return err
}
}
before := projectSetting(setting, model)
projected := projectSettingBody(setting, body)
allowed := make([]string, 0, len(setting.Writable))
for _, field := range setting.Writable {
allowed = append(allowed, field.FillKey)
}
if err := lagoon.Fill(model, allowed, projected, false); err != nil {
return &ValidationError{Details: map[string]any{"body": []string{"The settings payload is invalid."}}}
}
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
if err := hook.BeforeValidate(tx); err != nil {
return err
}
}
rules := settingRules(setting, model)
messages, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
if err != nil {
return err
}
if len(messages) > 0 {
return &ValidationError{Details: validationDetails(messages)}
}
after := projectSetting(setting, model)
if !exists {
if err := tx.Create(model).Error; err != nil {
return err
}
} else if !reflect.DeepEqual(before, after) {
if err := tx.Save(model).Error; err != nil {
return err
}
}
result = SettingsResult{Exists: true, Data: after}
return nil
})
return result, err
}
func newSettingModel(setting *CompiledSetting) (any, error) {
if setting == nil || setting.Item.NewModel == nil {
return nil, errors.New("cabana: setting model is not configured")
}
model := setting.Item.NewModel()
rv := reflect.ValueOf(model)
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return nil, errors.New("cabana: setting model is invalid")
}
return model, nil
}
func applySettingDefaults(setting *CompiledSetting, model any) error {
defaults := map[string]any{}
for _, field := range setting.Form.Fields {
if field.Default == nil {
continue
}
var value any
if err := json.Unmarshal(field.Default.raw, &value); err != nil {
return err
}
if field.Type == "switch" || field.Type == "checkbox" {
switch typed := value.(type) {
case float64:
value = typed != 0
case json.Number:
value = typed.String() != "0"
}
}
defaults[field.Name] = value
}
return lagoon.Fill(model, settingWritable(setting), defaults, true)
}
func settingWritable(setting *CompiledSetting) []string {
out := make([]string, 0, len(setting.Writable))
for _, field := range setting.Writable {
out = append(out, field.FillKey)
}
return out
}
func projectSettingBody(setting *CompiledSetting, body map[string]any) map[string]any {
out := map[string]any{}
if setting == nil {
return out
}
for _, field := range setting.Writable {
value, ok := body[field.Name]
if ok && !nestedValue(value) {
out[field.FillKey] = value
}
}
return out
}
func projectSetting(setting *CompiledSetting, model any) map[string]any {
out := map[string]any{}
value := reflect.ValueOf(model)
for value.IsValid() && value.Kind() == reflect.Pointer {
if value.IsNil() {
return out
}
value = value.Elem()
}
if !value.IsValid() || value.Kind() != reflect.Struct || setting == nil {
return out
}
for _, field := range setting.Writable {
v := fieldByColumn(value, field.FillKey)
if v.IsValid() && v.CanInterface() {
out[field.Name] = v.Interface()
}
}
return out
}
func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
details := map[string]any{}
for _, field := range setting.Form.Fields {
value, present := body[field.Name]
if present && nestedValue(value) {
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
continue
}
if field.Required && !present {
details[field.Name] = []string{"The " + field.Name + " field is required."}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
func settingRules(setting *CompiledSetting, model any) map[string]string {
rules := map[string]string{}
if provider, ok := model.(hasRules); ok && provider != nil {
for key, rule := range provider.Rules() {
rules[key] = rule
}
}
for _, field := range setting.Form.Fields {
if field.Required {
rules[field.Name] = mergeRequired(rules[field.Name])
}
}
return rules
}
func setColumn(model any, column string, value any) error {
rv := reflect.ValueOf(model)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return errors.New("cabana: nil model")
}
rv = rv.Elem()
}
field := fieldByColumn(rv, column)
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("cabana: model has no writable %s column", column)
}
src := reflect.ValueOf(value)
if src.Type().AssignableTo(field.Type()) {
field.Set(src)
return nil
}
if src.Type().ConvertibleTo(field.Type()) {
field.Set(src.Convert(field.Type()))
return nil
}
return fmt.Errorf("cabana: cannot set %s", column)
}

View File

@@ -145,6 +145,7 @@ type HasPermissions interface {
// NavigationItem is one registerNavigation() entry. Controller is the admin // NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID. // controller ID; the SPA derives its route from that ID.
type NavigationItem struct { type NavigationItem struct {
Code string
Label string Label string
Icon string Icon string
Permissions []string Permissions []string
@@ -169,6 +170,8 @@ type SettingsItem struct {
Order int Order int
Keywords []string Keywords []string
Permissions []string Permissions []string
Form string `json:"-"`
NewModel func() any `json:"-"`
} }
// HasSettings is implemented by plugins that declare settings screens. // HasSettings is implemented by plugins that declare settings screens.