From 10ca7a02e3feecd0974bbfa58902285bc9dc149f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 26 Sep 2026 23:06:22 +0200 Subject: [PATCH] feat(09-11): add permissioned admin metadata and settings --- bouncer/context.go | 1 + cabana/auth.go | 16 +- cabana/auth_test.go | 10 + cabana/contracts.go | 15 +- cabana/crud_lifecycle_test.go | 3 +- cabana/http.go | 108 ++++++++++- cabana/metadata_settings_test.go | 213 +++++++++++++++++++++ cabana/navigation.go | 94 ++++++++++ cabana/registry.go | 182 ++++++++++++++++++ cabana/security_test.go | 1 + cabana/settings.go | 308 +++++++++++++++++++++++++++++++ pact/capabilities.go | 3 + 12 files changed, 947 insertions(+), 7 deletions(-) create mode 100644 cabana/metadata_settings_test.go create mode 100644 cabana/navigation.go create mode 100644 cabana/settings.go diff --git a/bouncer/context.go b/bouncer/context.go index 78f07e8..e8d88d5 100644 --- a/bouncer/context.go +++ b/bouncer/context.go @@ -16,6 +16,7 @@ type Principal struct { MustChangePassword bool PreferredLocale string TokensValidAfter time.Time + Backend bool `json:"-"` IsSuperuser bool `json:"-"` PermissionGrants map[string]bool `json:"-"` } diff --git a/cabana/auth.go b/cabana/auth.go index 436f978..ac39d7d 100644 --- a/cabana/auth.go +++ b/cabana/auth.go @@ -30,7 +30,8 @@ const ( // BackendUsers loads activated backend principals. It never reads frontend users. type BackendUsers struct { - DB *gorm.DB + DB *gorm.DB + Registry *Registry } 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 { 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 { principal := &bouncer.Principal{ ID: user.ID, + Backend: true, IsSuperuser: user.IsSuperuser, PermissionGrants: parseGrants(user.Role.Permissions), } diff --git a/cabana/auth_test.go b/cabana/auth_test.go index 0cb2e0f..69ad6b6 100644 --- a/cabana/auth_test.go +++ b/cabana/auth_test.go @@ -205,6 +205,13 @@ func TestAdminAuthLogging(t *testing.T) { gdb := adminGorm(t) h := adminHandler(t, gdb, nil) 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 prev := slog.Default() 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 { 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 } type demoController struct{} diff --git a/cabana/contracts.go b/cabana/contracts.go index 048fb9c..41f8a6c 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -75,7 +75,11 @@ type CompiledController struct { // Registry is the immutable controller map keyed by controller ID. 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). @@ -87,6 +91,15 @@ func (r *Registry) Get(id string) (*CompiledController, bool) { 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. // A nil principal fails. Superusers pass. An empty requirement list allows // any authenticated principal. Grants ending in ".*" match by prefix. diff --git a/cabana/crud_lifecycle_test.go b/cabana/crud_lifecycle_test.go index d693b75..7fedf49 100644 --- a/cabana/crud_lifecycle_test.go +++ b/cabana/crud_lifecycle_test.go @@ -394,13 +394,14 @@ func hookFixture(t *testing.T) (CRUDService, *service, *CompiledController, *gor func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context { ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log) if principal != nil { + principal.Backend = true ctx = bouncer.WithUser(ctx, principal) } return ctx } 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 { diff --git a/cabana/http.go b/cabana/http.go index 2199e20..e319d97 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -58,6 +58,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err != nil { return nil, err } + if err := compileContributions(reg, plugins); err != nil { + return nil, err + } if app == 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) - 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.Register("summercms.cabana", "backend", guard); err != nil { return nil, err @@ -102,6 +105,7 @@ func writeUnauthenticated(w http.ResponseWriter, _ error) { type lazyBackendUsers struct { app *backpack.App + reg *Registry } 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 { 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) { @@ -124,6 +128,14 @@ func (s *service) mount(r pact.Router) { r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { g.Post("/auth/logout", s.logout) 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) constrainController(g) 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_]*") } +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) { s.protect(w, r, func(cc *CompiledController) { 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 } principal, _ := bouncer.User(r.Context()) - if principal == nil { + if principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } diff --git a/cabana/metadata_settings_test.go b/cabana/metadata_settings_test.go new file mode 100644 index 0000000..d798ce9 --- /dev/null +++ b/cabana/metadata_settings_test.go @@ -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) + } +} diff --git a/cabana/navigation.go b/cabana/navigation.go new file mode 100644 index 0000000..a20c1a4 --- /dev/null +++ b/cabana/navigation.go @@ -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, + } +} diff --git a/cabana/registry.go b/cabana/registry.go index c510044..2020f3a 100644 --- a/cabana/registry.go +++ b/cabana/registry.go @@ -71,3 +71,185 @@ func compileRegistry(items []controllerRef) (*Registry, error) { } 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 +} diff --git a/cabana/security_test.go b/cabana/security_test.go index a89f0a8..37236fd 100644 --- a/cabana/security_test.go +++ b/cabana/security_test.go @@ -82,6 +82,7 @@ func controllerRequest(principal *bouncer.Principal) *http.Request { req.SetPathValue("plugin", "demo") req.SetPathValue("controller", "widgets") if principal != nil { + principal.Backend = true req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } return req diff --git a/cabana/settings.go b/cabana/settings.go new file mode 100644 index 0000000..8214ad7 --- /dev/null +++ b/cabana/settings.go @@ -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) +} diff --git a/pact/capabilities.go b/pact/capabilities.go index 32ec2f8..d8b0fd7 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -145,6 +145,7 @@ type HasPermissions interface { // NavigationItem is one registerNavigation() entry. Controller is the admin // controller ID; the SPA derives its route from that ID. type NavigationItem struct { + Code string Label string Icon string Permissions []string @@ -169,6 +170,8 @@ type SettingsItem struct { Order int Keywords []string Permissions []string + Form string `json:"-"` + NewModel func() any `json:"-"` } // HasSettings is implemented by plugins that declare settings screens.