feat(admin): let admins restrict which public groups a user may access

Public groups have always been bindable by every user: CanBindGroup returned
true for any non-exclusive group, and user_allowed_groups only ever carried the
exclusive groups an admin had granted. Admins had no way to hand a single user
a subset of the public groups short of converting a group to exclusive, which
changes it for everyone already using it.

A user now carries restrict_public_groups. While it is false, which is the
default and what every existing row migrates to, nothing changes: every public
group stays bindable. Once an admin turns it on for a user, that user's public
groups are narrowed to the ones listed in user_allowed_groups, the same table
that already gates exclusive groups.

The flag is an administrative control, so it rides on the admin user DTO only
and leaves the shape of the end-user endpoints alone.

The model plaza filter honours the flag as well, so a restricted user is not
shown groups they would be refused when binding a key. Anonymous visitors have
no user record and keep the previous view.

Enforcement rides on the existing CanBindGroup choke point, so it covers key
creation, key updates, and per-request authorization together. An API key bound
to a group that is later withdrawn stops working at request time rather than
lingering as a key that can be listed but not used.

The admin dialog gains a toggle over the public group list. Turning it off
re-checks every public group, so an admin cannot save a list that reads as
restrictive while the restriction itself is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
akihitohyh
2026-08-28 02:40:17 +08:00
co-authored by Claude Opus 5
parent efb46db0a9
commit b56c61eccd
29 changed files with 507 additions and 93 deletions
+1
View File
@@ -1801,6 +1801,7 @@ var (
{Name: "signup_source", Type: field.TypeString, Default: "email"},
{Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "restrict_public_groups", Type: field.TypeBool, Default: false},
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
{Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"},
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
+55 -1
View File
@@ -48422,6 +48422,7 @@ type UserMutation struct {
signup_source *string
last_login_at *time.Time
last_active_at *time.Time
restrict_public_groups *bool
balance_notify_enabled *bool
balance_notify_threshold_type *string
balance_notify_threshold *float64
@@ -49347,6 +49348,42 @@ func (m *UserMutation) ResetLastActiveAt() {
delete(m.clearedFields, user.FieldLastActiveAt)
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (m *UserMutation) SetRestrictPublicGroups(b bool) {
m.restrict_public_groups = &b
}
// RestrictPublicGroups returns the value of the "restrict_public_groups" field in the mutation.
func (m *UserMutation) RestrictPublicGroups() (r bool, exists bool) {
v := m.restrict_public_groups
if v == nil {
return
}
return *v, true
}
// OldRestrictPublicGroups returns the old "restrict_public_groups" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldRestrictPublicGroups(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldRestrictPublicGroups is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldRestrictPublicGroups requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldRestrictPublicGroups: %w", err)
}
return oldValue.RestrictPublicGroups, nil
}
// ResetRestrictPublicGroups resets all changes to the "restrict_public_groups" field.
func (m *UserMutation) ResetRestrictPublicGroups() {
m.restrict_public_groups = nil
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (m *UserMutation) SetBalanceNotifyEnabled(b bool) {
m.balance_notify_enabled = &b
@@ -50373,7 +50410,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 24)
fields := make([]string, 0, 25)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@@ -50428,6 +50465,9 @@ func (m *UserMutation) Fields() []string {
if m.last_active_at != nil {
fields = append(fields, user.FieldLastActiveAt)
}
if m.restrict_public_groups != nil {
fields = append(fields, user.FieldRestrictPublicGroups)
}
if m.balance_notify_enabled != nil {
fields = append(fields, user.FieldBalanceNotifyEnabled)
}
@@ -50490,6 +50530,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.LastLoginAt()
case user.FieldLastActiveAt:
return m.LastActiveAt()
case user.FieldRestrictPublicGroups:
return m.RestrictPublicGroups()
case user.FieldBalanceNotifyEnabled:
return m.BalanceNotifyEnabled()
case user.FieldBalanceNotifyThresholdType:
@@ -50547,6 +50589,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldLastLoginAt(ctx)
case user.FieldLastActiveAt:
return m.OldLastActiveAt(ctx)
case user.FieldRestrictPublicGroups:
return m.OldRestrictPublicGroups(ctx)
case user.FieldBalanceNotifyEnabled:
return m.OldBalanceNotifyEnabled(ctx)
case user.FieldBalanceNotifyThresholdType:
@@ -50694,6 +50738,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetLastActiveAt(v)
return nil
case user.FieldRestrictPublicGroups:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetRestrictPublicGroups(v)
return nil
case user.FieldBalanceNotifyEnabled:
v, ok := value.(bool)
if !ok {
@@ -50953,6 +51004,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldLastActiveAt:
m.ResetLastActiveAt()
return nil
case user.FieldRestrictPublicGroups:
m.ResetRestrictPublicGroups()
return nil
case user.FieldBalanceNotifyEnabled:
m.ResetBalanceNotifyEnabled()
return nil
+9 -5
View File
@@ -2217,24 +2217,28 @@ func init() {
user.DefaultSignupSource = userDescSignupSource.Default.(string)
// user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error)
// userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field.
userDescRestrictPublicGroups := userFields[15].Descriptor()
// user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field.
user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool)
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
userDescBalanceNotifyEnabled := userFields[15].Descriptor()
userDescBalanceNotifyEnabled := userFields[16].Descriptor()
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
// userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field.
userDescBalanceNotifyThresholdType := userFields[16].Descriptor()
userDescBalanceNotifyThresholdType := userFields[17].Descriptor()
// user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field.
user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string)
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
userDescBalanceNotifyExtraEmails := userFields[18].Descriptor()
userDescBalanceNotifyExtraEmails := userFields[19].Descriptor()
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
// userDescTotalRecharged is the schema descriptor for total_recharged field.
userDescTotalRecharged := userFields[19].Descriptor()
userDescTotalRecharged := userFields[20].Descriptor()
// user.DefaultTotalRecharged holds the default value on creation for the total_recharged field.
user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64)
// userDescRpmLimit is the schema descriptor for rpm_limit field.
userDescRpmLimit := userFields[20].Descriptor()
userDescRpmLimit := userFields[21].Descriptor()
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
+5
View File
@@ -96,6 +96,11 @@ func (User) Fields() []ent.Field {
Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
// 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为),
// 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。
field.Bool("restrict_public_groups").
Default(false),
// 余额不足通知
field.Bool("balance_notify_enabled").
Default(true),
+12 -1
View File
@@ -53,6 +53,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// LastActiveAt holds the value of the "last_active_at" field.
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
// RestrictPublicGroups holds the value of the "restrict_public_groups" field.
RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"`
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
// BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field.
@@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled:
values[i] = new(sql.NullBool)
case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
values[i] = new(sql.NullFloat64)
@@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
_m.LastActiveAt = new(time.Time)
*_m.LastActiveAt = value.Time
}
case user.FieldRestrictPublicGroups:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i])
} else if value.Valid {
_m.RestrictPublicGroups = value.Bool
}
case user.FieldBalanceNotifyEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
@@ -588,6 +596,9 @@ func (_m *User) String() string {
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("restrict_public_groups=")
builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups))
builder.WriteString(", ")
builder.WriteString("balance_notify_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
builder.WriteString(", ")
+10
View File
@@ -51,6 +51,8 @@ const (
FieldLastLoginAt = "last_login_at"
// FieldLastActiveAt holds the string denoting the last_active_at field in the database.
FieldLastActiveAt = "last_active_at"
// FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database.
FieldRestrictPublicGroups = "restrict_public_groups"
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
FieldBalanceNotifyEnabled = "balance_notify_enabled"
// FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database.
@@ -212,6 +214,7 @@ var Columns = []string{
FieldSignupSource,
FieldLastLoginAt,
FieldLastActiveAt,
FieldRestrictPublicGroups,
FieldBalanceNotifyEnabled,
FieldBalanceNotifyThresholdType,
FieldBalanceNotifyThreshold,
@@ -280,6 +283,8 @@ var (
DefaultSignupSource string
// SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
SignupSourceValidator func(string) error
// DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field.
DefaultRestrictPublicGroups bool
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
DefaultBalanceNotifyEnabled bool
// DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field.
@@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc()
}
// ByRestrictPublicGroups orders the results by the restrict_public_groups field.
func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc()
}
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
+15
View File
@@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastActiveAt, v))
}
// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ.
func RestrictPublicGroups(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
func BalanceNotifyEnabled(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
@@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldLastActiveAt))
}
// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsNEQ(v bool) predicate.User {
return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
func BalanceNotifyEnabledEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
+65
View File
@@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate {
return _c
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate {
_c.mutation.SetRestrictPublicGroups(v)
return _c
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate {
if v != nil {
_c.SetRestrictPublicGroups(*v)
}
return _c
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultSignupSource
_c.mutation.SetSignupSource(v)
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
v := user.DefaultRestrictPublicGroups
_c.mutation.SetRestrictPublicGroups(v)
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
v := user.DefaultBalanceNotifyEnabled
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -730,6 +748,9 @@ func (_c *UserCreate) check() error {
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
}
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)}
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
}
@@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldLastActiveAt, field.TypeTime, value)
_node.LastActiveAt = &value
}
if value, ok := _c.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
_node.RestrictPublicGroups = value
}
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
_node.BalanceNotifyEnabled = value
@@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert {
return u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert {
u.Set(user.FieldRestrictPublicGroups, v)
return u
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert {
u.SetExcluded(user.FieldRestrictPublicGroups)
return u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
u.Set(user.FieldBalanceNotifyEnabled, v)
@@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
+34
View File
@@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
@@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
+45 -41
View File
@@ -59,30 +59,32 @@ func NewUserHandler(
// CreateUserRequest represents admin create user request
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username"`
Notes string `json:"notes"`
Role string `json:"role" binding:"omitempty,oneof=admin user"`
Balance *float64 `json:"balance"`
Concurrency int `json:"concurrency"`
RPMLimit int `json:"rpm_limit"`
AllowedGroups []int64 `json:"allowed_groups"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username"`
Notes string `json:"notes"`
Role string `json:"role" binding:"omitempty,oneof=admin user"`
Balance *float64 `json:"balance"`
Concurrency int `json:"concurrency"`
RPMLimit int `json:"rpm_limit"`
AllowedGroups []int64 `json:"allowed_groups"`
RestrictPublicGroups bool `json:"restrict_public_groups"`
}
// UpdateUserRequest represents admin update user request
// 使用指针类型来区分"未提供"和"设置为0"
type UpdateUserRequest struct {
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"omitempty,min=6"`
Username *string `json:"username"`
Notes *string `json:"notes"`
Role string `json:"role" binding:"omitempty,oneof=admin user"`
Balance *float64 `json:"balance"`
Concurrency *int `json:"concurrency"`
RPMLimit *int `json:"rpm_limit"`
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
AllowedGroups *[]int64 `json:"allowed_groups"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"omitempty,min=6"`
Username *string `json:"username"`
Notes *string `json:"notes"`
Role string `json:"role" binding:"omitempty,oneof=admin user"`
Balance *float64 `json:"balance"`
Concurrency *int `json:"concurrency"`
RPMLimit *int `json:"rpm_limit"`
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
AllowedGroups *[]int64 `json:"allowed_groups"`
RestrictPublicGroups *bool `json:"restrict_public_groups"`
// GroupRates 用户专属分组倍率配置
// map[groupID]*rate,nil 表示删除该分组的专属倍率
GroupRates map[int64]*float64 `json:"group_rates"`
@@ -284,16 +286,17 @@ func (h *UserHandler) Create(c *gin.Context) {
}
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
Email: req.Email,
Password: req.Password,
Username: req.Username,
Notes: req.Notes,
Role: req.Role,
Balance: req.Balance,
Concurrency: req.Concurrency,
RPMLimit: req.RPMLimit,
AllowedGroups: req.AllowedGroups,
ActorAdminID: getAdminIDFromContext(c),
Email: req.Email,
Password: req.Password,
Username: req.Username,
Notes: req.Notes,
Role: req.Role,
Balance: req.Balance,
Concurrency: req.Concurrency,
RPMLimit: req.RPMLimit,
AllowedGroups: req.AllowedGroups,
RestrictPublicGroups: req.RestrictPublicGroups,
ActorAdminID: getAdminIDFromContext(c),
})
if err != nil {
response.ErrorFrom(c, err)
@@ -342,18 +345,19 @@ func (h *UserHandler) Update(c *gin.Context) {
// 使用指针类型直接传递,nil 表示未提供该字段
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
Email: req.Email,
Password: req.Password,
Username: req.Username,
Notes: req.Notes,
Role: req.Role,
Balance: req.Balance,
Concurrency: req.Concurrency,
RPMLimit: req.RPMLimit,
Status: req.Status,
AllowedGroups: req.AllowedGroups,
GroupRates: req.GroupRates,
ActorAdminID: getAdminIDFromContext(c),
Email: req.Email,
Password: req.Password,
Username: req.Username,
Notes: req.Notes,
Role: req.Role,
Balance: req.Balance,
Concurrency: req.Concurrency,
RPMLimit: req.RPMLimit,
Status: req.Status,
AllowedGroups: req.AllowedGroups,
RestrictPublicGroups: req.RestrictPublicGroups,
GroupRates: req.GroupRates,
ActorAdminID: getAdminIDFromContext(c),
})
if err != nil {
response.ErrorFrom(c, err)
+5 -4
View File
@@ -68,10 +68,11 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
return nil
}
return &AdminUser{
User: *base,
Notes: u.Notes,
LastUsedAt: u.LastUsedAt,
GroupRates: u.GroupRates,
User: *base,
Notes: u.Notes,
LastUsedAt: u.LastUsedAt,
GroupRates: u.GroupRates,
RestrictPublicGroups: u.RestrictPublicGroups,
}
}
+3
View File
@@ -48,6 +48,9 @@ type AdminUser struct {
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier
GroupRates map[int64]float64 `json:"group_rates,omitempty"`
// RestrictPublicGroups 为 true 时,该用户仅可使用 allowed_groups 中列出的
// 公开分组。这是管理侧的权限开关,不下发给用户自身的接口。
RestrictPublicGroups bool `json:"restrict_public_groups"`
}
type APIKey struct {
+15 -10
View File
@@ -15,7 +15,8 @@ import (
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
type ModelPlazaHandler struct {
plazaService *service.ModelPlazaService
apiKeyService *service.APIKeyService
@@ -127,11 +128,12 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
return
}
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedExclusive map[int64]struct{}
// allowedGroups == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedGroups map[int64]struct{}
var restrictPublicGroups bool
var userRates map[int64]float64
if authed {
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
allowedGroups, restrictPublicGroups, err = h.apiKeyService.GetUserGroupVisibility(c.Request.Context(), subject.UserID)
if err != nil {
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
response.ErrorFrom(c, err)
@@ -145,7 +147,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
}
}
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
@@ -158,18 +160,21 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
// 在广场看到自己实际绑定不了的分组。
func filterPlazaVisibleGroups(
groups []service.PlazaGroup,
allowedExclusive map[int64]struct{},
allowedGroups map[int64]struct{},
restrictPublicGroups bool,
) []service.PlazaGroup {
visible := make([]service.PlazaGroup, 0, len(groups))
for _, g := range groups {
if g.IsExclusive {
if allowedExclusive == nil {
if g.IsExclusive || (restrictPublicGroups && allowedGroups != nil) {
if allowedGroups == nil {
continue
}
if _, ok := allowedExclusive[g.ID]; !ok {
if _, ok := allowedGroups[g.ID]; !ok {
continue
}
}
@@ -25,7 +25,7 @@ func plazaGroups() []service.PlazaGroup {
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
visible := filterPlazaVisibleGroups(plazaGroups(), nil, false)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
@@ -34,7 +34,7 @@ func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
allowed := map[int64]struct{}{2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
visible := filterPlazaVisibleGroups(plazaGroups(), allowed, false)
require.Len(t, visible, 3)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
@@ -46,10 +46,33 @@ func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{}, false)
require.Len(t, visible, 2)
}
func TestFilterPlazaVisibleGroups_RestrictedUserSeesOnlyGrantedPublic(t *testing.T) {
// 开启公开分组限制后,公开分组也必须落在授权集合内,否则用户会在广场
// 看到自己实际绑定不了的分组。
allowed := map[int64]struct{}{1: {}, 2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed, true)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
// 3 是未授权的公开分组,受限后不可见;4 是未授权的专属分组,一贯不可见。
require.ElementsMatch(t, []int64{1, 2}, ids)
}
func TestFilterPlazaVisibleGroups_RestrictionDoesNotAffectAnonymous(t *testing.T) {
// 匿名没有用户记录,限制标志无从谈起,可见性必须与未受限时一致。
visible := filterPlazaVisibleGroups(plazaGroups(), nil, true)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
@@ -155,6 +155,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
user.FieldBalance,
user.FieldConcurrency,
user.FieldBalanceNotifyEnabled,
user.FieldRestrictPublicGroups,
user.FieldBalanceNotifyThresholdType,
user.FieldBalanceNotifyThreshold,
user.FieldBalanceNotifyExtraEmails,
@@ -931,6 +932,7 @@ func userEntityToService(u *dbent.User) *service.User {
TotpEnabled: u.TotpEnabled,
TotpEnabledAt: u.TotpEnabledAt,
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
RestrictPublicGroups: u.RestrictPublicGroups,
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
TotalRecharged: u.TotalRecharged,
+4
View File
@@ -153,6 +153,7 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard
SetNillableLastLoginAt(userIn.LastLoginAt).
SetNillableLastActiveAt(userIn.LastActiveAt).
SetRpmLimit(userIn.RPMLimit).
SetRestrictPublicGroups(userIn.RestrictPublicGroups).
Save(txCtx)
if err != nil {
return translatePersistenceError(err, nil, service.ErrEmailExists)
@@ -316,6 +317,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User, field
if fields.Status {
updateOp = updateOp.SetStatus(userIn.Status)
}
if fields.RestrictPublicGroups {
updateOp = updateOp.SetRestrictPublicGroups(userIn.RestrictPublicGroups)
}
if fields.BalanceNotifySettings {
updateOp = updateOp.
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
+12 -9
View File
@@ -139,15 +139,16 @@ type AdminService interface {
// CreateUserInput represents input for creating a new user via admin operations.
type CreateUserInput struct {
Email string
Password string
Username string
Notes string
Role string // 空字符串表示使用默认角色(user);合法值 admin/user
Balance *float64
Concurrency int
RPMLimit int
AllowedGroups []int64
Email string
Password string
Username string
Notes string
Role string // 空字符串表示使用默认角色(user);合法值 admin/user
Balance *float64
Concurrency int
RPMLimit int
AllowedGroups []int64
RestrictPublicGroups bool
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
ActorAdminID int64
}
@@ -163,6 +164,8 @@ type UpdateUserInput struct {
RPMLimit *int // 使用指针区分"未提供"和"设置为0"
Status string
AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组"
// RestrictPublicGroups 指针区分"未提供"和"显式开关"。
RestrictPublicGroups *bool
// GroupRates 用户专属分组倍率配置
// map[groupID]*rate,nil 表示删除该分组的专属倍率
GroupRates map[int64]*float64
+9 -1
View File
@@ -141,6 +141,8 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
RPMLimit: input.RPMLimit,
Status: StatusActive,
AllowedGroups: input.AllowedGroups,
RestrictPublicGroups: input.RestrictPublicGroups,
}
if err := user.SetPassword(input.Password); err != nil {
return nil, err
@@ -279,6 +281,12 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
fields.AllowedGroups = true
}
oldRestrictPublicGroups := user.RestrictPublicGroups
if input.RestrictPublicGroups != nil {
user.RestrictPublicGroups = *input.RestrictPublicGroups
fields.RestrictPublicGroups = true
}
if err := s.userRepo.Update(ctx, user, fields); err != nil {
return nil, err
}
@@ -299,7 +307,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
if s.authCacheInvalidator != nil {
// RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联,
// allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) {
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || user.RestrictPublicGroups != oldRestrictPublicGroups || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
}
}
@@ -41,6 +41,7 @@ type APIKeyAuthUserSnapshot struct {
Email string `json:"email"`
Username string `json:"username"`
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
RestrictPublicGroups bool `json:"restrict_public_groups"`
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"`
BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"`
@@ -360,6 +360,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
Email: apiKey.User.Email,
Username: apiKey.User.Username,
BalanceNotifyEnabled: apiKey.User.BalanceNotifyEnabled,
RestrictPublicGroups: apiKey.User.RestrictPublicGroups,
BalanceNotifyThresholdType: apiKey.User.BalanceNotifyThresholdType,
BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails,
@@ -464,6 +465,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
Email: snapshot.User.Email,
Username: snapshot.User.Username,
BalanceNotifyEnabled: snapshot.User.BalanceNotifyEnabled,
RestrictPublicGroups: snapshot.User.RestrictPublicGroups,
BalanceNotifyThresholdType: snapshot.User.BalanceNotifyThresholdType,
BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails,
+5 -4
View File
@@ -1071,20 +1071,21 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
return keys, nil
}
// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。
// GetUserGroupVisibility 返回 user_allowed_groups 授权给该用户的分组 ID 集合,
// 以及该用户是否开启了公开分组限制。开启时公开分组的可见性也要落在该集合内。
//
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) {
func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64) (map[int64]struct{}, bool, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
return nil, false, fmt.Errorf("get user: %w", err)
}
allowed := make(map[int64]struct{}, len(user.AllowedGroups))
for _, id := range user.AllowedGroups {
allowed[id] = struct{}{}
}
return allowed, nil
return allowed, user.RestrictPublicGroups, nil
}
// GetUserGroupRates 获取用户的专属分组倍率配置
+12 -6
View File
@@ -23,7 +23,11 @@ type User struct {
Concurrency int
Status string
AllowedGroups []int64
TokenVersion int64 // Incremented on password change to invalidate existing tokens
// RestrictPublicGroups narrows the public groups this user may bind to the
// ones listed in AllowedGroups. False keeps the default, where every public
// group is bindable.
RestrictPublicGroups bool
TokenVersion int64 // Incremented on password change to invalidate existing tokens
// TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived
// value expected in JWT claims and refresh-token state.
TokenVersionResolved bool
@@ -74,14 +78,16 @@ func (u *User) IsActive() bool {
// CanBindGroup checks whether a user can bind to a given group.
// For standard groups:
// - Public groups (non-exclusive): all users can bind
// - Exclusive groups: only users with the group in AllowedGroups can bind
// - Public groups (non-exclusive): bindable by every user, unless the user has
// RestrictPublicGroups set, in which case the group must be in AllowedGroups
// - Exclusive groups: only users with the group in AllowedGroups can bind
func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool {
// 公开分组(非专属):所有用户都可以绑定
if !isExclusive {
// 公开分组(非专属):默认所有用户都可以绑定;仅当该用户开启了公开分组
// 限制时,才需要落在 AllowedGroups 中。
if !isExclusive && !u.RestrictPublicGroups {
return true
}
// 专属分组:需要在 AllowedGroups 中
// 专属分组,以及受限用户的公开分组:需要在 AllowedGroups 中
for _, id := range u.AllowedGroups {
if id == groupID {
return true
@@ -0,0 +1,87 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserCanBindGroup(t *testing.T) {
const (
publicGroupA int64 = 10
publicGroupB int64 = 11
exclusiveGroupA int64 = 20
)
for _, tc := range []struct {
name string
user User
groupID int64
isExclusive bool
want bool
}{
{
name: "public group is bindable by default",
user: User{},
groupID: publicGroupA,
want: true,
},
{
name: "exclusive group needs an explicit grant",
user: User{},
groupID: exclusiveGroupA,
isExclusive: true,
want: false,
},
{
name: "granted exclusive group is bindable",
user: User{AllowedGroups: []int64{exclusiveGroupA}},
groupID: exclusiveGroupA,
isExclusive: true,
want: true,
},
{
name: "an unrestricted user keeps every public group even with a grant list",
user: User{AllowedGroups: []int64{exclusiveGroupA}},
groupID: publicGroupA,
want: true,
},
{
name: "a restricted user keeps the public groups listed for them",
user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}},
groupID: publicGroupA,
want: true,
},
{
name: "a restricted user loses the public groups not listed for them",
user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}},
groupID: publicGroupB,
want: false,
},
{
name: "a restricted user with no list keeps no public group",
user: User{RestrictPublicGroups: true},
groupID: publicGroupA,
want: false,
},
{
name: "restricting public groups does not widen exclusive access",
user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}},
groupID: exclusiveGroupA,
isExclusive: true,
want: false,
},
{
name: "a restricted user can still hold both kinds of grant",
user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA, exclusiveGroupA}},
groupID: exclusiveGroupA,
isExclusive: true,
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
user := tc.user
require.Equal(t, tc.want, user.CanBindGroup(tc.groupID, tc.isExclusive))
})
}
}
+2
View File
@@ -113,6 +113,8 @@ type UserUpdateFields struct {
BalanceNotifyExtraEmails bool
// AllowedGroups 为 true 时才同步 user_allowed_groups 关联表。
AllowedGroups bool
// RestrictPublicGroups 覆盖 restrict_public_groups 列。
RestrictPublicGroups bool
}
// BalanceChange 记录一次余额变更前后的值。
@@ -0,0 +1,7 @@
-- Per-user access control for public (non-exclusive) groups.
--
-- Public groups have always been bindable by every user. When this flag is
-- enabled for a user, the public groups they may bind are narrowed to the ones
-- listed in user_allowed_groups, which until now only carried exclusive groups.
-- The default keeps every existing user unrestricted.
ALTER TABLE users ADD COLUMN IF NOT EXISTS restrict_public_groups BOOLEAN NOT NULL DEFAULT false;