feat: register the Users admin screen with the privileged-member guard
- permissions (the four Winter codes and manage_privileged_groups), the user navigation item and the embedded admin YAML - Users controller: list with deactivated users, row states, the groups, created date and activated filters, and the form's name, surname and email - a member of a group in golem15.user.privileged_groups (default admin) keeps their email, password and row unless the administrator holds golem15.users.manage_privileged_groups; the refusal is a 403 that saves nothing
This commit is contained in:
36
admin.go
Normal file
36
admin.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// adminFS holds the admin YAML and partials, file by file: the controllers
|
||||
// directory also holds files that are not admin assets.
|
||||
//
|
||||
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml models/user/fields.yaml models/user/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
// AdminControllers hands the controllers a lazy accessor of the booted app,
|
||||
// so they read the database handle and the config at request time.
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminAssets = (*Plugin)(nil)
|
||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||
_ pact.HasPermissions = (*Plugin)(nil)
|
||||
_ pact.HasNavigation = (*Plugin)(nil)
|
||||
)
|
||||
31
admin_navigation.go
Normal file
31
admin_navigation.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Navigation is the plugin's Winter registerNavigation() entry: the main item
|
||||
// user (order 555) with its side menu. Controller names an admin controller
|
||||
// id in place of a backend URL, and icons are lucide names.
|
||||
func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
return []pact.NavigationItem{
|
||||
{
|
||||
Code: "user",
|
||||
Label: "golem15.user::lang.users.menu_label",
|
||||
Icon: "user",
|
||||
Permissions: []string{"golem15.users.*"},
|
||||
Order: 555,
|
||||
Controller: "golem15.user.users",
|
||||
SideMenu: []pact.NavigationItem{
|
||||
{
|
||||
Code: "users",
|
||||
Label: "golem15.user::lang.users.menu_label",
|
||||
Icon: "user",
|
||||
Permissions: []string{controllers.PermissionAccessUsers},
|
||||
Controller: "golem15.user.users",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
49
admin_permissions.go
Normal file
49
admin_permissions.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
const userPermissionTab = "golem15.user::lang.plugin.tab"
|
||||
|
||||
// Permissions is the plugin's Winter registerPermissions() catalog plus one
|
||||
// code of its own. The four PHP codes default to the developer role;
|
||||
// impersonate_user is registered so imported backend roles that name it stay
|
||||
// valid, although no screen uses it. manage_privileged_groups has no default
|
||||
// role: only a superuser, or a role that was granted it explicitly, may
|
||||
// change the email or password of a privileged-group member or delete one.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{
|
||||
Code: controllers.PermissionAccessUsers,
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.access_users",
|
||||
Roles: []string{"developer"},
|
||||
},
|
||||
{
|
||||
Code: "golem15.users.access_groups",
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.access_groups",
|
||||
Roles: []string{"developer"},
|
||||
},
|
||||
{
|
||||
Code: "golem15.users.access_settings",
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.access_settings",
|
||||
Roles: []string{"developer"},
|
||||
},
|
||||
{
|
||||
Code: "golem15.users.impersonate_user",
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.impersonate_user",
|
||||
Roles: []string{"developer"},
|
||||
},
|
||||
{
|
||||
Code: classes.PermissionManagePrivilegedGroups,
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.manage_privileged_groups",
|
||||
},
|
||||
}
|
||||
}
|
||||
48
classes/admin_actions.go
Normal file
48
classes/admin_actions.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ThrottleStates reads the ban and suspension state of userIDs from
|
||||
// user_throttle in one query. A user is banned when any of their rows is
|
||||
// banned, and suspended when a row is suspended and its suspended_at lies
|
||||
// inside the suspension window (golem15.user.throttle.suspension_minutes,
|
||||
// read from ctx as ContextWithThrottle stores it). It is a pure read: an
|
||||
// expired suspension is not lifted here.
|
||||
func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, suspended map[uint]bool, err error) {
|
||||
banned, suspended = map[uint]bool{}, map[uint]bool{}
|
||||
if db == nil {
|
||||
return nil, nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return banned, suspended, nil
|
||||
}
|
||||
minutes := throttleFrom(ctx).minutes
|
||||
if minutes <= 0 {
|
||||
minutes = 15
|
||||
}
|
||||
window := time.Duration(minutes) * time.Minute
|
||||
var rows []models.Throttle
|
||||
err = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
for _, row := range rows {
|
||||
if row.IsBanned {
|
||||
banned[row.UserID] = true
|
||||
}
|
||||
if row.IsSuspended && row.SuspendedAt != nil && now.Before(row.SuspendedAt.Add(window)) {
|
||||
suspended[row.UserID] = true
|
||||
}
|
||||
}
|
||||
return banned, suspended, nil
|
||||
}
|
||||
118
classes/privileged.go
Normal file
118
classes/privileged.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionManagePrivilegedGroups is the backend permission an administrator
|
||||
// needs, on top of golem15.users.access_users, to change the email or the
|
||||
// password of a member of a privileged group, or to delete such a user for
|
||||
// good.
|
||||
const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups"
|
||||
|
||||
// privilegedGroupsKey is the config list of privileged group codes.
|
||||
const privilegedGroupsKey = "golem15.user.privileged_groups"
|
||||
|
||||
// defaultPrivilegedGroup is the only privileged code when the config names
|
||||
// none: the site-admin group.
|
||||
const defaultPrivilegedGroup = "admin"
|
||||
|
||||
// PrivilegedGroupCodes returns the group codes whose members are protected,
|
||||
// read from golem15.user.privileged_groups on every call so a config change
|
||||
// applies without a restart. A nil config or a missing key gives the single
|
||||
// code admin. Blank entries are dropped; a key that is present and empty
|
||||
// means that no group is privileged.
|
||||
func PrivilegedGroupCodes(cfg *compass.Config) []string {
|
||||
if cfg == nil {
|
||||
return []string{defaultPrivilegedGroup}
|
||||
}
|
||||
raw, ok := cfg.Lookup(privilegedGroupsKey)
|
||||
if !ok {
|
||||
return []string{defaultPrivilegedGroup}
|
||||
}
|
||||
var entries []string
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
entries = v
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
entries = append(entries, s)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// An environment override arrives as one comma-separated string.
|
||||
entries = strings.Split(v, ",")
|
||||
}
|
||||
codes := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if code := strings.TrimSpace(entry); code != "" {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// IsPrivilegedCode reports whether a group's code is in codes. Codes compare
|
||||
// case-sensitively, as HasGroupCode does, and a group without a code is
|
||||
// never privileged.
|
||||
func IsPrivilegedCode(codes []string, code *string) bool {
|
||||
if code == nil || *code == "" {
|
||||
return false
|
||||
}
|
||||
for _, privileged := range codes {
|
||||
if privileged == *code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PrivilegedGroupIDs returns the ids of the user_groups rows whose code is in
|
||||
// codes, ordered by id.
|
||||
func PrivilegedGroupIDs(ctx context.Context, db *gorm.DB, codes []string) ([]uint, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
ids := []uint{}
|
||||
if len(codes) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Table("user_groups").
|
||||
Where("code IN ?", codes).
|
||||
Order("id").
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// IsPrivilegedMember reports whether the user belongs to at least one group
|
||||
// whose code is in codes. A user in no group, and a group without a code,
|
||||
// give false. A deactivated user's membership counts: it survives the soft
|
||||
// delete.
|
||||
func IsPrivilegedMember(ctx context.Context, db *gorm.DB, codes []string, userID uint) (bool, error) {
|
||||
if db == nil {
|
||||
return false, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
if len(codes) == 0 || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
held, err := UserGroupCodes(ctx, db.Session(&gorm.Session{NewDB: true}), userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for i := range held {
|
||||
if IsPrivilegedCode(codes, &held[i]) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -22,3 +22,5 @@ password:
|
||||
min_length: 8
|
||||
api_tokens:
|
||||
prefix: usr_
|
||||
privileged_groups:
|
||||
- admin
|
||||
|
||||
28
controllers/admin_registry.go
Normal file
28
controllers/admin_registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// AdminControllers is the plugin's admin controller list. app returns the
|
||||
// booted application; it is asked on every call, so a controller never keeps
|
||||
// a database handle, a config or a mailer of its own.
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
usersAdminController{app: app},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
var _ pact.AdminController = usersAdminController{}
|
||||
var _ pact.AdminPermissioned = usersAdminController{}
|
||||
var _ pact.AdminRecordSource = usersAdminController{}
|
||||
var _ pact.ListExtendQuery = usersAdminController{}
|
||||
var _ pact.FormExtendQuery = usersAdminController{}
|
||||
var _ pact.ListRowStates = usersAdminController{}
|
||||
var _ pact.FormRules = usersAdminController{}
|
||||
var _ pact.FilterOptions = usersAdminController{}
|
||||
var _ pact.FormBeforeUpdate = usersAdminController{}
|
||||
var _ pact.FormBeforeDelete = usersAdminController{}
|
||||
}
|
||||
23
controllers/request_db.go
Normal file
23
controllers/request_db.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// requestDB is the database handle a hook or scope should read through. Inside
|
||||
// an admin write it is the transaction the write runs in, so the hook sees the
|
||||
// write's snapshot and does not take a second pool connection while the
|
||||
// transaction holds row locks. Outside a transaction (the list) it is the
|
||||
// application pool from the booted app. It is nil when neither exists.
|
||||
func requestDB(ctx context.Context, pool func() *gorm.DB) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return pool()
|
||||
}
|
||||
14
controllers/users/config_filter.yaml
Normal file
14
controllers/users/config_filter.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
scopes:
|
||||
groups:
|
||||
label: golem15.user::lang.group.label
|
||||
modelClass: Golem15\User\Models\UserGroup
|
||||
nameFrom: name
|
||||
scope: filterByGroup
|
||||
created_date:
|
||||
label: golem15.user::lang.user.created_at
|
||||
type: daterange
|
||||
column: created_at
|
||||
activated:
|
||||
label: golem15.user::lang.user.status_activated
|
||||
type: switch
|
||||
column: is_activated
|
||||
14
controllers/users/config_form.yaml
Normal file
14
controllers/users/config_form.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: golem15.user::lang.user.label
|
||||
form: ~/plugins/golem15/user/models/user/fields.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
defaultRedirect: golem15/user/users
|
||||
|
||||
update:
|
||||
redirect: golem15/user/users/update/:id
|
||||
redirectClose: golem15/user/users
|
||||
|
||||
messages:
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.user::lang.users.delete_confirm
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
20
controllers/users/config_list.yaml
Normal file
20
controllers/users/config_list.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
list: ~/plugins/golem15/user/models/user/columns.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
title: golem15.user::lang.users.list_title
|
||||
recordUrl: golem15/user/users/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
toolbar:
|
||||
buttons: []
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
empty: golem15.user::lang.users.list_empty
|
||||
rowStateDeleted: golem15.user::lang.users.state_deactivated
|
||||
rowStateNegative: golem15.user::lang.users.state_banned
|
||||
rowStateDisabled: golem15.user::lang.users.state_not_activated
|
||||
239
controllers/users_admin_controller.go
Normal file
239
controllers/users_admin_controller.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionAccessUsers is the backend permission of the Users screen, the
|
||||
// PHP Users::$requiredPermissions.
|
||||
const PermissionAccessUsers = "golem15.users.access_users"
|
||||
|
||||
// usersLang prefixes the phrase keys of the Users screen.
|
||||
const usersLang = "golem15.user::lang.users."
|
||||
|
||||
// usersAdminController is the Winter Users backend controller: the list with
|
||||
// its filters and row states, and the form. The screen and every write need
|
||||
// golem15.users.access_users.
|
||||
//
|
||||
// app is resolved per call from the booted plugin. Nothing is cached: the
|
||||
// database handle, the config and the privileged group list are read when a
|
||||
// request needs them.
|
||||
type usersAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (usersAdminController) ID() string { return "golem15.user.users" }
|
||||
|
||||
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
|
||||
|
||||
func (usersAdminController) ConfigDir() string { return "controllers/users" }
|
||||
|
||||
func (usersAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessUsers}
|
||||
}
|
||||
|
||||
func (usersAdminController) NewRecord() any { return &models.User{} }
|
||||
|
||||
// application returns the booted app, or nil before boot.
|
||||
func (c usersAdminController) application() *backpack.App {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
return c.app()
|
||||
}
|
||||
|
||||
// db is the application pool, or nil when the app has none.
|
||||
func (c usersAdminController) db() *gorm.DB {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
// config is the application config, or nil before boot.
|
||||
func (c usersAdminController) config() *compass.Config {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Config
|
||||
}
|
||||
|
||||
// ListExtendQuery and FormExtendQuery include deactivated (soft-deleted)
|
||||
// users, the PHP listExtendQuery and formExtendQuery withTrashed().
|
||||
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
return db.Unscoped()
|
||||
}
|
||||
|
||||
func (usersAdminController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
return db.Unscoped()
|
||||
}
|
||||
|
||||
// ListRowStates is the PHP listInjectRowClass: deleted for a deactivated
|
||||
// user, negative for a banned one, disabled for one who is not activated.
|
||||
// The ban state of the whole page comes from one user_throttle query.
|
||||
func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
|
||||
ids := make([]uint, 0, len(records))
|
||||
for _, record := range records {
|
||||
user, ok := record.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return nil, errors.New("users admin: list record is not a user")
|
||||
}
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([][]pact.RowState, len(records))
|
||||
for i, record := range records {
|
||||
user := record.(*models.User)
|
||||
if user.DeletedAt.Valid {
|
||||
out[i] = append(out[i], pact.RowStateDeleted)
|
||||
}
|
||||
if banned[user.ID] {
|
||||
out[i] = append(out[i], pact.RowStateNegative)
|
||||
}
|
||||
if !user.IsActivated {
|
||||
out[i] = append(out[i], pact.RowStateDisabled)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormRules are the rules of an admin save. They replace User.Rules(), which
|
||||
// is the register contract and would ask every admin update for a confirmed
|
||||
// password.
|
||||
func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string {
|
||||
return map[string]string{
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
}
|
||||
}
|
||||
|
||||
// FilterOptions serves the choices of the groups filter (scope
|
||||
// filterByGroup): every user group by name. Any other scope has no choices.
|
||||
func (c usersAdminController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != models.FilterByGroup {
|
||||
return nil
|
||||
}
|
||||
db := c.db()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var groups []models.UserGroup
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&groups).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(group.ID), 10), Label: group.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardPrivilegedMember protects the account of a privileged-group member
|
||||
// against takeover. It answers nil when the administrator on ctx holds
|
||||
// golem15.users.manage_privileged_groups, or when the user is not a member of
|
||||
// a group whose code is in golem15.user.privileged_groups; otherwise it
|
||||
// refuses with a 403 carrying message and details. Membership is read
|
||||
// through the write's transaction. A failed query is returned as it is.
|
||||
func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID uint, message string, details map[string]any) error {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
|
||||
return nil
|
||||
}
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
privileged, err := classes.IsPrivilegedMember(ctx, db, classes.PrivilegedGroupCodes(c.config()), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !privileged {
|
||||
return nil
|
||||
}
|
||||
return &cabana.ForbiddenError{Message: message, Details: details}
|
||||
}
|
||||
|
||||
// FormBeforeUpdate starts with the takeover guard: a changed email or a
|
||||
// submitted password on a member of a privileged group needs
|
||||
// golem15.users.manage_privileged_groups. Everything else an update does
|
||||
// comes after it.
|
||||
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
if err := c.guardCredentials(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardCredentials compares the email the save would write with the stored
|
||||
// one and looks for a submitted password, then asks guardPrivilegedMember
|
||||
// for the fields that would change.
|
||||
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
var stored []string
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
return errors.New("users admin: the user being updated was not found")
|
||||
}
|
||||
const message = usersLang + "privileged_member_forbidden"
|
||||
details := map[string]any{}
|
||||
if stored[0] != user.Email {
|
||||
details["email"] = []string{message}
|
||||
}
|
||||
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
|
||||
if password, _ := values["password"].(string); password != "" {
|
||||
details["password"] = []string{message}
|
||||
}
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.guardPrivilegedMember(ctx, user.ID, message, details)
|
||||
}
|
||||
|
||||
// FormBeforeDelete refuses the delete of a privileged-group member without
|
||||
// golem15.users.manage_privileged_groups. The framework calls it for the
|
||||
// form's delete and once per record inside the bulk delete's transaction, so
|
||||
// one refused record rolls the whole selection back.
|
||||
func (c usersAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
return c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_delete_forbidden", nil)
|
||||
}
|
||||
@@ -1,3 +1,34 @@
|
||||
account:
|
||||
invalid_login: Invalid email or password
|
||||
no_avatar: Your account has no display picture to remove.
|
||||
plugin:
|
||||
tab: Users
|
||||
access_users: Manage Users
|
||||
access_groups: Manage User Groups
|
||||
access_settings: Manage User Settings
|
||||
impersonate_user: Impersonate Users
|
||||
manage_privileged_groups: Manage privileged group membership
|
||||
users:
|
||||
menu_label: Users
|
||||
list_title: Manage Users
|
||||
list_empty: No users match this view. Clear the filters, or add one with New User.
|
||||
state_deactivated: Deactivated
|
||||
state_banned: Banned
|
||||
state_not_activated: Not activated
|
||||
delete_confirm: Do you really want to delete this user? This user will be permanently removed and unrecoverable.
|
||||
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
|
||||
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
|
||||
user:
|
||||
label: User
|
||||
id: ID
|
||||
name: Name
|
||||
surname: Surname
|
||||
email: Email
|
||||
account: Account
|
||||
created_at: Registered
|
||||
last_seen: Last seen
|
||||
created_ip_address: Created IP Address
|
||||
last_ip_address: Last IP Address
|
||||
status_activated: Activated
|
||||
group:
|
||||
label: Group
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
account:
|
||||
invalid_login: Nieprawidłowy email lub hasło
|
||||
no_avatar: Brak avatara
|
||||
plugin:
|
||||
tab: Użytkownicy
|
||||
access_users: Zarządzaj użytkownikami
|
||||
access_groups: Zarządzaj grupami użytkowników
|
||||
access_settings: Zarządzaj ustawieniami użytkownika
|
||||
impersonate_user: Wcielaj się w użytkowników
|
||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||
users:
|
||||
menu_label: Użytkownicy
|
||||
list_title: Zarządzaj użytkownikami
|
||||
list_empty: Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik.
|
||||
state_deactivated: Dezaktywowany
|
||||
state_banned: Zbanowany
|
||||
state_not_activated: Nieaktywowany
|
||||
delete_confirm: Czy na pewno chcesz usunąć tego użytkownika? Zostanie trwale usunięty bez możliwości odzyskania.
|
||||
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
|
||||
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
|
||||
user:
|
||||
label: Użytkownik
|
||||
id: ID
|
||||
name: Imię / nick
|
||||
surname: Nazwisko
|
||||
email: Email
|
||||
account: Konto
|
||||
created_at: Zarejestrowany
|
||||
last_seen: Ostatnio widziany
|
||||
created_ip_address: Stworzono z IP
|
||||
last_ip_address: Ostatnie IP
|
||||
status_activated: Aktywowany
|
||||
group:
|
||||
label: Grupa
|
||||
|
||||
28
models/user/columns.yaml
Normal file
28
models/user/columns.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
columns:
|
||||
id:
|
||||
label: golem15.user::lang.user.id
|
||||
invisible: true
|
||||
name:
|
||||
label: golem15.user::lang.user.name
|
||||
searchable: true
|
||||
surname:
|
||||
label: golem15.user::lang.user.surname
|
||||
searchable: true
|
||||
invisible: true
|
||||
email:
|
||||
label: golem15.user::lang.user.email
|
||||
searchable: true
|
||||
created_at:
|
||||
label: golem15.user::lang.user.created_at
|
||||
type: datetime
|
||||
last_seen:
|
||||
label: golem15.user::lang.user.last_seen
|
||||
type: datetime
|
||||
created_ip_address:
|
||||
label: golem15.user::lang.user.created_ip_address
|
||||
searchable: true
|
||||
invisible: true
|
||||
last_ip_address:
|
||||
label: golem15.user::lang.user.last_ip_address
|
||||
searchable: true
|
||||
invisible: true
|
||||
16
models/user/fields.yaml
Normal file
16
models/user/fields.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
fields:
|
||||
name:
|
||||
label: golem15.user::lang.user.name
|
||||
type: text
|
||||
span: left
|
||||
tab: golem15.user::lang.user.account
|
||||
surname:
|
||||
label: golem15.user::lang.user.surname
|
||||
type: text
|
||||
span: right
|
||||
tab: golem15.user::lang.user.account
|
||||
email:
|
||||
label: golem15.user::lang.user.email
|
||||
type: text
|
||||
span: full
|
||||
tab: golem15.user::lang.user.account
|
||||
@@ -34,7 +34,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
sessionSecret = "summercms-test-only-hs256-secret"
|
||||
sessionSecret = "summercms-test-only-hs256-secret"
|
||||
// adminHarnessSecret signs backend tokens in tests that mount the admin API.
|
||||
adminHarnessSecret = "summercms-test-only-admin-hs256-secret"
|
||||
invalidLoginBody = `{"error":true,"message":"Invalid email or password"}`
|
||||
prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
|
||||
)
|
||||
@@ -43,7 +45,10 @@ var (
|
||||
sessionOnce sync.Once
|
||||
sessionGDB *gorm.DB
|
||||
sessionSQL *sql.DB
|
||||
sessionErr error
|
||||
// sessionDSN is the container's connection string; the admin harness
|
||||
// opens its own database in the same container with it.
|
||||
sessionDSN string
|
||||
sessionErr error
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
@@ -507,6 +512,9 @@ func sessionConfig(t *testing.T) *compass.Config {
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret,
|
||||
// The plugin registers admin controllers, so assembling its
|
||||
// routes mounts the admin API, which needs its own secret.
|
||||
"SUMMER_ADMIN__JWT__SECRET=" + adminHarnessSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -538,6 +546,7 @@ func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
sessionDSN = dsn
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
sessionErr = err
|
||||
|
||||
Reference in New Issue
Block a user