feat(api-key): 添加 IP 白名单/黑名单限制功能 (#221)

* feat(api-key): add IP whitelist/blacklist restriction and usage log IP tracking

- Add IP restriction feature for API keys (whitelist/blacklist with CIDR support)
- Add IP address logging to usage logs (admin-only visibility)
- Remove billing_type column from usage logs UI (redundant)
- Use generic "Access denied" error message for security

Backend:
- New ip package with IP/CIDR validation and matching utilities
- Database migrations for ip_whitelist, ip_blacklist (api_keys) and ip_address (usage_logs)
- Middleware IP restriction check after API key validation
- Input validation for IP/CIDR patterns on create/update

Frontend:
- API key form with enable toggle for IP restriction
- Shield icon indicator in table for keys with IP restriction
- Removed billing_type filter and column from usage views

* fix: update API contract tests for ip_whitelist/ip_blacklist fields

Add ip_whitelist and ip_blacklist fields to expected JSON responses
in API contract tests to match the new API key schema.
This commit is contained in:
Edric.Li
2026-01-09 21:59:32 +08:00
committed by GitHub
parent 62dc0b953b
commit 0a4641c24e
45 changed files with 1500 additions and 183 deletions

View File

@@ -54,26 +54,30 @@ const (
// APIKeyMutation represents an operation that mutates the APIKey nodes in the graph.
type APIKeyMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
key *string
name *string
status *string
clearedFields map[string]struct{}
user *int64
cleareduser bool
group *int64
clearedgroup bool
usage_logs map[int64]struct{}
removedusage_logs map[int64]struct{}
clearedusage_logs bool
done bool
oldValue func(context.Context) (*APIKey, error)
predicates []predicate.APIKey
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
key *string
name *string
status *string
ip_whitelist *[]string
appendip_whitelist []string
ip_blacklist *[]string
appendip_blacklist []string
clearedFields map[string]struct{}
user *int64
cleareduser bool
group *int64
clearedgroup bool
usage_logs map[int64]struct{}
removedusage_logs map[int64]struct{}
clearedusage_logs bool
done bool
oldValue func(context.Context) (*APIKey, error)
predicates []predicate.APIKey
}
var _ ent.Mutation = (*APIKeyMutation)(nil)
@@ -488,6 +492,136 @@ func (m *APIKeyMutation) ResetStatus() {
m.status = nil
}
// SetIPWhitelist sets the "ip_whitelist" field.
func (m *APIKeyMutation) SetIPWhitelist(s []string) {
m.ip_whitelist = &s
m.appendip_whitelist = nil
}
// IPWhitelist returns the value of the "ip_whitelist" field in the mutation.
func (m *APIKeyMutation) IPWhitelist() (r []string, exists bool) {
v := m.ip_whitelist
if v == nil {
return
}
return *v, true
}
// OldIPWhitelist returns the old "ip_whitelist" field's value of the APIKey entity.
// If the APIKey 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 *APIKeyMutation) OldIPWhitelist(ctx context.Context) (v []string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldIPWhitelist is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldIPWhitelist requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldIPWhitelist: %w", err)
}
return oldValue.IPWhitelist, nil
}
// AppendIPWhitelist adds s to the "ip_whitelist" field.
func (m *APIKeyMutation) AppendIPWhitelist(s []string) {
m.appendip_whitelist = append(m.appendip_whitelist, s...)
}
// AppendedIPWhitelist returns the list of values that were appended to the "ip_whitelist" field in this mutation.
func (m *APIKeyMutation) AppendedIPWhitelist() ([]string, bool) {
if len(m.appendip_whitelist) == 0 {
return nil, false
}
return m.appendip_whitelist, true
}
// ClearIPWhitelist clears the value of the "ip_whitelist" field.
func (m *APIKeyMutation) ClearIPWhitelist() {
m.ip_whitelist = nil
m.appendip_whitelist = nil
m.clearedFields[apikey.FieldIPWhitelist] = struct{}{}
}
// IPWhitelistCleared returns if the "ip_whitelist" field was cleared in this mutation.
func (m *APIKeyMutation) IPWhitelistCleared() bool {
_, ok := m.clearedFields[apikey.FieldIPWhitelist]
return ok
}
// ResetIPWhitelist resets all changes to the "ip_whitelist" field.
func (m *APIKeyMutation) ResetIPWhitelist() {
m.ip_whitelist = nil
m.appendip_whitelist = nil
delete(m.clearedFields, apikey.FieldIPWhitelist)
}
// SetIPBlacklist sets the "ip_blacklist" field.
func (m *APIKeyMutation) SetIPBlacklist(s []string) {
m.ip_blacklist = &s
m.appendip_blacklist = nil
}
// IPBlacklist returns the value of the "ip_blacklist" field in the mutation.
func (m *APIKeyMutation) IPBlacklist() (r []string, exists bool) {
v := m.ip_blacklist
if v == nil {
return
}
return *v, true
}
// OldIPBlacklist returns the old "ip_blacklist" field's value of the APIKey entity.
// If the APIKey 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 *APIKeyMutation) OldIPBlacklist(ctx context.Context) (v []string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldIPBlacklist is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldIPBlacklist requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldIPBlacklist: %w", err)
}
return oldValue.IPBlacklist, nil
}
// AppendIPBlacklist adds s to the "ip_blacklist" field.
func (m *APIKeyMutation) AppendIPBlacklist(s []string) {
m.appendip_blacklist = append(m.appendip_blacklist, s...)
}
// AppendedIPBlacklist returns the list of values that were appended to the "ip_blacklist" field in this mutation.
func (m *APIKeyMutation) AppendedIPBlacklist() ([]string, bool) {
if len(m.appendip_blacklist) == 0 {
return nil, false
}
return m.appendip_blacklist, true
}
// ClearIPBlacklist clears the value of the "ip_blacklist" field.
func (m *APIKeyMutation) ClearIPBlacklist() {
m.ip_blacklist = nil
m.appendip_blacklist = nil
m.clearedFields[apikey.FieldIPBlacklist] = struct{}{}
}
// IPBlacklistCleared returns if the "ip_blacklist" field was cleared in this mutation.
func (m *APIKeyMutation) IPBlacklistCleared() bool {
_, ok := m.clearedFields[apikey.FieldIPBlacklist]
return ok
}
// ResetIPBlacklist resets all changes to the "ip_blacklist" field.
func (m *APIKeyMutation) ResetIPBlacklist() {
m.ip_blacklist = nil
m.appendip_blacklist = nil
delete(m.clearedFields, apikey.FieldIPBlacklist)
}
// ClearUser clears the "user" edge to the User entity.
func (m *APIKeyMutation) ClearUser() {
m.cleareduser = true
@@ -630,7 +764,7 @@ func (m *APIKeyMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *APIKeyMutation) Fields() []string {
fields := make([]string, 0, 8)
fields := make([]string, 0, 10)
if m.created_at != nil {
fields = append(fields, apikey.FieldCreatedAt)
}
@@ -655,6 +789,12 @@ func (m *APIKeyMutation) Fields() []string {
if m.status != nil {
fields = append(fields, apikey.FieldStatus)
}
if m.ip_whitelist != nil {
fields = append(fields, apikey.FieldIPWhitelist)
}
if m.ip_blacklist != nil {
fields = append(fields, apikey.FieldIPBlacklist)
}
return fields
}
@@ -679,6 +819,10 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) {
return m.GroupID()
case apikey.FieldStatus:
return m.Status()
case apikey.FieldIPWhitelist:
return m.IPWhitelist()
case apikey.FieldIPBlacklist:
return m.IPBlacklist()
}
return nil, false
}
@@ -704,6 +848,10 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldGroupID(ctx)
case apikey.FieldStatus:
return m.OldStatus(ctx)
case apikey.FieldIPWhitelist:
return m.OldIPWhitelist(ctx)
case apikey.FieldIPBlacklist:
return m.OldIPBlacklist(ctx)
}
return nil, fmt.Errorf("unknown APIKey field %s", name)
}
@@ -769,6 +917,20 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
}
m.SetStatus(v)
return nil
case apikey.FieldIPWhitelist:
v, ok := value.([]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetIPWhitelist(v)
return nil
case apikey.FieldIPBlacklist:
v, ok := value.([]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetIPBlacklist(v)
return nil
}
return fmt.Errorf("unknown APIKey field %s", name)
}
@@ -808,6 +970,12 @@ func (m *APIKeyMutation) ClearedFields() []string {
if m.FieldCleared(apikey.FieldGroupID) {
fields = append(fields, apikey.FieldGroupID)
}
if m.FieldCleared(apikey.FieldIPWhitelist) {
fields = append(fields, apikey.FieldIPWhitelist)
}
if m.FieldCleared(apikey.FieldIPBlacklist) {
fields = append(fields, apikey.FieldIPBlacklist)
}
return fields
}
@@ -828,6 +996,12 @@ func (m *APIKeyMutation) ClearField(name string) error {
case apikey.FieldGroupID:
m.ClearGroupID()
return nil
case apikey.FieldIPWhitelist:
m.ClearIPWhitelist()
return nil
case apikey.FieldIPBlacklist:
m.ClearIPBlacklist()
return nil
}
return fmt.Errorf("unknown APIKey nullable field %s", name)
}
@@ -860,6 +1034,12 @@ func (m *APIKeyMutation) ResetField(name string) error {
case apikey.FieldStatus:
m.ResetStatus()
return nil
case apikey.FieldIPWhitelist:
m.ResetIPWhitelist()
return nil
case apikey.FieldIPBlacklist:
m.ResetIPBlacklist()
return nil
}
return fmt.Errorf("unknown APIKey field %s", name)
}
@@ -8396,6 +8576,7 @@ type UsageLogMutation struct {
first_token_ms *int
addfirst_token_ms *int
user_agent *string
ip_address *string
image_count *int
addimage_count *int
image_size *string
@@ -9801,6 +9982,55 @@ func (m *UsageLogMutation) ResetUserAgent() {
delete(m.clearedFields, usagelog.FieldUserAgent)
}
// SetIPAddress sets the "ip_address" field.
func (m *UsageLogMutation) SetIPAddress(s string) {
m.ip_address = &s
}
// IPAddress returns the value of the "ip_address" field in the mutation.
func (m *UsageLogMutation) IPAddress() (r string, exists bool) {
v := m.ip_address
if v == nil {
return
}
return *v, true
}
// OldIPAddress returns the old "ip_address" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldIPAddress(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldIPAddress is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldIPAddress requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldIPAddress: %w", err)
}
return oldValue.IPAddress, nil
}
// ClearIPAddress clears the value of the "ip_address" field.
func (m *UsageLogMutation) ClearIPAddress() {
m.ip_address = nil
m.clearedFields[usagelog.FieldIPAddress] = struct{}{}
}
// IPAddressCleared returns if the "ip_address" field was cleared in this mutation.
func (m *UsageLogMutation) IPAddressCleared() bool {
_, ok := m.clearedFields[usagelog.FieldIPAddress]
return ok
}
// ResetIPAddress resets all changes to the "ip_address" field.
func (m *UsageLogMutation) ResetIPAddress() {
m.ip_address = nil
delete(m.clearedFields, usagelog.FieldIPAddress)
}
// SetImageCount sets the "image_count" field.
func (m *UsageLogMutation) SetImageCount(i int) {
m.image_count = &i
@@ -10111,7 +10341,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 28)
fields := make([]string, 0, 29)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -10187,6 +10417,9 @@ func (m *UsageLogMutation) Fields() []string {
if m.user_agent != nil {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.ip_address != nil {
fields = append(fields, usagelog.FieldIPAddress)
}
if m.image_count != nil {
fields = append(fields, usagelog.FieldImageCount)
}
@@ -10254,6 +10487,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.FirstTokenMs()
case usagelog.FieldUserAgent:
return m.UserAgent()
case usagelog.FieldIPAddress:
return m.IPAddress()
case usagelog.FieldImageCount:
return m.ImageCount()
case usagelog.FieldImageSize:
@@ -10319,6 +10554,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldFirstTokenMs(ctx)
case usagelog.FieldUserAgent:
return m.OldUserAgent(ctx)
case usagelog.FieldIPAddress:
return m.OldIPAddress(ctx)
case usagelog.FieldImageCount:
return m.OldImageCount(ctx)
case usagelog.FieldImageSize:
@@ -10509,6 +10746,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetUserAgent(v)
return nil
case usagelog.FieldIPAddress:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetIPAddress(v)
return nil
case usagelog.FieldImageCount:
v, ok := value.(int)
if !ok {
@@ -10782,6 +11026,9 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldUserAgent) {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.FieldCleared(usagelog.FieldIPAddress) {
fields = append(fields, usagelog.FieldIPAddress)
}
if m.FieldCleared(usagelog.FieldImageSize) {
fields = append(fields, usagelog.FieldImageSize)
}
@@ -10814,6 +11061,9 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldUserAgent:
m.ClearUserAgent()
return nil
case usagelog.FieldIPAddress:
m.ClearIPAddress()
return nil
case usagelog.FieldImageSize:
m.ClearImageSize()
return nil
@@ -10900,6 +11150,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldUserAgent:
m.ResetUserAgent()
return nil
case usagelog.FieldIPAddress:
m.ResetIPAddress()
return nil
case usagelog.FieldImageCount:
m.ResetImageCount()
return nil