Files
summercms/modules/cabana/contracts.go
Jakub Zych 530b02af9c feat(19.6.1-01): administrator lifecycle and last-superuser protection
- activate, deactivate and restore as record and bulk actions; delete is
  the soft delete through deleted_at, never a hard delete
- deleted admins listed and opened (unscoped), deleted/deactivated row
  states, Deleted filter (hide / only), preview status hint
- deactivate and delete bump tokens_valid_after; activate and restore do not
- self delete and self deactivate refused; outranking and last active
  superuser guarded on delete, deactivate and bulk rows (whole selection
  rolls back)
2026-10-22 11:13:26 +02:00

407 lines
15 KiB
Go

package cabana
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// 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" }
// Fillable lists the role columns the admin's Roles screen writes from a
// form: name, code and description. permissions and is_system are never
// filled (the permission editor stores the grants, and is_system only comes
// from migrations).
func (BackendUserRole) Fillable() []string { return []string{"name", "code", "description"} }
// Rules are the role's admin save rules: a name unique among roles and a
// code. The code's uniqueness is checked by the Roles screen itself.
func (BackendUserRole) Rules() map[string]string {
return map[string]string{
"name": "required|max:255|unique:backend_user_roles",
"code": "required|max:255",
}
}
// BackendUser is the Winter backend_users row. It is not a frontend user.
// Nullable Winter columns are mapped so a copied row round-trips without a
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
// Metadata is Winter's jsonable metadata column kept as raw text, so a copied
// value that is not a JSON object (NULL, blank or []) still loads; read it
// through Subtitle.
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"`
ActivationCode string `gorm:"column:activation_code"`
PersistCode string `gorm:"column:persist_code"`
ResetPasswordCode string `gorm:"column:reset_password_code"`
Permissions string `gorm:"column:permissions"`
Metadata *string `gorm:"column:metadata"`
IsActivated bool `gorm:"column:is_activated"`
IsSuperuser bool `gorm:"column:is_superuser"`
RoleID *uint `gorm:"column:role_id"`
ActivatedAt *time.Time `gorm:"column:activated_at"`
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"`
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
Role BackendUserRole
// priorRoleID is the stored role_id the Administrators screen saw
// before a save assigned the submitted role (priorLoaded set), so the
// save can refuse a change of the editor's own role after the
// assignment. Unexported: GORM and the admin's field binding skip it.
priorRoleID *uint
priorLoaded bool
}
func (BackendUser) TableName() string { return "backend_users" }
// Fillable lists the columns the admin's Administrators screen writes from a
// form: login, email, first and last name. Role, permissions, the superuser
// flag, activation and the password are protected and written only by the
// screen's own checks.
func (BackendUser) Fillable() []string {
return []string{"login", "email", "first_name", "last_name"}
}
// The Administrators list filter scopes: filterByRole keeps the holders of
// one role, filterTrashed shows or hides deleted administrators.
const (
filterByRole = "filterByRole"
filterTrashed = "filterTrashed"
)
// The filterTrashed values.
const (
trashedHide = "hide"
trashedOnly = "only"
)
// FilterScopes lists the scope names the Administrators list filters call.
func (BackendUser) FilterScopes() []string { return []string{filterByRole, filterTrashed} }
// FilterScope narrows db for the scope name. filterByRole keeps the holders
// of the role whose id is value; a value that is not a role id matches
// nothing. filterTrashed with "hide" leaves deleted administrators out and
// with "only" keeps only them; any other value does not narrow. Any other
// name matches nothing; a nil db is returned as it is.
func (BackendUser) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
if name == filterTrashed {
switch strings.TrimSpace(fmt.Sprint(value)) {
case trashedHide:
return db.Where("backend_users.deleted_at IS NULL")
case trashedOnly:
return db.Where("backend_users.deleted_at IS NOT NULL")
}
return db
}
if name != filterByRole {
return db.Where("1 = 0")
}
id, ok := filterID(value)
if !ok {
return db.Where("1 = 0")
}
return db.Where("backend_users.role_id = ?", id)
}
// filterID reads a filter value as a positive whole number, given as a
// number or as its decimal text.
func filterID(value any) (uint64, bool) {
if value == nil {
return 0, false
}
text := strings.TrimSpace(fmt.Sprint(value))
id, err := strconv.ParseUint(text, 10, 32)
if err != nil || id == 0 {
return 0, false
}
return id, true
}
// Rules are the administrator's admin save rules. The uniqueness of login
// and email, deleted administrators included and ignoring case, is checked
// by the Administrators screen itself.
func (BackendUser) Rules() map[string]string {
return map[string]string{
"login": "required|max:255",
"email": "required|email|max:255",
}
}
const (
// BackendUserMorphType is the system_files attachment_type of a backend
// user's files: WinterCMS's class string, so avatar rows copied from
// WinterCMS match as they are.
BackendUserMorphType = "Backend\\Models\\User"
// BackendUserAvatarField is the attachOne relation (system_files.field)
// of a backend user's avatar.
BackendUserAvatarField = "avatar"
)
// MorphName implements attach.Owner with WinterCMS's class string.
func (BackendUser) MorphName() string { return BackendUserMorphType }
// AttachRelations implements attach.HasRelations: one public attachOne
// avatar, as WinterCMS's Backend\Models\User declares it.
func (BackendUser) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: BackendUserAvatarField, Public: true}}
}
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
// all read as an empty subtitle.
func (u BackendUser) Subtitle() string { return metadataString(u.Metadata, metadataSubtitle) }
// Option is a dropdown choice shared with later schema plans.
type Option = pact.Option
// WritableField is one schema field bound to a model fill key at activation.
// FillKey comes from the model column, not from request JSON.
type WritableField struct {
Name string
FillKey string
}
// CompiledController is one admin controller after YAML compilation.
type CompiledController struct {
PluginID string
Controller pact.AdminController
List *ListSchema
Form *FormSchema
Relations map[string]*CompiledRelation
Writable []WritableField
// FieldRelations are the form's `type: relation` fields keyed by field name.
FieldRelations map[string]*CompiledFieldRelation
// Actions are the controller's pact.HasAdminActions entries keyed by name:
// the single namespace that toolbar.buttons names and widget action: keys
// resolve through. create and delete are reserved built-in names.
Actions map[string]pact.AdminAction
// BulkActions are the controller's pact.HasAdminBulkActions entries keyed
// by name. They have their own namespace next to Actions; create and
// delete are reserved there too.
BulkActions map[string]pact.AdminBulkAction
// RecordActions are the controller's pact.HasAdminRecordActions entries
// keyed by name, a third namespace with the same reserved names.
RecordActions map[string]pact.AdminRecordAction
// contributed maps the name of each record action another plugin added
// through pact.HasAdminActionExtensions to that plugin's ID.
contributed map[string]string
// scripts and styles are the controller's declared plugin JS and CSS,
// in declared order.
scripts []*pluginAsset
styles []*pluginAsset
// partials are the parsed controller partials (headerPartial and every
// `type: partial` path) keyed by name; formPartials are the names form
// fields declare, the only ones a request may render with a record id.
partials map[string]*compiledPartial
formPartials map[string]bool
// files are the form's `type: fileupload` fields bound to the model's
// attach.Relation, keyed by field name.
files map[string]*compiledFile
// dates are the form's `type: datepicker` fields after the Go type
// check, keyed by field name.
dates map[string]*compiledDate
// virtual is the set of form field names the controller lists through
// pact.FormVirtualFields: never bound, filled or projected.
virtual map[string]bool
// permissions are the form's `type: permissioneditor` fields: field name
// to mode (radio or checkbox).
permissions map[string]string
// reorder is the compiled config_reorder.yaml; nil without one (D-09).
reorder *compiledReorder
// singleton is true for a controller implementing pact.AdminSingleton
// (D-11): one record, opened directly; no list, create, bulk or delete.
singleton bool
}
// Registry is the immutable controller map keyed by controller ID.
type Registry struct {
byID map[string]*CompiledController
permissions map[string]pact.Permission
// permissionOrder holds the permission codes in registration order
// (plugin activation order, then each plugin's declared order).
permissionOrder []string
roleGrants map[string]map[string]bool
navigation []pact.NavigationItem
settings map[string]*CompiledSetting
// assets are every controller's declared plugin files keyed by URL tail
// (vendor/plugin/<path after assets/>); the asset route serves only these.
assets map[string]*pluginAsset
// fieldTypes are the plugin field types of this boot
// (pact.HasFormFieldTypes); nil when no plugin registers one.
fieldTypes *fieldTypeRegistry
// tokenScopes is the compiled personal API token catalog (* , admin:read,
// then plugin scopes in activation order).
tokenScopes *tokenScopeCatalog
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
func (r *Registry) Get(id string) (*CompiledController, bool) {
if r == nil {
return nil, false
}
cc, ok := r.byID[id]
return cc, ok && cc != nil
}
// Setting returns one compiled singleton setting by stable code. Link entries
// (pact.SettingsItem with Controller set) are not singletons and are not
// returned, so the singleton settings endpoints answer 404 for their codes.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil {
return nil, false
}
setting, ok := r.settings[code]
if !ok || setting == nil || setting.Item.Controller != "" {
return nil, false
}
return setting, true
}
// Allows reports whether principal satisfies any of the required permission
// codes, the way Winter's hasAnyAccess does. A nil principal fails. When the
// principal carries a non-full personal API token scope limit, the scope is
// evaluated first (owner ∩ scope); a superuser with a scoped token is still
// limited by the scope, and an empty requirement list is refused. Otherwise
// superusers pass and an empty requirement list allows any authenticated
// principal. Both sides may use a wildcard: a grant ending in ".*" covers
// every code with that prefix, and a required code ending in ".*" (or
// starting with "*") is met by any granted code that matches it.
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.Scope != nil && !principal.Scope.Full {
return allowsUnderScope(principal, required)
}
if principal.IsSuperuser || len(required) == 0 {
return true
}
for _, code := range required {
if granted(principal.PermissionGrants, code) {
return true
}
}
return false
}
// granted ports Winter's User::hasPermission for one code. Only enabled
// grants are in the map, so the "(int) $value === 1" test is already applied.
func granted(grants map[string]bool, code string) bool {
switch {
case len(code) > 1 && strings.HasSuffix(code, "*"):
prefix := strings.TrimSuffix(code, "*")
for key, on := range grants {
if on && key != prefix && strings.HasPrefix(key, prefix) {
return true
}
}
return false
case len(code) > 1 && strings.HasPrefix(code, "*"):
suffix := strings.TrimPrefix(code, "*")
for key, on := range grants {
if on && key != suffix && strings.HasSuffix(key, suffix) {
return true
}
}
return false
}
if grants[code] {
return true
}
for key, on := range grants {
if !on || len(key) < 2 || !strings.HasSuffix(key, "*") {
continue
}
prefix := strings.TrimSuffix(key, "*")
if prefix != code && 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) {
WriteErrorDetails(w, status, code, message, nil)
}
// WriteErrorDetails writes a D-10 error envelope with field messages.
func WriteErrorDetails(w http.ResponseWriter, status int, code, message string, details map[string]any) {
if details == nil {
details = map[string]any{}
}
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": code,
"message": message,
"details": details,
},
})
}
// writeJSON encodes body before the status line goes out, so a value that
// cannot be encoded (NaN, an infinity, a failing MarshalJSON) is a logged 500
// with the generic body instead of a 200 with a truncated one.
func writeJSON(w http.ResponseWriter, status int, body any) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
moduleLog.Error("cabana: response could not be encoded", "status", status, "error", err)
buf.Reset()
_ = json.NewEncoder(&buf).Encode(map[string]any{
"error": map[string]any{"code": "error", "message": msgServerError, "details": map[string]any{}},
})
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}