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:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/foundation/tokenhash"
|
||||
@@ -18,6 +19,7 @@ var (
|
||||
ErrMasterNotActive = errors.New("master is not active")
|
||||
ErrChildKeyLimitReached = errors.New("child key limit reached")
|
||||
ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group")
|
||||
ErrModelLimitForbidden = errors.New("model not in master's accessible models")
|
||||
)
|
||||
|
||||
type MasterService struct {
|
||||
@@ -100,15 +102,25 @@ verified:
|
||||
return &master, nil
|
||||
}
|
||||
|
||||
func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) {
|
||||
return s.issueChildKey(masterID, group, scopes, "master")
|
||||
type IssueKeyOptions struct {
|
||||
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) {
|
||||
return s.issueChildKey(masterID, group, scopes, "admin")
|
||||
func (s *MasterService) IssueChildKey(masterID uint, opts IssueKeyOptions) (*model.Key, string, error) {
|
||||
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
|
||||
if err := s.db.First(&master, masterID).Error; err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
group = strings.TrimSpace(group)
|
||||
group := strings.TrimSpace(opts.Group)
|
||||
if 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)
|
||||
}
|
||||
|
||||
if err := s.ValidateModelLimits(&master, opts.ModelLimits); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
key := &model.Key{
|
||||
MasterID: masterID,
|
||||
KeySecret: string(hashedChildKey),
|
||||
TokenHash: tokenHash,
|
||||
Group: group,
|
||||
Scopes: scopes,
|
||||
DefaultNamespace: strings.TrimSpace(master.DefaultNamespace),
|
||||
Namespaces: strings.TrimSpace(master.Namespaces),
|
||||
IssuedAtEpoch: master.Epoch,
|
||||
Status: "active",
|
||||
IssuedBy: strings.TrimSpace(issuedBy),
|
||||
MasterID: masterID,
|
||||
KeySecret: string(hashedChildKey),
|
||||
TokenHash: tokenHash,
|
||||
Group: group,
|
||||
Scopes: strings.TrimSpace(opts.Scopes),
|
||||
DefaultNamespace: strings.TrimSpace(master.DefaultNamespace),
|
||||
Namespaces: strings.TrimSpace(master.Namespaces),
|
||||
IssuedAtEpoch: master.Epoch,
|
||||
Status: "active",
|
||||
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) == "" {
|
||||
key.DefaultNamespace = "default"
|
||||
@@ -175,6 +196,53 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string
|
||||
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) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
@@ -182,3 +250,68 @@ func generateRandomKey(length int) (string, error) {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user