feat: scaffold ai gateway identity and design

This commit is contained in:
2026-05-09 16:01:32 +08:00
parent 6323e70e49
commit 5b20f017eb
18 changed files with 3043 additions and 372 deletions
+63 -19
View File
@@ -24,14 +24,21 @@ const (
)
type User struct {
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
TenantID string `json:"tenantId,omitempty"`
SSOID string `json:"sso_id,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeySecret string `json:"apiKeySecret,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
TenantID string `json:"tenantId,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
SSOID string `json:"sso_id,omitempty"`
Source string `json:"source,omitempty"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeySecret string `json:"apiKeySecret,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
}
type contextKey string
@@ -41,15 +48,15 @@ const userContextKey contextKey = "easyai-auth-user"
var ErrUnauthorized = errors.New("unauthorized")
type Authenticator struct {
JWTSecret string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
HTTPClient *http.Client
HTTPClient *http.Client
}
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
return &Authenticator{
JWTSecret: jwtSecret,
JWTSecret: jwtSecret,
ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"),
ServerMainInternalToken: internalToken,
HTTPClient: &http.Client{
@@ -112,14 +119,24 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
}
user := &User{
ID: stringClaim(claims, "sub"),
Username: stringClaim(claims, "username"),
Roles: stringSliceClaim(claims, "role"),
TenantID: stringClaim(claims, "tenantId"),
SSOID: stringClaim(claims, "sso_id"),
APIKeyID: stringClaim(claims, "apiKeyId"),
APIKeySecret: stringClaim(claims, "apiKeySecret"),
APIKeyName: stringClaim(claims, "apiKeyName"),
ID: stringClaim(claims, "sub"),
Username: stringClaim(claims, "username"),
Roles: stringSliceClaim(claims, "role"),
TenantID: stringClaim(claims, "tenantId"),
GatewayTenantID: stringClaim(claims, "gatewayTenantId"),
TenantKey: stringClaim(claims, "tenantKey"),
SSOID: stringClaim(claims, "sso_id"),
Source: stringClaim(claims, "source"),
GatewayUserID: stringClaim(claims, "gatewayUserId"),
UserGroupID: stringClaim(claims, "userGroupId"),
UserGroupKey: stringClaim(claims, "userGroupKey"),
UserGroupKeys: stringSliceClaim(claims, "userGroupKeys"),
APIKeyID: stringClaim(claims, "apiKeyId"),
APIKeySecret: stringClaim(claims, "apiKeySecret"),
APIKeyName: stringClaim(claims, "apiKeyName"),
}
if user.Source == "" {
user.Source = "gateway"
}
if user.ID == "" {
return nil, ErrUnauthorized
@@ -127,6 +144,30 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
return user, nil
}
func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
if ttl <= 0 {
ttl = time.Hour
}
now := time.Now()
claims := jwt.MapClaims{
"sub": user.ID,
"username": user.Username,
"role": user.Roles,
"tenantId": user.TenantID,
"gatewayTenantId": user.GatewayTenantID,
"tenantKey": user.TenantKey,
"source": user.Source,
"gatewayUserId": user.GatewayUserID,
"userGroupId": user.UserGroupID,
"userGroupKey": user.UserGroupKey,
"userGroupKeys": user.UserGroupKeys,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.JWTSecret))
}
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" {
return nil, ErrUnauthorized
@@ -154,6 +195,9 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User,
if user.ID == "" {
return nil, ErrUnauthorized
}
if user.Source == "" {
user.Source = "server-main"
}
return &user, nil
}
+23 -11
View File
@@ -8,14 +8,19 @@ import (
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
CORSAllowedOrigin string
LogLevel slog.Level
AppEnv string
HTTPAddr string
DatabaseURL string
IdentityMode string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
TaskProgressCallbackEnabled bool
TaskProgressCallbackURL string
TaskProgressCallbackTimeoutMS string
TaskProgressCallbackMaxAttempts string
CORSAllowedOrigin string
LogLevel slog.Level
}
func Load() Config {
@@ -23,14 +28,21 @@ func Load() Config {
AppEnv: env("APP_ENV", "development"),
HTTPAddr: env("HTTP_ADDR", ":8088"),
DatabaseURL: gatewayDatabaseURL(),
IdentityMode: env("IDENTITY_MODE", "hybrid"),
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
ServerMainBaseURL: strings.TrimRight(
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
"/",
),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
LogLevel: logLevel(env("LOG_LEVEL", "info")),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
),
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
LogLevel: logLevel(env("LOG_LEVEL", "info")),
}
}
+130 -3
View File
@@ -2,8 +2,10 @@ package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -12,9 +14,10 @@ import (
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"service": "easyai-ai-gateway",
"env": s.cfg.AppEnv,
"ok": true,
"service": "easyai-ai-gateway",
"env": s.cfg.AppEnv,
"identityMode": s.cfg.IdentityMode,
})
}
@@ -31,6 +34,100 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, user)
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
writeError(w, http.StatusForbidden, "local registration is disabled")
return
}
var input store.LocalRegisterInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
user, err := s.store.RegisterLocalUser(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrWeakPassword) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if errors.Is(err, store.ErrInvalidInvitation) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.logger.Error("register local user failed", "error", err)
writeError(w, http.StatusConflict, "user already exists or tenant is unavailable")
return
}
s.writeAuthResponse(w, http.StatusCreated, user)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
writeError(w, http.StatusForbidden, "local login is disabled")
return
}
var input store.LocalLoginInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
user, err := s.store.AuthenticateLocalUser(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "invalid account or password")
return
}
s.logger.Error("login local user failed", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
s.writeAuthResponse(w, http.StatusOK, user)
}
func (s *Server) localIdentityEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(s.cfg.IdentityMode))
return mode == "" || mode == "standalone" || mode == "hybrid"
}
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
authUser := authUserFromGatewayUser(user)
const ttl = 24 * time.Hour
token, err := s.auth.SignJWT(authUser, ttl)
if err != nil {
s.logger.Error("sign local jwt failed", "error", err)
writeError(w, http.StatusInternalServerError, "token sign failed")
return
}
writeJSON(w, status, map[string]any{
"accessToken": token,
"tokenType": "Bearer",
"expiresIn": int(ttl.Seconds()),
"user": authUser,
})
}
func authUserFromGatewayUser(user store.GatewayUser) *auth.User {
roles := user.Roles
if len(roles) == 0 {
roles = []string{"user"}
}
tenantID := user.TenantID
if tenantID == "" {
tenantID = user.TenantKey
}
return &auth.User{
ID: user.ID,
Username: user.Username,
Roles: roles,
TenantID: tenantID,
GatewayTenantID: user.GatewayTenantID,
TenantKey: user.TenantKey,
Source: "gateway",
GatewayUserID: user.ID,
UserGroupID: user.DefaultUserGroupID,
}
}
func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) {
platforms, err := s.store.ListPlatforms(r.Context())
if err != nil {
@@ -103,6 +200,36 @@ func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) listTenants(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListTenants(r.Context())
if err != nil {
s.logger.Error("list tenants failed", "error", err)
writeError(w, http.StatusInternalServerError, "list tenants failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListUsers(r.Context())
if err != nil {
s.logger.Error("list users failed", "error", err)
writeError(w, http.StatusInternalServerError, "list users failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListUserGroups(r.Context())
if err != nil {
s.logger.Error("list user groups failed", "error", err)
writeError(w, http.StatusInternalServerError, "list user groups failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+6 -1
View File
@@ -29,13 +29,18 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/catalog/providers", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels)))
mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants)))
mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.createPlatform)))
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels)))
mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions")))
+543 -19
View File
@@ -3,17 +3,27 @@ package store
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type Store struct {
pool *pgxpool.Pool
}
var (
ErrInvalidCredentials = errors.New("invalid account or password")
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
ErrWeakPassword = errors.New("password must be at least 8 characters")
)
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
@@ -126,6 +136,83 @@ type PricingRule struct {
UpdatedAt time.Time `json:"updatedAt"`
}
type GatewayTenant struct {
ID string `json:"id"`
TenantKey string `json:"tenantKey"`
Source string `json:"source"`
ExternalTenantID string `json:"externalTenantId,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"`
PlanKey string `json:"planKey,omitempty"`
BillingProfile map[string]any `json:"billingProfile,omitempty"`
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
AuthPolicy map[string]any `json:"authPolicy,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Status string `json:"status"`
SyncedAt string `json:"syncedAt,omitempty"`
SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type LocalRegisterInput struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
TenantKey string `json:"tenantKey"`
TenantName string `json:"tenantName"`
InvitationCode string `json:"invitationCode"`
}
type LocalLoginInput struct {
Account string `json:"account"`
Password string `json:"password"`
}
type GatewayUser struct {
ID string `json:"id"`
UserKey string `json:"userKey"`
Source string `json:"source"`
ExternalUserID string `json:"externalUserId,omitempty"`
Username string `json:"username"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"`
Roles []string `json:"roles,omitempty"`
AuthProfile map[string]any `json:"authProfile,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Status string `json:"status"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
SyncedAt string `json:"syncedAt,omitempty"`
SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserGroup struct {
ID string `json:"id"`
GroupKey string `json:"groupKey"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Source string `json:"source"`
Priority int `json:"priority"`
RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy,omitempty"`
BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy,omitempty"`
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
QuotaPolicy map[string]any `json:"quotaPolicy,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RateLimitWindow struct {
ScopeType string `json:"scopeType"`
ScopeKey string `json:"scopeKey"`
@@ -145,18 +232,24 @@ type CreateTaskInput struct {
}
type GatewayTask struct {
ID string `json:"id"`
Kind string `json:"kind"`
UserID string `json:"userId"`
TenantID string `json:"tenantId,omitempty"`
Model string `json:"model"`
Request map[string]any `json:"request,omitempty"`
Status string `json:"status"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Kind string `json:"kind"`
UserID string `json:"userId"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserSource string `json:"userSource,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
Model string `json:"model"`
Request map[string]any `json:"request,omitempty"`
Status string `json:"status"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) {
@@ -408,6 +501,383 @@ ORDER BY scope_type ASC, resource_type ASC, created_at DESC`)
return items, rows.Err()
}
func (s *Store) ListTenants(ctx context.Context) ([]GatewayTenant, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''),
COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy,
auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
created_at, updated_at
FROM gateway_tenants
WHERE deleted_at IS NULL
ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]GatewayTenant, 0)
for rows.Next() {
var item GatewayTenant
var billingProfile []byte
var rateLimitPolicy []byte
var authPolicy []byte
var metadata []byte
if err := rows.Scan(
&item.ID,
&item.TenantKey,
&item.Source,
&item.ExternalTenantID,
&item.Name,
&item.Description,
&item.DefaultUserGroupID,
&item.PlanKey,
&billingProfile,
&rateLimitPolicy,
&authPolicy,
&metadata,
&item.Status,
&item.SyncedAt,
&item.SourceUpdatedAt,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.BillingProfile = decodeObject(billingProfile)
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
item.AuthPolicy = decodeObject(authPolicy)
item.Metadata = decodeObject(metadata)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListUsers(ctx context.Context) ([]GatewayUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
created_at, updated_at
FROM gateway_users
WHERE deleted_at IS NULL
ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]GatewayUser, 0)
for rows.Next() {
var item GatewayUser
var roles []byte
var authProfile []byte
var metadata []byte
if err := rows.Scan(
&item.ID,
&item.UserKey,
&item.Source,
&item.ExternalUserID,
&item.Username,
&item.DisplayName,
&item.Email,
&item.Phone,
&item.AvatarURL,
&item.GatewayTenantID,
&item.TenantID,
&item.TenantKey,
&item.DefaultUserGroupID,
&roles,
&authProfile,
&metadata,
&item.Status,
&item.LastLoginAt,
&item.SyncedAt,
&item.SourceUpdatedAt,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.Roles = decodeStringArray(roles)
item.AuthProfile = decodeObject(authProfile)
item.Metadata = decodeObject(metadata)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListUserGroups(ctx context.Context) ([]UserGroup, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, group_key, name, COALESCE(description, ''), source, priority,
recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata,
status, created_at, updated_at
FROM gateway_user_groups
ORDER BY priority ASC, group_key ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]UserGroup, 0)
for rows.Next() {
var item UserGroup
var rechargeDiscountPolicy []byte
var billingDiscountPolicy []byte
var rateLimitPolicy []byte
var quotaPolicy []byte
var metadata []byte
if err := rows.Scan(
&item.ID,
&item.GroupKey,
&item.Name,
&item.Description,
&item.Source,
&item.Priority,
&rechargeDiscountPolicy,
&billingDiscountPolicy,
&rateLimitPolicy,
&quotaPolicy,
&metadata,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy)
item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy)
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
item.QuotaPolicy = decodeObject(quotaPolicy)
item.Metadata = decodeObject(metadata)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) RegisterLocalUser(ctx context.Context, input LocalRegisterInput) (GatewayUser, error) {
account := normalizeAccount(firstNonEmpty(input.Username, input.Email))
if account == "" {
return GatewayUser{}, errors.New("username or email is required")
}
if len(input.Password) < 8 {
return GatewayUser{}, ErrWeakPassword
}
tenantKey := normalizeKey(input.TenantKey)
if tenantKey == "" {
tenantKey = "personal-" + normalizeKey(account)
}
tenantName := strings.TrimSpace(input.TenantName)
if tenantName == "" {
tenantName = tenantKey
}
displayName := strings.TrimSpace(input.DisplayName)
username := strings.TrimSpace(input.Username)
if username == "" {
username = account
}
email := strings.TrimSpace(strings.ToLower(input.Email))
invitationCode := strings.TrimSpace(input.InvitationCode)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return GatewayUser{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return GatewayUser{}, err
}
defer tx.Rollback(ctx)
var tenantID string
userGroupID := ""
role := "user"
invitationID := ""
if invitationCode != "" {
if err := tx.QueryRow(ctx, `
SELECT i.id::text,
i.tenant_id::text,
t.tenant_key,
t.name,
COALESCE(i.user_group_id::text, t.default_user_group_id::text, ''),
COALESCE(NULLIF(i.role, ''), 'user')
FROM gateway_tenant_invitations i
JOIN gateway_tenants t ON t.id = i.tenant_id
WHERE lower(i.invite_code) = lower($1)
AND i.status = 'active'
AND t.status = 'active'
AND (i.expires_at IS NULL OR i.expires_at > now())
AND (i.max_uses IS NULL OR i.used_count < i.max_uses)
FOR UPDATE OF i`,
invitationCode,
).Scan(&invitationID, &tenantID, &tenantKey, &tenantName, &userGroupID, &role); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GatewayUser{}, ErrInvalidInvitation
}
return GatewayUser{}, err
}
} else if err := tx.QueryRow(ctx, `
INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name)
VALUES ($1, 'gateway', $1, $2)
ON CONFLICT (tenant_key) DO UPDATE SET updated_at=now()
RETURNING id::text`,
tenantKey, tenantName,
).Scan(&tenantID); err != nil {
return GatewayUser{}, err
}
rolesJSON, err := json.Marshal([]string{role})
if err != nil {
return GatewayUser{}, err
}
var user GatewayUser
var roles []byte
var authProfile []byte
var metadata []byte
if err := tx.QueryRow(ctx, `
INSERT INTO gateway_users (
user_key, source, external_user_id, username, display_name, email,
password_hash, gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, status
)
VALUES ($1, 'gateway', $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, $7::uuid, $8, $8, NULLIF($9, '')::uuid, $10::jsonb, 'active')
RETURNING id::text, user_key, source, COALESCE(external_user_id, ''), username,
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
created_at, updated_at`,
"gateway:"+account, account, username, displayName, email, string(passwordHash), tenantID, tenantKey, userGroupID, string(rolesJSON),
).Scan(
&user.ID,
&user.UserKey,
&user.Source,
&user.ExternalUserID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Phone,
&user.AvatarURL,
&user.GatewayTenantID,
&user.TenantID,
&user.TenantKey,
&user.DefaultUserGroupID,
&roles,
&authProfile,
&metadata,
&user.Status,
&user.LastLoginAt,
&user.SyncedAt,
&user.SourceUpdatedAt,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
return GatewayUser{}, err
}
if invitationID != "" {
if _, err := tx.Exec(ctx, `
UPDATE gateway_tenant_invitations
SET used_count = used_count + 1, updated_at = now()
WHERE id = $1::uuid`, invitationID); err != nil {
return GatewayUser{}, err
}
}
if userGroupID != "" {
metadata, err := json.Marshal(map[string]any{
"source": "registration",
"invitationId": invitationID,
})
if err != nil {
return GatewayUser{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_user_group_memberships (group_id, principal_type, principal_id, source, metadata)
VALUES ($1::uuid, 'user', $2, 'gateway', $3::jsonb)
ON CONFLICT (group_id, principal_type, principal_id)
DO UPDATE SET status = 'active', updated_at = now()`,
userGroupID, user.ID, string(metadata),
); err != nil {
return GatewayUser{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return GatewayUser{}, err
}
user.Roles = decodeStringArray(roles)
user.AuthProfile = decodeObject(authProfile)
user.Metadata = decodeObject(metadata)
return user, nil
}
func (s *Store) AuthenticateLocalUser(ctx context.Context, input LocalLoginInput) (GatewayUser, error) {
account := normalizeAccount(input.Account)
if account == "" || input.Password == "" {
return GatewayUser{}, ErrInvalidCredentials
}
var user GatewayUser
var passwordHash string
var roles []byte
var authProfile []byte
var metadata []byte
err := s.pool.QueryRow(ctx, `
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
status, COALESCE(password_hash, ''), COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''),
COALESCE(source_updated_at::text, ''), created_at, updated_at
FROM gateway_users
WHERE source='gateway'
AND deleted_at IS NULL
AND (external_user_id=$1 OR lower(username)=$1 OR lower(COALESCE(email, ''))=$1)
ORDER BY created_at ASC
LIMIT 1`, account,
).Scan(
&user.ID,
&user.UserKey,
&user.Source,
&user.ExternalUserID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Phone,
&user.AvatarURL,
&user.GatewayTenantID,
&user.TenantID,
&user.TenantKey,
&user.DefaultUserGroupID,
&roles,
&authProfile,
&metadata,
&user.Status,
&passwordHash,
&user.LastLoginAt,
&user.SyncedAt,
&user.SourceUpdatedAt,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
if IsNotFound(err) {
return GatewayUser{}, ErrInvalidCredentials
}
return GatewayUser{}, err
}
if user.Status != "active" || passwordHash == "" {
return GatewayUser{}, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
return GatewayUser{}, ErrInvalidCredentials
}
user.Roles = decodeStringArray(roles)
user.AuthProfile = decodeObject(authProfile)
user.Metadata = decodeObject(metadata)
_, _ = s.pool.Exec(ctx, `UPDATE gateway_users SET last_login_at=now(), updated_at=now() WHERE id=$1`, user.ID)
return user, nil
}
func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) {
rows, err := s.pool.Query(ctx, `
SELECT scope_type, scope_key, metric, window_start, limit_value::float8, used_value::float8,
@@ -448,11 +918,16 @@ func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *aut
var resultBytes []byte
var billingsBytes []byte
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_tasks (kind, user_id, tenant_id, model, request, status)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, 'queued')
RETURNING id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
input.Kind, user.ID, user.TenantID, input.Model, requestBody,
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
INSERT INTO gateway_tasks (
kind, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
api_key_id, user_group_id, user_group_key, model, request, status
)
VALUES ($1, $2, NULLIF($3, '')::uuid, COALESCE(NULLIF($4, ''), 'gateway'), NULLIF($5, '')::uuid, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, '')::uuid, NULLIF($10, ''), $11, $12, 'queued')
RETURNING id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source,
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
input.Kind, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.UserGroupID, user.UserGroupKey, input.Model, requestBody,
).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return GatewayTask{}, err
}
@@ -468,10 +943,12 @@ func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error)
var resultBytes []byte
var billingsBytes []byte
err := s.pool.QueryRow(ctx, `
SELECT id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
SELECT id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source,
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
FROM gateway_tasks
WHERE id=$1`, taskID,
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return GatewayTask{}, err
}
@@ -506,3 +983,50 @@ func decodeArray(bytes []byte) []any {
}
return out
}
func decodeStringArray(bytes []byte) []string {
if len(bytes) == 0 {
return nil
}
var out []string
if err := json.Unmarshal(bytes, &out); err == nil {
return out
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func normalizeAccount(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastDash := false
for _, r := range value {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == '.' || unicode.IsSpace(r):
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "default"
}
return out
}