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
+9
View File
@@ -5,4 +5,13 @@ go 1.23
require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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
}
+270
View File
@@ -49,6 +49,9 @@ CREATE TABLE IF NOT EXISTS integration_platforms (
auth_type text NOT NULL DEFAULT 'bearer',
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
visibility_scope text NOT NULL DEFAULT 'global',
tenant_id text,
tenant_key text,
default_pricing_mode text NOT NULL DEFAULT 'inherit_discount',
default_discount_factor numeric NOT NULL DEFAULT 1,
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
@@ -72,6 +75,9 @@ CREATE INDEX IF NOT EXISTS idx_integration_platforms_status_priority
CREATE INDEX IF NOT EXISTS idx_integration_platforms_cooldown
ON integration_platforms(cooldown_until);
CREATE INDEX IF NOT EXISTS idx_integration_platforms_tenant_scope
ON integration_platforms(visibility_scope, tenant_id, tenant_key, status);
CREATE TABLE IF NOT EXISTS model_pricing_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_type text NOT NULL,
@@ -94,6 +100,240 @@ CREATE INDEX IF NOT EXISTS idx_model_pricing_scope
CREATE INDEX IF NOT EXISTS idx_model_pricing_effective
ON model_pricing_rules(effective_from, effective_to);
CREATE TABLE IF NOT EXISTS gateway_user_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
group_key text NOT NULL UNIQUE,
name text NOT NULL,
description text,
source text NOT NULL DEFAULT 'gateway',
priority integer NOT NULL DEFAULT 100,
recharge_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
billing_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_user_groups_status_priority
ON gateway_user_groups(status, priority);
CREATE TABLE IF NOT EXISTS gateway_tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_key text NOT NULL UNIQUE,
source text NOT NULL DEFAULT 'gateway',
external_tenant_id text,
name text NOT NULL,
description text,
default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
plan_key text,
billing_profile jsonb NOT NULL DEFAULT '{}'::jsonb,
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
auth_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'active',
synced_at timestamptz,
source_updated_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE(source, external_tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_gateway_tenants_source_external
ON gateway_tenants(source, external_tenant_id)
WHERE external_tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gateway_tenants_status
ON gateway_tenants(status, created_at DESC);
CREATE TABLE IF NOT EXISTS gateway_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_key text NOT NULL UNIQUE,
source text NOT NULL DEFAULT 'gateway',
external_user_id text,
username text NOT NULL,
display_name text,
email text,
phone text,
avatar_url text,
password_hash text,
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
tenant_id text,
tenant_key text,
default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
roles jsonb NOT NULL DEFAULT '[]'::jsonb,
auth_profile jsonb NOT NULL DEFAULT '{}'::jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'active',
last_login_at timestamptz,
synced_at timestamptz,
source_updated_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE(source, external_user_id)
);
CREATE INDEX IF NOT EXISTS idx_gateway_users_source_external
ON gateway_users(source, external_user_id)
WHERE external_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gateway_users_status
ON gateway_users(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_users_tenant
ON gateway_users(tenant_id, tenant_key, status);
CREATE TABLE IF NOT EXISTS gateway_user_group_memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
group_id uuid NOT NULL REFERENCES gateway_user_groups(id) ON DELETE CASCADE,
principal_type text NOT NULL,
principal_id text NOT NULL,
source text NOT NULL DEFAULT 'gateway',
priority integer NOT NULL DEFAULT 100,
effective_from timestamptz,
effective_to timestamptz,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(group_id, principal_type, principal_id)
);
CREATE INDEX IF NOT EXISTS idx_user_group_membership_principal
ON gateway_user_group_memberships(principal_type, principal_id, status);
CREATE INDEX IF NOT EXISTS idx_user_group_membership_effective
ON gateway_user_group_memberships(effective_from, effective_to);
CREATE TABLE IF NOT EXISTS gateway_tenant_invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES gateway_tenants(id) ON DELETE CASCADE,
invite_code text NOT NULL UNIQUE,
role text NOT NULL DEFAULT 'user',
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
max_uses integer,
used_count integer NOT NULL DEFAULT 0,
expires_at timestamptz,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_by uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_invitations_tenant
ON gateway_tenant_invitations(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_gateway_invitations_expiry
ON gateway_tenant_invitations(expires_at);
CREATE TABLE IF NOT EXISTS gateway_api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
tenant_id text,
tenant_key text,
user_id text,
key_prefix text NOT NULL,
key_hash text NOT NULL UNIQUE,
name text NOT NULL,
scopes jsonb NOT NULL DEFAULT '[]'::jsonb,
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'active',
expires_at timestamptz,
last_used_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_owner
ON gateway_api_keys(gateway_user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_prefix
ON gateway_api_keys(key_prefix, status);
CREATE TABLE IF NOT EXISTS gateway_wallet_accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
tenant_id text,
tenant_key text,
user_id text,
currency text NOT NULL DEFAULT 'resource',
balance numeric NOT NULL DEFAULT 0,
frozen_balance numeric NOT NULL DEFAULT 0,
total_recharged numeric NOT NULL DEFAULT 0,
total_spent numeric NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(gateway_user_id, currency)
);
CREATE INDEX IF NOT EXISTS idx_gateway_wallet_accounts_tenant
ON gateway_wallet_accounts(gateway_tenant_id, status);
CREATE TABLE IF NOT EXISTS gateway_wallet_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES gateway_wallet_accounts(id) ON DELETE CASCADE,
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
direction text NOT NULL,
transaction_type text NOT NULL,
amount numeric NOT NULL,
balance_before numeric NOT NULL,
balance_after numeric NOT NULL,
idempotency_key text,
reference_type text,
reference_id text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_wallet_transactions_account
ON gateway_wallet_transactions(account_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_wallet_tx_idempotency
ON gateway_wallet_transactions(account_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS gateway_recharge_orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
tenant_id text,
tenant_key text,
user_id text,
amount numeric NOT NULL,
bonus_amount numeric NOT NULL DEFAULT 0,
payable_amount numeric NOT NULL,
currency text NOT NULL DEFAULT 'resource',
channel text NOT NULL DEFAULT 'manual',
status text NOT NULL DEFAULT 'created',
external_order_id text,
idempotency_key text,
paid_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_recharge_orders_user
ON gateway_recharge_orders(gateway_user_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_recharge_order_idempotency
ON gateway_recharge_orders(gateway_user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS platform_models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
@@ -135,8 +375,15 @@ CREATE TABLE IF NOT EXISTS gateway_tasks (
kind text NOT NULL,
run_mode text NOT NULL DEFAULT 'production',
user_id text NOT NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
user_source text NOT NULL DEFAULT 'gateway',
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
tenant_id text,
tenant_key text,
api_key_id text,
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
user_group_key text,
user_group_policy_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
model text NOT NULL,
model_type text,
request jsonb NOT NULL DEFAULT '{}'::jsonb,
@@ -226,6 +473,29 @@ CREATE TABLE IF NOT EXISTS gateway_task_events (
CREATE INDEX IF NOT EXISTS idx_gateway_events_task_created
ON gateway_task_events(task_id, created_at);
CREATE TABLE IF NOT EXISTS gateway_task_callback_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
event_id uuid REFERENCES gateway_task_events(id) ON DELETE SET NULL,
seq bigint NOT NULL,
callback_url text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(task_id, seq, callback_url)
);
CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_pending
ON gateway_task_callback_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_task
ON gateway_task_callback_outbox(task_id, seq);
CREATE TABLE IF NOT EXISTS runtime_client_states (
client_id text PRIMARY KEY,
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,