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
+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(", ")