- 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
185 lines
6.1 KiB
Go
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)
|
|
}
|