From dfa00f7e3afa5db850f92bbe5141581e575d8ed5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:17:19 +0200 Subject: [PATCH] feat(09-01): implement separate-admin genre list tracer - Audience-aware mint, verify, refresh, and backend guard keep frontend tokens compatible - Cabana mounts raw admin login, list schema, and record list behind admin.jwt.secret - Framework migration seeds Winter backend users and developer/publisher roles --- bouncer/context.go | 4 + bouncer/jwt.go | 84 ++++++++- bouncer/mint.go | 27 ++- bouncer/refresh.go | 22 ++- cabana/auth.go | 236 ++++++++++++++++++++++++ cabana/contracts.go | 117 ++++++++++++ cabana/http.go | 278 +++++++++++++++++++++++++++++ cabana/registry.go | 59 ++++++ cabana/schema.go | 161 +++++++++++++++++ lagoon/backend_admin_migrations.go | 67 +++++++ lagoon/migrations.go | 11 +- pact/capabilities.go | 113 +++++++++++- surf/router.go | 12 ++ 13 files changed, 1178 insertions(+), 13 deletions(-) create mode 100644 cabana/auth.go create mode 100644 cabana/contracts.go create mode 100644 cabana/http.go create mode 100644 cabana/registry.go create mode 100644 cabana/schema.go create mode 100644 lagoon/backend_admin_migrations.go diff --git a/bouncer/context.go b/bouncer/context.go index b1b3958..78f07e8 100644 --- a/bouncer/context.go +++ b/bouncer/context.go @@ -9,11 +9,15 @@ type userKey struct{} // 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. type Principal struct { ID uint MustChangePassword bool PreferredLocale string TokensValidAfter time.Time + IsSuperuser bool `json:"-"` + PermissionGrants map[string]bool `json:"-"` } // WithUser stores the verified principal on ctx. diff --git a/bouncer/jwt.go b/bouncer/jwt.go index 395bbc7..7fcfb12 100644 --- a/bouncer/jwt.go +++ b/bouncer/jwt.go @@ -66,10 +66,13 @@ func Middleware(secret string, users UserProvider) func(http.Handler) http.Handl } type jwtGuard struct { - secret string - users UserProvider - bl BlacklistStore - cookieNames []string + secret string + users UserProvider + bl BlacklistStore + cookieNames []string + audience string + requireAudience bool + writeFn func(http.ResponseWriter, error) } var ( @@ -84,12 +87,31 @@ func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNam return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames} } +// NewBackendJWTGuard is bearer-only and requires AudienceBackend. +// write may replace the PHP-shaped 401 body; nil keeps write401. +func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard { + return &jwtGuard{ + secret: secret, + users: users, + bl: bl, + audience: AudienceBackend, + requireAudience: true, + writeFn: write, + } +} + func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) { raw, err := extractToken(r, g.cookieNames) if err != nil { return nil, err } - sub, iat, _, jti, err := VerifyClaims(raw, g.secret) + var sub, jti string + var iat time.Time + if g.requireAudience { + sub, iat, _, jti, err = VerifyClaimsAudience(raw, g.secret, g.audience) + } else { + sub, iat, _, jti, err = VerifyClaims(raw, g.secret) + } if err != nil { return nil, err } @@ -123,6 +145,10 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) { } func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) { + if g.writeFn != nil { + g.writeFn(w, err) + return + } write401(w, err.Error()) } @@ -147,7 +173,20 @@ func Verify(tokenString, secret string) (string, error) { } // VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti. +// A missing audience stays valid so PHP-issued frontend tokens keep working. func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) { + return verifyClaims(tokenString, secret, "") +} + +// VerifyClaimsAudience is VerifyClaims plus a required audience claim. +func VerifyClaimsAudience(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) { + if strings.TrimSpace(audience) == "" { + return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt audience is empty") + } + return verifyClaims(tokenString, secret, audience) +} + +func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) { if strings.TrimSpace(secret) == "" { return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty") } @@ -159,6 +198,9 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j if err != nil { return "", time.Time{}, time.Time{}, "", mapJWTError(err) } + if audience != "" && !audienceMatches(claims, audience) { + return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature) + } sub = subject(claims) if sub == "" { return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims) @@ -169,6 +211,38 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j return sub, iat, exp, jti, nil } +func audienceMatches(claims jwt.MapClaims, expected string) bool { + for _, aud := range claimAudiences(claims) { + if aud == expected { + return true + } + } + return false +} + +func claimAudiences(claims jwt.MapClaims) []string { + switch v := claims["aud"].(type) { + case string: + if strings.TrimSpace(v) == "" { + return nil + } + return []string{v} + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + func extractToken(r *http.Request, cookieNames []string) (string, error) { raw, err := bearerToken(r) if err == nil { diff --git a/bouncer/mint.go b/bouncer/mint.go index 8570556..59c6597 100644 --- a/bouncer/mint.go +++ b/bouncer/mint.go @@ -11,35 +11,56 @@ import ( ) // prvHash is sha1("Golem15\User\Models\User"), the lock-subject PHP jwt-auth -// stamps on every user token. +// stamps on every frontend user token. Backend tokens do not carry it. const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883" +const ( + // AudienceUser is the frontend jwt-guard audience. + AudienceUser = "user" + // AudienceBackend is the admin jwt-guard audience. + AudienceBackend = "backend" +) + type registeredClaims struct { jwt.RegisteredClaims Prv string `json:"prv,omitempty"` } -// Mint signs an HS256 token whose iss is the full URL of the minting endpoint. +// Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL. // The returned jti is the token's own jti claim. func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, error) { + return MintAudience(secret, sub, issuerURL, ttl, AudienceUser) +} + +// MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP +// prv lock-subject; backend tokens omit it. +func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) { if strings.TrimSpace(secret) == "" { return "", "", fmt.Errorf("bouncer: jwt secret is empty") } + if strings.TrimSpace(audience) == "" { + return "", "", fmt.Errorf("bouncer: jwt audience is empty") + } jti, err := newJTI() if err != nil { return "", "", err } now := time.Now() + prv := "" + if audience == AudienceUser { + prv = prvHash + } claims := registeredClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: issuerURL, Subject: sub, + Audience: jwt.ClaimStrings{audience}, ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), ID: jti, }, - Prv: prvHash, + Prv: prv, } signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) if err != nil { diff --git a/bouncer/refresh.go b/bouncer/refresh.go index c5d1743..929b518 100644 --- a/bouncer/refresh.go +++ b/bouncer/refresh.go @@ -15,6 +15,18 @@ import ( // jwt-auth: the later of the old exp and iat+refreshTTL, plus one minute, so // a logged-out token cannot be refreshed again for the rest of its refresh window. func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) { + return refreshAudience(secret, tokenString, AudienceUser, true, refreshTTL, bl, grace, issuerURL) +} + +// RefreshAudience reissues a token that already carries audience. Missing aud is rejected. +func RefreshAudience(secret, tokenString, audience string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) { + if strings.TrimSpace(audience) == "" { + return "", errors.New("bouncer: jwt audience is empty") + } + return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL) +} + +func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) { if strings.TrimSpace(secret) == "" { return "", errors.New("bouncer: jwt secret is empty") } @@ -29,6 +41,14 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS if sub == "" { return "", errors.New(msgRequiredClaims) } + auds := claimAudiences(claims) + if len(auds) == 0 { + if !allowMissing { + return "", errors.New(msgBadSignature) + } + } else if !audienceMatches(claims, audience) { + return "", errors.New(msgBadSignature) + } iat, ok := claimTime(claims, "iat") if !ok || time.Now().After(iat.Add(refreshTTL)) { return "", errors.New("Token has expired and can no longer be refreshed") @@ -48,7 +68,7 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS if !expOK || ttl <= 0 { return "", errors.New(msgRequiredClaims) } - next, _, err := Mint(secret, sub, issuerURL, ttl) + next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience) if err != nil { return "", err } diff --git a/cabana/auth.go b/cabana/auth.go new file mode 100644 index 0000000..d1e444a --- /dev/null +++ b/cabana/auth.go @@ -0,0 +1,236 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "gorm.io/gorm" +) + +const ( + msgInvalidCredentials = "Invalid credentials" + msgUnauthenticated = "Unauthenticated" + msgForbidden = "Forbidden" + msgNotFound = "Not found" + msgServerError = "Server error" +) + +// BackendUserRole is the Winter backend_user_roles row. +type BackendUserRole struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Code string `gorm:"column:code"` + Description string `gorm:"column:description"` + Permissions string `gorm:"column:permissions"` + IsSystem bool `gorm:"column:is_system"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (BackendUserRole) TableName() string { return "backend_user_roles" } + +// BackendUser is the Winter backend_users row. It is not a frontend user. +type BackendUser struct { + ID uint `gorm:"column:id;primaryKey"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + Login string `gorm:"column:login"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password"` + IsActivated bool `gorm:"column:is_activated"` + IsSuperuser bool `gorm:"column:is_superuser"` + RoleID *uint `gorm:"column:role_id"` + LastLogin *time.Time `gorm:"column:last_login"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + Role BackendUserRole +} + +func (BackendUser) TableName() string { return "backend_users" } + +// BackendUsers loads activated backend principals. It never reads frontend users. +type BackendUsers struct { + DB *gorm.DB +} + +func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { + if p.DB == nil || id == 0 { + return nil, nil + } + var user BackendUser + err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if !user.IsActivated { + return nil, nil + } + return principalFrom(user), nil +} + +func principalFrom(user BackendUser) *bouncer.Principal { + return &bouncer.Principal{ + ID: user.ID, + IsSuperuser: user.IsSuperuser, + PermissionGrants: parseGrants(user.Role.Permissions), + } +} + +func parseGrants(raw string) map[string]bool { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" || raw == "null" { + return nil + } + var decoded map[string]any + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return nil + } + out := make(map[string]bool, len(decoded)) + for code, value := range decoded { + if truthyGrant(value) { + out[code] = true + } + } + if len(out) == 0 { + return nil + } + return out +} + +func truthyGrant(value any) bool { + switch v := value.(type) { + case bool: + return v + case float64: + return v == 1 + case string: + return v == "1" || strings.EqualFold(v, "true") + case json.Number: + return v.String() == "1" + default: + return false + } +} + +type loginBody struct { + Login string `json:"login"` + Email string `json:"email"` + Password string `json:"password"` +} + +func (s *service) login(w http.ResponseWriter, r *http.Request) { + var body loginBody + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + if err := dec.Decode(&body); err != nil { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials) + return + } + identifier := strings.TrimSpace(body.Login) + if identifier == "" { + identifier = strings.TrimSpace(body.Email) + } + if identifier == "" || body.Password == "" { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials) + return + } + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier) + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + hash := user.Password + if !found { + hash = dummyPasswordHash + } + if !found || !user.IsActivated || !bouncer.CheckPassword(hash, body.Password) { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials) + return + } + token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend) + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + WriteData(w, http.StatusOK, map[string]string{ + "access_token": token, + "token_type": "bearer", + }, map[string]any{}) +} + +func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) { + email := strings.ToLower(identifier) + var user BackendUser + err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return BackendUser{}, false, nil + } + if err != nil { + return BackendUser{}, false, err + } + return user, true, nil +} + +func (s *service) db() (*gorm.DB, error) { + if s == nil || s.app == nil { + return nil, errors.New("cabana: database is not configured") + } + db, ok := s.app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("cabana: database is not configured") + } + return db, nil +} + +func adminSecret(app *backpack.App) (string, error) { + secret := "" + if app != nil && app.Config != nil { + secret = strings.TrimSpace(app.Config.String("admin.jwt.secret")) + } + if secret == "" { + return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)") + } + return secret, nil +} + +func adminTTL(app *backpack.App) time.Duration { + minutes := 60 + if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 { + minutes = app.Config.Int("admin.jwt.ttl") + } + return time.Duration(minutes) * time.Minute +} + +func adminIssuer(app *backpack.App) string { + base := "" + if app != nil && app.Config != nil { + base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/") + } + if base == "" { + return "/_admin/api/v1/auth/login" + } + return base + "/_admin/api/v1/auth/login" +} + +// dummyPasswordHash keeps a missing-user login on the bcrypt path. +var dummyPasswordHash = func() string { + hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials") + if err != nil { + return "" + } + return hash +}() diff --git a/cabana/contracts.go b/cabana/contracts.go new file mode 100644 index 0000000..4ff93ab --- /dev/null +++ b/cabana/contracts.go @@ -0,0 +1,117 @@ +package cabana + +import ( + "encoding/json" + "net/http" + "strings" + + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/pact" +) + +// Option is a dropdown choice shared with later schema plans. +type Option = pact.Option + +// ListColumn is one compiled columns.yaml entry, in file order. +type ListColumn struct { + Key string `json:"key"` + Label string `json:"label"` + Searchable bool `json:"searchable"` + Sortable bool `json:"sortable"` + Type string `json:"type,omitempty"` +} + +// ListSchema is the boot-compiled list contract for one controller. +type ListSchema struct { + Title string `json:"title,omitempty"` + RecordsPerPage int `json:"recordsPerPage"` + ShowSearch bool `json:"showSearch"` + Columns []ListColumn `json:"columns"` +} + +// CompiledController is one admin controller after YAML compilation. +type CompiledController struct { + PluginID string + Controller pact.AdminController + List *ListSchema +} + +// Registry is the immutable controller map keyed by controller ID. +type Registry struct { + byID map[string]*CompiledController +} + +// Get returns the compiled controller for a D-09 id (vendor.plugin.controller). +func (r *Registry) Get(id string) (*CompiledController, bool) { + if r == nil { + return nil, false + } + cc, ok := r.byID[id] + return cc, ok && cc != 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. +func Allows(principal *bouncer.Principal, required []string) bool { + if principal == nil { + return false + } + if principal.IsSuperuser || len(required) == 0 { + return true + } + for _, code := range required { + if !granted(principal.PermissionGrants, code) { + return false + } + } + return true +} + +func granted(grants map[string]bool, code string) bool { + if grants[code] { + return true + } + for key, on := range grants { + if !on || !strings.HasSuffix(key, ".*") { + continue + } + prefix := strings.TrimSuffix(key, "*") + if strings.HasPrefix(code, prefix) { + return true + } + } + return false +} + +func requiredOf(ctl pact.AdminController) []string { + if p, ok := ctl.(pact.AdminPermissioned); ok && p != nil { + return p.RequiredPermissions() + } + return nil +} + +// WriteData writes a D-10 success envelope. +func WriteData(w http.ResponseWriter, status int, data, meta any) { + if meta == nil { + meta = map[string]any{} + } + writeJSON(w, status, map[string]any{"data": data, "meta": meta}) +} + +// WriteError writes a D-10 error envelope. details is always an object. +func WriteError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{ + "error": map[string]any{ + "code": code, + "message": message, + "details": map[string]any{}, + }, + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/cabana/http.go b/cabana/http.go new file mode 100644 index 0000000..06da993 --- /dev/null +++ b/cabana/http.go @@ -0,0 +1,278 @@ +package cabana + +import ( + "context" + "errors" + "net/http" + "reflect" + "strconv" + "strings" + "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" +) + +// Routes is the raw admin API mounted by surf.BuildRouter. +type Routes struct { + Middleware pact.Middleware + Mount func(r pact.Router) +} + +type service struct { + app *backpack.App + reg *Registry + secret string + ttl time.Duration + issuer string +} + +// Activate compiles admin controllers and, when any exist, requires +// admin.jwt.secret. No controllers means no admin routes and no secret check. +func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { + items, err := collectControllers(plugins) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, nil + } + secret, err := adminSecret(app) + if err != nil { + return nil, err + } + reg, err := compileRegistry(items) + if err != nil { + return nil, err + } + if app == nil { + return nil, errors.New("cabana: app is nil") + } + guards, ok := app.Lookup[*bouncer.Registry]() + if !ok || guards == nil { + guards = bouncer.NewRegistry() + if err := app.Publish(guards); err != nil { + return nil, err + } + } + var bl bouncer.BlacklistStore + if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { + bl = store + } + guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated) + if _, err := guards.Middleware("backend"); err != nil { + if err := guards.Register("summercms.cabana", "backend", guard); err != nil { + return nil, err + } + } + mw, err := guards.Middleware("backend") + if err != nil { + return nil, err + } + svc := &service{ + app: app, + reg: reg, + secret: secret, + ttl: adminTTL(app), + issuer: adminIssuer(app), + } + return &Routes{Middleware: mw, Mount: svc.mount}, nil +} + +func writeUnauthenticated(w http.ResponseWriter, _ error) { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) +} + +type lazyBackendUsers struct { + app *backpack.App +} + +func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { + if p.app == nil { + return nil, errors.New("cabana: database is not configured") + } + db, ok := p.app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("cabana: database is not configured") + } + return (BackendUsers{DB: db}).FindByID(ctx, id) +} + +func (s *service) mount(r pact.Router) { + r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) { + g.Post("/login", s.login) + }) + r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { + g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) + constrainController(g) + g.Get("/{vendor}/{plugin}/{controller}", s.list) + constrainController(g) + }) +} + +func constrainController(g pact.Router) { + g.Where("vendor", "[A-Za-z0-9_-]+") + g.Where("plugin", "[A-Za-z0-9_-]+") + g.Where("controller", "[A-Za-z0-9_-]+") +} + +func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cols := cc.List.Columns + if cols == nil { + cols = []ListColumn{} + } + WriteData(w, http.StatusOK, &ListSchema{ + Title: cc.List.Title, + RecordsPerPage: cc.List.RecordsPerPage, + ShowSearch: cc.List.ShowSearch, + Columns: cols, + }, map[string]any{}) + }) +} + +func (s *service) list(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rows, total, err := queryList(r.Context(), db, cc) + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + data := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + data = append(data, projectRow(row, cc.List.Columns)) + } + per := cc.List.RecordsPerPage + if per < 1 { + per = 20 + } + last := int((total + int64(per) - 1) / int64(per)) + if last < 1 { + last = 1 + } + WriteData(w, http.StatusOK, data, map[string]any{ + "page": 1, + "per_page": per, + "total": total, + "last_page": last, + }) + }) +} + +// protect runs after the backend guard. Controller lookup precedes permission +// evaluation, and schema/SQL run only inside fn. +func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) { + id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") + cc, ok := s.reg.Get(id) + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + principal, _ := bouncer.User(r.Context()) + if principal == nil { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + if !Allows(principal, requiredOf(cc.Controller)) { + WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) + return + } + fn(cc) +} + +func queryList(ctx context.Context, db *gorm.DB, cc *CompiledController) ([]any, int64, error) { + src, ok := cc.Controller.(pact.AdminRecordSource) + if !ok || src == nil { + return nil, 0, errors.New("cabana: admin controller has no record source") + } + model := src.NewRecord() + mt := reflect.TypeOf(model) + if mt == nil || mt.Kind() != reflect.Pointer { + return nil, 0, errors.New("cabana: admin model must be a pointer") + } + q := db.WithContext(ctx).Model(model) + if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil { + if next := ext.ListExtendQuery(ctx, q); next != nil { + q = next + } + } + var total int64 + if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, 0, err + } + per := 20 + if cc.List != nil && cc.List.RecordsPerPage > 0 { + per = cc.List.RecordsPerPage + } + slice := reflect.New(reflect.SliceOf(mt.Elem())) + if err := q.Session(&gorm.Session{}).Limit(per).Find(slice.Interface()).Error; err != nil { + return nil, 0, err + } + values := slice.Elem() + out := make([]any, values.Len()) + for i := 0; i < values.Len(); i++ { + out[i] = values.Index(i).Addr().Interface() + } + return out, total, nil +} + +func projectRow(row any, cols []ListColumn) map[string]any { + v := reflect.ValueOf(row) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return map[string]any{} + } + v = v.Elem() + } + out := make(map[string]any, len(cols)+1) + if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() { + out["id"] = id.Interface() + } + for _, col := range cols { + field := fieldByColumn(v, col.Key) + if !field.IsValid() || !field.CanInterface() { + continue + } + out[col.Key] = field.Interface() + } + return out +} + +func fieldByColumn(v reflect.Value, column string) reflect.Value { + if v.Kind() != reflect.Struct { + return reflect.Value{} + } + t := v.Type() + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + if gormColumn(field) == column || strings.EqualFold(field.Name, column) { + return v.Field(i) + } + } + return reflect.Value{} +} + +func gormColumn(field reflect.StructField) string { + for _, part := range strings.Split(field.Tag.Get("gorm"), ";") { + part = strings.TrimSpace(part) + if name, ok := strings.CutPrefix(part, "column:"); ok { + return name + } + } + return "" +} + +func uitoa(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} diff --git a/cabana/registry.go b/cabana/registry.go new file mode 100644 index 0000000..d85074f --- /dev/null +++ b/cabana/registry.go @@ -0,0 +1,59 @@ +package cabana + +import ( + "fmt" + "strings" + + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" +) + +type controllerRef struct { + plugin party.Plugin + ctl pact.AdminController +} + +func collectControllers(plugins []party.Plugin) ([]controllerRef, error) { + var out []controllerRef + for _, p := range plugins { + src, ok := p.(pact.HasAdminControllers) + if !ok || p == nil { + continue + } + for _, ctl := range src.AdminControllers() { + if ctl == nil { + continue + } + id := ctl.ID() + if id != p.ID() && !strings.HasPrefix(id, p.ID()+".") { + return nil, fmt.Errorf("cabana: controller %s is not owned by plugin %s", id, p.ID()) + } + out = append(out, controllerRef{plugin: p, ctl: ctl}) + } + } + return out, nil +} + +func compileRegistry(items []controllerRef) (*Registry, error) { + byID := make(map[string]*CompiledController, len(items)) + for _, item := range items { + id := item.ctl.ID() + if _, exists := byID[id]; exists { + return nil, fmt.Errorf("cabana: duplicate admin controller %s", id) + } + assets, ok := item.plugin.(pact.AdminAssets) + if !ok || assets == nil || assets.AdminFS() == nil { + return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID()) + } + list, err := compileList(item.plugin.ID(), item.ctl, assets.AdminFS()) + if err != nil { + return nil, err + } + byID[id] = &CompiledController{ + PluginID: item.plugin.ID(), + Controller: item.ctl, + List: list, + } + } + return &Registry{byID: byID}, nil +} diff --git a/cabana/schema.go b/cabana/schema.go new file mode 100644 index 0000000..a8d702e --- /dev/null +++ b/cabana/schema.go @@ -0,0 +1,161 @@ +package cabana + +import ( + "bytes" + "fmt" + "io/fs" + "path" + "strings" + + "git.golem15.com/golem15/summercms/pact" + "github.com/goccy/go-yaml" +) + +type listDocument struct { + List string `yaml:"list"` + ModelClass string `yaml:"modelClass"` + Title string `yaml:"title"` + RecordURL string `yaml:"recordUrl"` + NoRecordsMessage string `yaml:"noRecordsMessage"` + RecordsPerPage int `yaml:"recordsPerPage"` + ShowCheckboxes bool `yaml:"showCheckboxes"` + ShowSearch bool `yaml:"showSearch"` + Toolbar *struct { + Buttons string `yaml:"buttons"` + Search *struct { + Prompt string `yaml:"prompt"` + } `yaml:"search"` + } `yaml:"toolbar"` +} + +type columnsDocument struct { + Columns yaml.MapSlice `yaml:"columns"` +} + +type columnDocument struct { + Label string `yaml:"label"` + Searchable bool `yaml:"searchable"` + Sortable bool `yaml:"sortable"` + Type string `yaml:"type"` + Relation string `yaml:"relation"` + Select string `yaml:"select"` +} + +func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) { + dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") + if dir == "." || strings.HasPrefix(dir, "..") { + return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) + } + cfgPath := path.Join(dir, "config_list.yaml") + raw, err := readAsset(fsys, cfgPath) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + var doc listDocument + if err := decodeStrict(raw, &doc); err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + if strings.TrimSpace(doc.List) == "" { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty")) + } + colPath, err := assetPath(pluginID, doc.List) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + colRaw, err := readAsset(fsys, colPath) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), colPath, err) + } + var cols columnsDocument + if err := decodeStrict(colRaw, &cols); err != nil { + return nil, bootErr(pluginID, ctl.ID(), colPath, err) + } + compiled := make([]ListColumn, 0, len(cols.Columns)) + seen := map[string]struct{}{} + for _, item := range cols.Columns { + key, ok := item.Key.(string) + if !ok || !identifier(key) { + return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key)) + } + if _, dup := seen[key]; dup { + return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key)) + } + seen[key] = struct{}{} + encoded, err := yaml.Marshal(item.Value) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), colPath, err) + } + var spec columnDocument + if err := decodeStrict(encoded, &spec); err != nil { + return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err)) + } + compiled = append(compiled, ListColumn{ + Key: key, + Label: spec.Label, + Searchable: spec.Searchable, + Sortable: spec.Sortable, + Type: spec.Type, + }) + } + per := doc.RecordsPerPage + if per < 1 { + per = 20 + } + showSearch := doc.ShowSearch + if doc.Toolbar != nil && doc.Toolbar.Search != nil { + showSearch = true + } + return &ListSchema{ + Title: doc.Title, + RecordsPerPage: per, + ShowSearch: showSearch, + Columns: compiled, + }, nil +} + +func decodeStrict(raw []byte, dest any) error { + dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) + if err := dec.Decode(dest); err != nil { + return err + } + return nil +} + +func readAsset(fsys fs.FS, name string) ([]byte, error) { + name = path.Clean(name) + if name == "." || strings.HasPrefix(name, "..") || strings.Contains(name, "..") { + return nil, fmt.Errorf("path escapes the plugin") + } + return fs.ReadFile(fsys, name) +} + +func assetPath(pluginID, ref string) (string, error) { + ref = strings.TrimSpace(ref) + ref = strings.TrimPrefix(ref, "~/") + prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/" + ref = strings.TrimPrefix(ref, prefix) + ref = path.Clean(ref) + if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") { + return "", fmt.Errorf("list path escapes the plugin") + } + return ref, nil +} + +func identifier(s string) bool { + if s == "" { + return false + } + for i, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': + case i > 0 && r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +func bootErr(pluginID, controllerID, file string, err error) error { + return fmt.Errorf("cabana: admin schema %s/%s/%s: %w", pluginID, controllerID, file, err) +} diff --git a/lagoon/backend_admin_migrations.go b/lagoon/backend_admin_migrations.go new file mode 100644 index 0000000..60fd400 --- /dev/null +++ b/lagoon/backend_admin_migrations.go @@ -0,0 +1,67 @@ +package lagoon + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// BackendAdminMigrations creates Winter-shaped backend identity tables and +// seeds the developer and publisher system roles. History is isolated under +// the summercms.cabana plugin id. +var BackendAdminMigrations = []*gormigrate.Migration{ + { + ID: "202609240001_backend_admin_identity", + Migrate: func(tx *gorm.DB) error { + stmts := []string{ + `CREATE TABLE backend_user_roles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + code TEXT UNIQUE, + description TEXT, + permissions TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`, + `CREATE TABLE backend_users ( + id SERIAL PRIMARY KEY, + first_name TEXT, + last_name TEXT, + login TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + activation_code TEXT, + persist_code TEXT, + reset_password_code TEXT, + permissions TEXT, + is_activated BOOLEAN NOT NULL DEFAULT FALSE, + is_superuser BOOLEAN NOT NULL DEFAULT FALSE, + role_id INTEGER REFERENCES backend_user_roles(id), + activated_at TIMESTAMPTZ, + last_login TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +)`, + `CREATE INDEX backend_users_role_id_index ON backend_users (role_id)`, + `CREATE INDEX backend_users_deleted_at_index ON backend_users (deleted_at)`, + `INSERT INTO backend_user_roles (name, code, description, permissions, is_system) +VALUES + ('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE), + ('Publisher', 'publisher', 'Site editor with access to publishing tools.', '{}', TRUE)`, + } + for _, stmt := range stmts { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec(`DROP TABLE IF EXISTS backend_users`).Error; err != nil { + return err + } + return tx.Exec(`DROP TABLE IF EXISTS backend_user_roles`).Error + }, + }, +} diff --git a/lagoon/migrations.go b/lagoon/migrations.go index dbd29c9..319e42b 100644 --- a/lagoon/migrations.go +++ b/lagoon/migrations.go @@ -57,8 +57,8 @@ func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) }, migrations), nil } -// Migrate runs the framework-owned system_files set first, then each -// plugin's HasMigrations set in party.Activate order. +// Migrate runs the framework-owned system_files and backend-admin sets +// first, then each plugin's HasMigrations set in party.Activate order. func Migrate(gdb *gorm.DB, plugins []party.Plugin) error { if gdb == nil { return fmt.Errorf("lagoon: gorm db is nil") @@ -70,6 +70,13 @@ func Migrate(gdb *gorm.DB, plugins []party.Plugin) error { if err := m.Migrate(); err != nil { return fmt.Errorf("lagoon: migrate system_files: %w", err) } + admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) + if err != nil { + return err + } + if err := admin.Migrate(); err != nil { + return fmt.Errorf("lagoon: migrate backend admin: %w", err) + } for _, p := range plugins { hm, ok := p.(pact.HasMigrations) if !ok { diff --git a/pact/capabilities.go b/pact/capabilities.go index 9915f27..dde0ed9 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/summercms/bonfire" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) // HasCommands is implemented by plugins that register console commands. @@ -111,6 +112,116 @@ type HasAdminControllers interface { AdminControllers() []AdminController } +// AdminAssets is the plugin-owned embedded tree of Winter admin YAML. +// Paths are relative to the plugin root (controllers/..., models/...). +type AdminAssets interface { + AdminFS() fs.FS +} + +// AdminPermissioned is the D-03 permission list enforced before schema or SQL. +type AdminPermissioned interface { + RequiredPermissions() []string +} + +// AdminRecordSource supplies the GORM model the generic admin handlers query. +// NewRecord returns a pointer to the model struct. +type AdminRecordSource interface { + NewRecord() any +} + +// Permission is one registerPermissions() entry. +type Permission struct { + Code string + Tab string + Label string + Roles []string +} + +// HasPermissions is implemented by plugins that declare backend permissions. +type HasPermissions interface { + Permissions() []Permission +} + +// NavigationItem is one registerNavigation() entry. Controller is the admin +// controller ID; the SPA derives its route from that ID. +type NavigationItem struct { + Label string + Icon string + Permissions []string + Order int + Controller string + SideMenu []NavigationItem +} + +// HasNavigation is implemented by plugins that declare backend navigation. +type HasNavigation interface { + Navigation() []NavigationItem +} + +// SettingsItem is one registerSettings() entry. +type SettingsItem struct { + Code string + Label string + Description string + Category string + Icon string + Model string + Order int + Keywords []string + Permissions []string +} + +// HasSettings is implemented by plugins that declare settings screens. +type HasSettings interface { + Settings() []SettingsItem +} + +// Option is one dropdown choice. Label may be a phrase key until request time. +type Option struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// DropdownOptionsProvider serves method-backed dropdown options. +type DropdownOptionsProvider interface { + DropdownOptions(field string) []Option +} + +// ListExtendQuery optionally narrows the admin list query. +type ListExtendQuery interface { + ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB +} + +// FormExtendQuery optionally narrows admin form record lookup. +type FormExtendQuery interface { + FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB +} + +// FormBeforeCreate optionally rejects or stamps a record before insert. +type FormBeforeCreate interface { + FormBeforeCreate(ctx context.Context, model any) error +} + +// FormBeforeUpdate optionally rejects or stamps a record before update. +type FormBeforeUpdate interface { + FormBeforeUpdate(ctx context.Context, model any) error +} + +// RelationExtendManageQuery optionally narrows relation-manager candidates. +type RelationExtendManageQuery interface { + RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB +} + +// RelationBeforeLink optionally stamps pivot columns before a link insert. +type RelationBeforeLink interface { + RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error +} + +// FilterScope is a model method referenced by a config_filter scope name. +type FilterScope interface { + FilterScope(name string, db *gorm.DB, value any) *gorm.DB +} + // HasLang is implemented by plugins that ship embedded translation YAML // under lang//.yaml. type HasLang interface { @@ -137,8 +248,6 @@ type OptionalMessage interface { // packages exist: // // HasListeners -// HasNavigation -// HasPermissions // HasSchedule // // The kernel type-asserts HasConfig (party, before Register), HasCommands diff --git a/surf/router.go b/surf/router.go index 791bb9f..d248528 100644 --- a/surf/router.go +++ b/surf/router.go @@ -9,6 +9,7 @@ import ( "time" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/cabana" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/towel" @@ -518,6 +519,17 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { } } } + admin, err := cabana.Activate(app, plugins) + if err != nil { + return nil, err + } + if admin != nil { + if err := r.RegisterMiddleware("summercms.cabana", "backend", admin.Middleware); err != nil { + return nil, err + } + r.BindPlugin("summercms.cabana") + admin.Mount(r) + } for _, rt := range r.routes { if _, err := r.wrap(rt); err != nil { return nil, err