mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(key): extend key metadata and validation
This commit is contained in:
@@ -425,13 +425,31 @@ func (h *AdminHandler) IssueChildKeyForMaster(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, req.Group, req.Scopes)
|
modelLimits := strings.TrimSpace(req.ModelLimits)
|
||||||
|
modelLimitsEnabled := false
|
||||||
|
if req.ModelLimitsEnabled != nil {
|
||||||
|
modelLimitsEnabled = *req.ModelLimitsEnabled
|
||||||
|
} else if modelLimits != "" {
|
||||||
|
modelLimitsEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, service.IssueKeyOptions{
|
||||||
|
Group: strings.TrimSpace(req.Group),
|
||||||
|
Scopes: strings.TrimSpace(req.Scopes),
|
||||||
|
ModelLimits: modelLimits,
|
||||||
|
ModelLimitsEnabled: modelLimitsEnabled,
|
||||||
|
ExpiresAt: req.ExpiresAt,
|
||||||
|
AllowIPs: strings.TrimSpace(req.AllowIPs),
|
||||||
|
DenyIPs: strings.TrimSpace(req.DenyIPs),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrMasterNotFound):
|
case errors.Is(err, service.ErrMasterNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrMasterNotActive):
|
case errors.Is(err, service.ErrMasterNotActive):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrModelLimitForbidden):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrChildKeyGroupForbidden):
|
case errors.Is(err, service.ErrChildKeyGroupForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrChildKeyLimitReached):
|
case errors.Is(err, service.ErrChildKeyLimitReached):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ez-api/ez-api/internal/model"
|
"github.com/ez-api/ez-api/internal/model"
|
||||||
"github.com/ez-api/ez-api/internal/service"
|
"github.com/ez-api/ez-api/internal/service"
|
||||||
@@ -25,6 +26,11 @@ func NewMasterHandler(db *gorm.DB, masterService *service.MasterService, syncSer
|
|||||||
type IssueChildKeyRequest struct {
|
type IssueChildKeyRequest struct {
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Scopes string `json:"scopes"`
|
Scopes string `json:"scopes"`
|
||||||
|
ModelLimits string `json:"model_limits,omitempty"`
|
||||||
|
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
AllowIPs string `json:"allow_ips,omitempty"`
|
||||||
|
DenyIPs string `json:"deny_ips,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueChildKey godoc
|
// IssueChildKey godoc
|
||||||
@@ -68,13 +74,31 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, group, req.Scopes)
|
modelLimits := strings.TrimSpace(req.ModelLimits)
|
||||||
|
modelLimitsEnabled := false
|
||||||
|
if req.ModelLimitsEnabled != nil {
|
||||||
|
modelLimitsEnabled = *req.ModelLimitsEnabled
|
||||||
|
} else if modelLimits != "" {
|
||||||
|
modelLimitsEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, service.IssueKeyOptions{
|
||||||
|
Group: group,
|
||||||
|
Scopes: strings.TrimSpace(req.Scopes),
|
||||||
|
ModelLimits: modelLimits,
|
||||||
|
ModelLimitsEnabled: modelLimitsEnabled,
|
||||||
|
ExpiresAt: req.ExpiresAt,
|
||||||
|
AllowIPs: strings.TrimSpace(req.AllowIPs),
|
||||||
|
DenyIPs: strings.TrimSpace(req.DenyIPs),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrMasterNotFound):
|
case errors.Is(err, service.ErrMasterNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrMasterNotActive):
|
case errors.Is(err, service.ErrMasterNotActive):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrModelLimitForbidden):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrChildKeyGroupForbidden):
|
case errors.Is(err, service.ErrChildKeyGroupForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrChildKeyLimitReached):
|
case errors.Is(err, service.ErrChildKeyLimitReached):
|
||||||
@@ -126,6 +150,18 @@ type TokenView struct {
|
|||||||
IssuedAtEpoch int64 `json:"issued_at_epoch"`
|
IssuedAtEpoch int64 `json:"issued_at_epoch"`
|
||||||
DefaultNamespace string `json:"default_namespace"`
|
DefaultNamespace string `json:"default_namespace"`
|
||||||
Namespaces string `json:"namespaces"`
|
Namespaces string `json:"namespaces"`
|
||||||
|
ModelLimits string `json:"model_limits,omitempty"`
|
||||||
|
ModelLimitsEnabled bool `json:"model_limits_enabled"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
AllowIPs string `json:"allow_ips,omitempty"`
|
||||||
|
DenyIPs string `json:"deny_ips,omitempty"`
|
||||||
|
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
UsedTokens int64 `json:"used_tokens"`
|
||||||
|
QuotaLimit int64 `json:"quota_limit"`
|
||||||
|
QuotaUsed int64 `json:"quota_used"`
|
||||||
|
QuotaResetAt *time.Time `json:"quota_reset_at,omitempty"`
|
||||||
|
QuotaResetType string `json:"quota_reset_type,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -140,6 +176,18 @@ func toTokenView(k model.Key) TokenView {
|
|||||||
IssuedAtEpoch: k.IssuedAtEpoch,
|
IssuedAtEpoch: k.IssuedAtEpoch,
|
||||||
DefaultNamespace: strings.TrimSpace(k.DefaultNamespace),
|
DefaultNamespace: strings.TrimSpace(k.DefaultNamespace),
|
||||||
Namespaces: strings.TrimSpace(k.Namespaces),
|
Namespaces: strings.TrimSpace(k.Namespaces),
|
||||||
|
ModelLimits: strings.TrimSpace(k.ModelLimits),
|
||||||
|
ModelLimitsEnabled: k.ModelLimitsEnabled,
|
||||||
|
ExpiresAt: k.ExpiresAt,
|
||||||
|
AllowIPs: strings.TrimSpace(k.AllowIPs),
|
||||||
|
DenyIPs: strings.TrimSpace(k.DenyIPs),
|
||||||
|
LastAccessedAt: k.LastAccessedAt,
|
||||||
|
RequestCount: k.RequestCount,
|
||||||
|
UsedTokens: k.UsedTokens,
|
||||||
|
QuotaLimit: k.QuotaLimit,
|
||||||
|
QuotaUsed: k.QuotaUsed,
|
||||||
|
QuotaResetAt: k.QuotaResetAt,
|
||||||
|
QuotaResetType: strings.TrimSpace(k.QuotaResetType),
|
||||||
CreatedAt: k.CreatedAt.UTC().Unix(),
|
CreatedAt: k.CreatedAt.UTC().Unix(),
|
||||||
UpdatedAt: k.UpdatedAt.UTC().Unix(),
|
UpdatedAt: k.UpdatedAt.UTC().Unix(),
|
||||||
}
|
}
|
||||||
@@ -214,6 +262,11 @@ func (h *MasterHandler) GetToken(c *gin.Context) {
|
|||||||
type UpdateTokenRequest struct {
|
type UpdateTokenRequest struct {
|
||||||
Scopes *string `json:"scopes,omitempty"`
|
Scopes *string `json:"scopes,omitempty"`
|
||||||
Status *string `json:"status,omitempty"` // active/suspended
|
Status *string `json:"status,omitempty"` // active/suspended
|
||||||
|
ModelLimits *string `json:"model_limits,omitempty"`
|
||||||
|
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
AllowIPs *string `json:"allow_ips,omitempty"`
|
||||||
|
DenyIPs *string `json:"deny_ips,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateToken godoc
|
// UpdateToken godoc
|
||||||
@@ -256,6 +309,31 @@ func (h *MasterHandler) UpdateToken(c *gin.Context) {
|
|||||||
if req.Scopes != nil {
|
if req.Scopes != nil {
|
||||||
update["scopes"] = strings.TrimSpace(*req.Scopes)
|
update["scopes"] = strings.TrimSpace(*req.Scopes)
|
||||||
}
|
}
|
||||||
|
if req.ModelLimits != nil {
|
||||||
|
modelLimits := strings.TrimSpace(*req.ModelLimits)
|
||||||
|
if modelLimits != "" {
|
||||||
|
if err := h.masterService.ValidateModelLimits(m, modelLimits); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update["model_limits"] = modelLimits
|
||||||
|
if req.ModelLimitsEnabled == nil {
|
||||||
|
update["model_limits_enabled"] = modelLimits != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ModelLimitsEnabled != nil {
|
||||||
|
update["model_limits_enabled"] = *req.ModelLimitsEnabled
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
update["expires_at"] = req.ExpiresAt
|
||||||
|
}
|
||||||
|
if req.AllowIPs != nil {
|
||||||
|
update["allow_ips"] = strings.TrimSpace(*req.AllowIPs)
|
||||||
|
}
|
||||||
|
if req.DenyIPs != nil {
|
||||||
|
update["deny_ips"] = strings.TrimSpace(*req.DenyIPs)
|
||||||
|
}
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
st := strings.ToLower(strings.TrimSpace(*req.Status))
|
st := strings.ToLower(strings.TrimSpace(*req.Status))
|
||||||
if st != "active" && st != "suspended" {
|
if st != "active" && st != "suspended" {
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ type Key struct {
|
|||||||
IssuedAtEpoch int64 `gorm:"not null" json:"issued_at_epoch"` // copy of master epoch at issuance
|
IssuedAtEpoch int64 `gorm:"not null" json:"issued_at_epoch"` // copy of master epoch at issuance
|
||||||
Status string `gorm:"size:50;default:'active'" json:"status"` // active, suspended
|
Status string `gorm:"size:50;default:'active'" json:"status"` // active, suspended
|
||||||
IssuedBy string `gorm:"size:20;default:'master'" json:"issued_by"`
|
IssuedBy string `gorm:"size:20;default:'master'" json:"issued_by"`
|
||||||
|
ModelLimits string `gorm:"size:2048" json:"model_limits"`
|
||||||
|
ModelLimitsEnabled bool `gorm:"default:false" json:"model_limits_enabled"`
|
||||||
|
ExpiresAt *time.Time `gorm:"index" json:"expires_at"`
|
||||||
|
AllowIPs string `gorm:"size:1024" json:"allow_ips"`
|
||||||
|
DenyIPs string `gorm:"size:1024" json:"deny_ips"`
|
||||||
|
LastAccessedAt *time.Time `json:"last_accessed_at"`
|
||||||
|
RequestCount int64 `gorm:"default:0" json:"request_count"`
|
||||||
|
UsedTokens int64 `gorm:"default:0" json:"used_tokens"`
|
||||||
|
QuotaLimit int64 `gorm:"default:-1" json:"quota_limit"`
|
||||||
|
QuotaUsed int64 `gorm:"default:0" json:"quota_used"`
|
||||||
|
QuotaResetAt *time.Time `json:"quota_reset_at"`
|
||||||
|
QuotaResetType string `gorm:"size:20" json:"quota_reset_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider remains the same.
|
// Provider remains the same.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ez-api/ez-api/internal/model"
|
"github.com/ez-api/ez-api/internal/model"
|
||||||
"github.com/ez-api/foundation/tokenhash"
|
"github.com/ez-api/foundation/tokenhash"
|
||||||
@@ -18,6 +19,7 @@ var (
|
|||||||
ErrMasterNotActive = errors.New("master is not active")
|
ErrMasterNotActive = errors.New("master is not active")
|
||||||
ErrChildKeyLimitReached = errors.New("child key limit reached")
|
ErrChildKeyLimitReached = errors.New("child key limit reached")
|
||||||
ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group")
|
ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group")
|
||||||
|
ErrModelLimitForbidden = errors.New("model not in master's accessible models")
|
||||||
)
|
)
|
||||||
|
|
||||||
type MasterService struct {
|
type MasterService struct {
|
||||||
@@ -100,15 +102,25 @@ verified:
|
|||||||
return &master, nil
|
return &master, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) {
|
type IssueKeyOptions struct {
|
||||||
return s.issueChildKey(masterID, group, scopes, "master")
|
Group string
|
||||||
|
Scopes string
|
||||||
|
ModelLimits string
|
||||||
|
ModelLimitsEnabled bool
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
AllowIPs string
|
||||||
|
DenyIPs string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, group string, scopes string) (*model.Key, string, error) {
|
func (s *MasterService) IssueChildKey(masterID uint, opts IssueKeyOptions) (*model.Key, string, error) {
|
||||||
return s.issueChildKey(masterID, group, scopes, "admin")
|
return s.issueChildKey(masterID, opts, "master")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MasterService) issueChildKey(masterID uint, group string, scopes string, issuedBy string) (*model.Key, string, error) {
|
func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, opts IssueKeyOptions) (*model.Key, string, error) {
|
||||||
|
return s.issueChildKey(masterID, opts, "admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MasterService) issueChildKey(masterID uint, opts IssueKeyOptions, issuedBy string) (*model.Key, string, error) {
|
||||||
var master model.Master
|
var master model.Master
|
||||||
if err := s.db.First(&master, masterID).Error; err != nil {
|
if err := s.db.First(&master, masterID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -120,7 +132,7 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string
|
|||||||
return nil, "", fmt.Errorf("%w", ErrMasterNotActive)
|
return nil, "", fmt.Errorf("%w", ErrMasterNotActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = strings.TrimSpace(group)
|
group := strings.TrimSpace(opts.Group)
|
||||||
if group == "" {
|
if group == "" {
|
||||||
group = master.Group
|
group = master.Group
|
||||||
}
|
}
|
||||||
@@ -146,17 +158,26 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string
|
|||||||
return nil, "", fmt.Errorf("failed to hash child key: %w", err)
|
return nil, "", fmt.Errorf("failed to hash child key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.ValidateModelLimits(&master, opts.ModelLimits); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
key := &model.Key{
|
key := &model.Key{
|
||||||
MasterID: masterID,
|
MasterID: masterID,
|
||||||
KeySecret: string(hashedChildKey),
|
KeySecret: string(hashedChildKey),
|
||||||
TokenHash: tokenHash,
|
TokenHash: tokenHash,
|
||||||
Group: group,
|
Group: group,
|
||||||
Scopes: scopes,
|
Scopes: strings.TrimSpace(opts.Scopes),
|
||||||
DefaultNamespace: strings.TrimSpace(master.DefaultNamespace),
|
DefaultNamespace: strings.TrimSpace(master.DefaultNamespace),
|
||||||
Namespaces: strings.TrimSpace(master.Namespaces),
|
Namespaces: strings.TrimSpace(master.Namespaces),
|
||||||
IssuedAtEpoch: master.Epoch,
|
IssuedAtEpoch: master.Epoch,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
IssuedBy: strings.TrimSpace(issuedBy),
|
IssuedBy: strings.TrimSpace(issuedBy),
|
||||||
|
ModelLimits: strings.TrimSpace(opts.ModelLimits),
|
||||||
|
ModelLimitsEnabled: opts.ModelLimitsEnabled,
|
||||||
|
ExpiresAt: opts.ExpiresAt,
|
||||||
|
AllowIPs: strings.TrimSpace(opts.AllowIPs),
|
||||||
|
DenyIPs: strings.TrimSpace(opts.DenyIPs),
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(key.DefaultNamespace) == "" {
|
if strings.TrimSpace(key.DefaultNamespace) == "" {
|
||||||
key.DefaultNamespace = "default"
|
key.DefaultNamespace = "default"
|
||||||
@@ -175,6 +196,53 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string
|
|||||||
return key, rawChildKey, nil
|
return key, rawChildKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MasterService) ValidateModelLimits(master *model.Master, limits string) error {
|
||||||
|
if master == nil {
|
||||||
|
return fmt.Errorf("master is required")
|
||||||
|
}
|
||||||
|
requested := splitList(limits)
|
||||||
|
if len(requested) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
namespaces := normalizeNamespaces(master.Namespaces, master.DefaultNamespace)
|
||||||
|
if len(namespaces) == 0 {
|
||||||
|
return fmt.Errorf("master has no namespaces configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindings []model.Binding
|
||||||
|
if err := s.db.Where("namespace IN ?", namespaces).Find(&bindings).Error; err != nil {
|
||||||
|
return fmt.Errorf("load bindings: %w", err)
|
||||||
|
}
|
||||||
|
allowedBindings := make(map[string]struct{}, len(bindings))
|
||||||
|
allowedPublic := make(map[string]struct{}, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
if !bindingActive(b.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ns := strings.TrimSpace(b.Namespace)
|
||||||
|
pm := strings.TrimSpace(b.PublicModel)
|
||||||
|
if ns == "" || pm == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowedBindings[ns+"."+pm] = struct{}{}
|
||||||
|
allowedPublic[pm] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range requested {
|
||||||
|
if strings.Contains(m, ".") {
|
||||||
|
if _, ok := allowedBindings[m]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", ErrModelLimitForbidden, m)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowedPublic[m]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", ErrModelLimitForbidden, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func generateRandomKey(length int) (string, error) {
|
func generateRandomKey(length int) (string, error) {
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
@@ -182,3 +250,68 @@ func generateRandomKey(length int) (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(bytes), nil
|
return hex.EncodeToString(bytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDefaultNamespace(ns string) string {
|
||||||
|
ns = strings.TrimSpace(ns)
|
||||||
|
if ns == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNamespaces(raw string, defaultNamespace string) []string {
|
||||||
|
defaultNamespace = normalizeDefaultNamespace(defaultNamespace)
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
var parts []string
|
||||||
|
if raw != "" {
|
||||||
|
parts = strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == ';' || r == '\t' || r == '\n'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(parts)+1)
|
||||||
|
seen := make(map[string]struct{}, len(parts)+1)
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
if _, ok := seen[defaultNamespace]; !ok {
|
||||||
|
out = append(out, defaultNamespace)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitList(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == ';' || r == '\t' || r == '\n'
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindingActive(status string) bool {
|
||||||
|
status = strings.ToLower(strings.TrimSpace(status))
|
||||||
|
return status == "" || status == "active"
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ func TestMasterService_IssueChildKey_RespectsLimit(t *testing.T) {
|
|||||||
t.Fatalf("CreateMaster: %v", err)
|
t.Fatalf("CreateMaster: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, raw1, err := svc.IssueChildKey(m.ID, "default", "chat:write")
|
_, raw1, err := svc.IssueChildKey(m.ID, IssueKeyOptions{
|
||||||
|
Group: "default",
|
||||||
|
Scopes: "chat:write",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueChildKey #1: %v", err)
|
t.Fatalf("IssueChildKey #1: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,10 @@ func TestMasterService_IssueChildKey_RespectsLimit(t *testing.T) {
|
|||||||
t.Fatalf("expected raw child key")
|
t.Fatalf("expected raw child key")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = svc.IssueChildKey(m.ID, "default", "chat:write")
|
_, _, err = svc.IssueChildKey(m.ID, IssueKeyOptions{
|
||||||
|
Group: "default",
|
||||||
|
Scopes: "chat:write",
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected child key limit error")
|
t.Fatalf("expected child key limit error")
|
||||||
}
|
}
|
||||||
@@ -76,7 +82,9 @@ func TestMasterService_IssueChildKeyAsAdmin_SetsIssuedBy(t *testing.T) {
|
|||||||
t.Fatalf("CreateMaster: %v", err)
|
t.Fatalf("CreateMaster: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key, raw, err := svc.IssueChildKeyAsAdmin(m.ID, "", "chat:write")
|
key, raw, err := svc.IssueChildKeyAsAdmin(m.ID, IssueKeyOptions{
|
||||||
|
Scopes: "chat:write",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueChildKeyAsAdmin: %v", err)
|
t.Fatalf("IssueChildKeyAsAdmin: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ func (s *SyncService) SyncKey(key *model.Key) error {
|
|||||||
"scopes": key.Scopes,
|
"scopes": key.Scopes,
|
||||||
"default_namespace": key.DefaultNamespace,
|
"default_namespace": key.DefaultNamespace,
|
||||||
"namespaces": key.Namespaces,
|
"namespaces": key.Namespaces,
|
||||||
|
"model_limits": strings.TrimSpace(key.ModelLimits),
|
||||||
|
"model_limits_enabled": key.ModelLimitsEnabled,
|
||||||
|
"expires_at": unixOrZero(key.ExpiresAt),
|
||||||
|
"allow_ips": strings.TrimSpace(key.AllowIPs),
|
||||||
|
"deny_ips": strings.TrimSpace(key.DenyIPs),
|
||||||
|
"last_accessed_at": unixOrZero(key.LastAccessedAt),
|
||||||
|
"request_count": key.RequestCount,
|
||||||
|
"used_tokens": key.UsedTokens,
|
||||||
|
"quota_limit": key.QuotaLimit,
|
||||||
|
"quota_used": key.QuotaUsed,
|
||||||
|
"quota_reset_at": unixOrZero(key.QuotaResetAt),
|
||||||
|
"quota_reset_type": strings.TrimSpace(key.QuotaResetType),
|
||||||
}
|
}
|
||||||
if err := s.rdb.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), fields).Err(); err != nil {
|
if err := s.rdb.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), fields).Err(); err != nil {
|
||||||
return fmt.Errorf("write auth token: %w", err)
|
return fmt.Errorf("write auth token: %w", err)
|
||||||
@@ -269,6 +281,18 @@ func (s *SyncService) SyncAll(db *gorm.DB) error {
|
|||||||
"scopes": k.Scopes,
|
"scopes": k.Scopes,
|
||||||
"default_namespace": k.DefaultNamespace,
|
"default_namespace": k.DefaultNamespace,
|
||||||
"namespaces": k.Namespaces,
|
"namespaces": k.Namespaces,
|
||||||
|
"model_limits": strings.TrimSpace(k.ModelLimits),
|
||||||
|
"model_limits_enabled": k.ModelLimitsEnabled,
|
||||||
|
"expires_at": unixOrZero(k.ExpiresAt),
|
||||||
|
"allow_ips": strings.TrimSpace(k.AllowIPs),
|
||||||
|
"deny_ips": strings.TrimSpace(k.DenyIPs),
|
||||||
|
"last_accessed_at": unixOrZero(k.LastAccessedAt),
|
||||||
|
"request_count": k.RequestCount,
|
||||||
|
"used_tokens": k.UsedTokens,
|
||||||
|
"quota_limit": k.QuotaLimit,
|
||||||
|
"quota_used": k.QuotaUsed,
|
||||||
|
"quota_reset_at": unixOrZero(k.QuotaResetAt),
|
||||||
|
"quota_reset_type": strings.TrimSpace(k.QuotaResetType),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +488,13 @@ func normalizeStatus(status string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unixOrZero(t *time.Time) int64 {
|
||||||
|
if t == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.UTC().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SyncService) refreshModelsMetaFromRedis(ctx context.Context, source string) error {
|
func (s *SyncService) refreshModelsMetaFromRedis(ctx context.Context, source string) error {
|
||||||
raw, err := s.rdb.HGetAll(ctx, "meta:models").Result()
|
raw, err := s.rdb.HGetAll(ctx, "meta:models").Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user