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) }