Files
summercms/modules/cabana/backend_privileges.go
Jakub Zych 88401737c9 feat(19.6.1-01): administrator form with server-side privilege rules
- create and edit administrators: password (min 4, max 72 bytes, bcrypt
  at admin.password.bcrypt_cost), role picker, radio permission overrides,
  superuser switch; no mail on create
- superusers hidden from non-superusers in list, lookups and schemas;
  is_superuser projected to superusers so an untouched save keeps it
- save-path rules: superuser-only switch, role and permission ceilings,
  no outranking, no own role/flag/overrides/password change, last active
  superuser not demoted (advisory lock + count in the write tx)
- password set for another admin bumps tokens_valid_after
- role, superuser, activation and last sign-in list filters
2026-10-22 11:08:12 +02:00

185 lines
6.1 KiB
Go

package cabana
import (
"context"
"errors"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
// superuserLockKey is the pg_advisory_xact_lock key every write that could
// shrink the set of active superusers takes before it counts the others
// (guardLastSuperuser). It is an arbitrary fixed bigint ("summ" "su" in
// ASCII); nothing else in the framework uses it. The lock is held until the
// write transaction ends.
const superuserLockKey int64 = 0x73756d6d00007375
// actsAsSuperuser reports whether principal holds superuser powers on the
// Administrators and Roles screens: a superuser signed in with a session or
// with a personal API token of full scope. A superuser's scoped token never
// gets superuser-only powers, although Allows lets it through its scope.
func actsAsSuperuser(p *bouncer.Principal) bool {
return p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)
}
// roleGrantSet is everything role grants: its stored permissions JSON plus
// the code-declared grants for its code, the merge FindByID applies.
func roleGrantSet(reg *Registry, role BackendUserRole) map[string]bool {
out := map[string]bool{}
for code := range parseGrants(role.Permissions) {
out[code] = true
}
for code := range reg.rolePermissions(role.Code) {
out[code] = true
}
return out
}
// effectiveGrants is the grants user holds: the role's stored and
// code-declared grants with the user's own permission overrides applied
// last, as FindByID merges them. The role is loaded into its own value
// through a fresh session on db, never onto user: GORM's Save would rewrite
// role_id from a loaded association.
func effectiveGrants(ctx context.Context, db *gorm.DB, reg *Registry, user *BackendUser) (map[string]bool, error) {
if user == nil {
return map[string]bool{}, nil
}
grants := map[string]bool{}
if user.RoleID != nil && *user.RoleID != 0 {
if db == nil {
return nil, errors.New("cabana: effective grants need a database handle")
}
var role BackendUserRole
if err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Where("id = ?", *user.RoleID).Limit(1).Find(&role).Error; err != nil {
return nil, err
}
if role.ID != 0 {
grants = roleGrantSet(reg, role)
}
}
out := applyUserPermissions(grants, user.Permissions)
if out == nil {
out = map[string]bool{}
}
return out, nil
}
// wildcardGrant reports whether code is a wildcard grant ("acme.*" or
// "*.manage").
func wildcardGrant(code string) bool {
return len(code) > 1 && (strings.HasSuffix(code, "*") || strings.HasPrefix(code, "*"))
}
// grantsCovered reports whether holder holds everything wanted grants. A
// plain code must pass granted(holder, code). A wildcard grant in wanted is
// covered only by the same wildcard in holder or, for a prefix wildcard
// ("acme.blog.*"), a broader prefix wildcard ("acme.*"); it is never handed
// to granted, where a wildcard means "any matching grant".
func grantsCovered(holder, wanted map[string]bool) bool {
for code, on := range wanted {
if !on {
continue
}
if !wildcardGrant(code) {
if !granted(holder, code) {
return false
}
continue
}
if holder[code] {
continue
}
if !strings.HasSuffix(code, "*") {
return false
}
prefix := strings.TrimSuffix(code, "*")
covered := false
for key, held := range holder {
if !held || len(key) < 2 || !strings.HasSuffix(key, "*") {
continue
}
if strings.HasPrefix(prefix, strings.TrimSuffix(key, "*")) {
covered = true
break
}
}
if !covered {
return false
}
}
return true
}
// guardOutranked refuses a change to target by an editor who does not hold
// every grant target holds (D-19): updating, deleting, or running an action
// on such an administrator would let the editor take over more privilege
// than they have. Superusers and the editor's own record pass; a superuser
// target is always out of reach for a non-superuser. The target's grants are
// read through the write transaction on ctx.
func guardOutranked(ctx context.Context, reg *Registry, editor *bouncer.Principal, target *BackendUser) error {
if actsAsSuperuser(editor) || target == nil {
return nil
}
if editor != nil && editor.ID == target.ID {
return nil
}
refused := &ForbiddenError{Message: "backend::lang.user.outranked"}
if editor == nil || target.IsSuperuser {
return refused
}
tx, ok := TxFromContext(ctx)
if !ok {
return errors.New("cabana: the outranking check needs the write transaction")
}
grants, err := effectiveGrants(ctx, tx, reg, target)
if err != nil {
return err
}
if !grantsCovered(editor.PermissionGrants, grants) {
return refused
}
return nil
}
// guardLastSuperuser refuses a write that would leave no active superuser
// (D-09). It answers nil unless target is currently an activated superuser
// that is not deleted; then, inside the write transaction on ctx, it takes
// the advisory lock superuserLockKey and counts the other activated,
// undeleted superusers. Every caller holds target's row lock first and
// nothing takes a row lock after the advisory lock, so two concurrent
// reductions serialize on the lock without a wait cycle, and the second one
// counts after the first committed. Call it before the write changes target.
func guardLastSuperuser(ctx context.Context, target *BackendUser) error {
if target == nil || !target.IsSuperuser || !target.IsActivated || target.DeletedAt.Valid {
return nil
}
tx, ok := TxFromContext(ctx)
if !ok {
return errors.New("cabana: the last-superuser check needs the write transaction")
}
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
if err := db.Exec("SELECT pg_advisory_xact_lock(?)", superuserLockKey).Error; err != nil {
return err
}
var others int64
if err := db.Model(&BackendUser{}).
Where("is_superuser AND is_activated AND deleted_at IS NULL AND id <> ?", target.ID).
Count(&others).Error; err != nil {
return err
}
if others == 0 {
return &ForbiddenError{Message: "backend::lang.user.last_superuser"}
}
return nil
}
// revocationCutoff is the tokens_valid_after value that ends every session
// issued so far, the value admin:reset-password writes.
func revocationCutoff() time.Time {
return time.Now().UTC().Add(time.Second)
}