Files
new-api/controller/access_token_audit_test.go
T
CaIon 4924361aef feat(auth): edit the name and permissions of an access token
The token menu now opens an edit dialog instead of a rename prompt.
Renaming still needs no verification. Changing the permissions requires
step-up verification bound to the token id and the new grant, so a
proof cannot be replayed for another token or another set of
permissions; the grant is checked against what the owner can do, the
same as at creation. PATCH /api/user/access_tokens/:id takes an
optional scopes list and is rate limited like the other token routes.

A token that holds permissions the catalog no longer offers can still
be renamed and keeps them; a new grant only contains offered
permissions. Edit stays disabled until the permission catalog has
loaded.

Grant changes are audited as access_token.update with the previous and
new scopes, and audit details show both as permission names.

The permission picker for tokens and admin users now uses toggle
buttons. Token permissions are grouped by personal, administration and
system, each group collapsible with a selected count and select-all or
clear.
2026-09-30 20:52:36 +08:00

1259 lines
66 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
sqlmysql "github.com/go-sql-driver/mysql"
"gorm.io/driver/clickhouse"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, i18n.Init())
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.UserAccessToken{}, &model.Option{},
&model.AuthFlow{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.UserOAuthBinding{}))
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
common.RedisEnabled = false
previousMaster := common.IsMasterNode
common.IsMasterNode = true
require.NoError(t, authz.Init(db))
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.IsMasterNode = previousMaster
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
token := "legacy-opaque-token"
user := &model.User{Username: "audit-owner", Password: "placeholder", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1, AffCode: "audit-owner"}
require.NoError(t, db.Create(user).Error)
return user, token
}
func auditRequest(router http.Handler, method, path, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(`{"secret":"body-must-not-be-logged"}`))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("User-Agent", "audit-test-client")
request.RemoteAddr = "192.0.2.8:4567"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
// newAccessTokenTestRouter mirrors the production paths, so the access token
// route rules keyed by gin full path apply unchanged.
func newAccessTokenTestRouter() *gin.Engine {
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
api := router.Group("/api")
api.GET("/verify/methods", middleware.UserAuth(), GetVerificationMethods)
api.POST("/verify", middleware.UserAuth(), UniversalVerify)
userRoute := api.Group("/user")
selfRoute := userRoute.Group("/", middleware.UserAuth())
selfRoute.GET("/self", func(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": c.GetInt("id")}) })
selfRoute.POST("/2fa/setup", Setup2FA)
selfRoute.POST("/2fa/disable", Disable2FA)
tokenRoute := selfRoute.Group("/access_tokens")
tokenRoute.GET("", ListAccessTokens)
tokenRoute.GET("/catalog", GetAccessTokenCatalog)
tokenRoute.GET("/scopes", GetAccessTokenScopes)
tokenRoute.POST("", CreateAccessToken)
tokenRoute.PATCH("/:id", UpdateAccessToken)
tokenRoute.DELETE("/:id", DeleteAccessToken)
tokenRoute.DELETE("/legacy", RevokeLegacyAccessToken)
adminRoute := userRoute.Group("/", middleware.AdminAuth())
adminRoute.DELETE("/:id", DeleteUser)
return router
}
func accessTokenRequest(router http.Handler, method, path, credential, proof, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Content-Type", "application/json")
if proof != "" {
request.Header.Set("X-Security-Proof", proof)
}
request.RemoteAddr = "192.0.2.8:4567"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func createScopedAccessToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
t.Helper()
suffix, err := common.GenerateRandomCharsKey(43)
require.NoError(t, err)
raw := model.AccessTokenPrefix + suffix
token := &model.UserAccessToken{Name: "test token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
require.NoError(t, token.SetScopes(scopes))
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
return raw, token
}
// createAccessTokenTestSession returns a browser session identity and the
// dashboard JWT that carries it.
func createAccessTokenTestSession(t *testing.T, userID int, sid string) (service.AuthIdentity, string) {
t.Helper()
now := time.Now().Unix()
require.NoError(t, model.CreateUserSession(&model.UserSession{SID: sid, UserID: userID, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}))
identity := service.AuthIdentity{UserID: userID, SessionID: sid, UserAuthVersion: 1, SessionVersion: 1}
jwt, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
return identity, jwt
}
func issueAccessTokenGenerateProof(t *testing.T, identity service.AuthIdentity, expiresAt int64, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: expiresAt})
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: context}, "password")
}
func issueAccessTokenRevokeProof(t *testing.T, identity service.AuthIdentity, target service.AccessTokenRevokeContext) string {
t.Helper()
context, err := common.Marshal(target)
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: context}, "password")
}
func issueAccessTokenUpdateProof(t *testing.T, identity service.AuthIdentity, tokenID int, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: tokenID, Scopes: scopes})
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: context}, "password")
}
// setLegacyAccessTokenRetireAt moves the stored transition deadline the way an
// operator restoring the option row would, then reloads it.
func setLegacyAccessTokenRetireAt(t *testing.T, retireAt int64) {
t.Helper()
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(retireAt)).Error)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
require.Equal(t, retireAt, model.LegacyAccessTokenRetireAt())
}
type accessTokenListResponse struct {
Success bool `json:"success"`
Code string `json:"code"`
Data struct {
Items []struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
Scopes []string `json:"scopes"`
} `json:"items"`
Legacy *struct {
TokenRef string `json:"token_ref"`
RetireAt int64 `json:"retire_at"`
} `json:"legacy"`
} `json:"data"`
}
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "lifecycle-session")
scoped, scopedToken := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", scopedToken.Id)
for _, credential := range []string{scoped, legacy} {
for _, endpoint := range []struct{ method, path string }{
{"GET", "/api/user/access_tokens"}, {"GET", "/api/user/access_tokens/catalog"}, {"GET", "/api/user/access_tokens/scopes"}, {"POST", "/api/user/access_tokens"},
{"PATCH", tokenPath}, {"DELETE", tokenPath}, {"DELETE", "/api/user/access_tokens/legacy"},
} {
response := accessTokenRequest(router, endpoint.method, endpoint.path, credential, "", `{"name":"renamed","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code, endpoint.method+" "+endpoint.path)
assert.Contains(t, response.Body.String(), `"code":"AUTH_SESSION_REQUIRED"`)
}
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
require.Len(t, tokens, 1)
assert.Equal(t, "test token", tokens[0].Name, "an access token cannot manage access tokens")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
require.Equal(t, http.StatusOK, response.Code)
for _, secret := range []string{scoped, legacy} {
assert.NotContains(t, response.Body.String(), secret)
}
var listed accessTokenListResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
require.Len(t, listed.Data.Items, 1)
assert.Equal(t, scopedToken.TokenHash, listed.Data.Items[0].TokenRef)
assert.Equal(t, []string{"profile:read"}, listed.Data.Items[0].Scopes)
require.NotNil(t, listed.Data.Legacy)
assert.Equal(t, model.AccessTokenFingerprint(legacy), listed.Data.Legacy.TokenRef)
assert.Equal(t, model.LegacyAccessTokenRetireAt(), listed.Data.Legacy.RetireAt)
// A token id only resolves among the caller's own tokens.
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
otherRaw, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
otherProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: otherToken.Id})
response = accessTokenRequest(router, "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), browser, otherProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", otherRaw, "", "").Code)
// A revoke proof names one target and is not spent on another one.
proof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: scopedToken.Id})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, proof, "")
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", scoped, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
legacyProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Empty(t, listed.Data.Items)
assert.Nil(t, listed.Data.Legacy)
// After the deadline a stored legacy value is inert: it is neither listed,
// revocable nor accepted, and the column itself is left untouched.
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", legacy).Error)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
retireAt := model.LegacyAccessTokenRetireAt()
setLegacyAccessTokenRetireAt(t, time.Now().Unix()-1)
t.Cleanup(func() { setLegacyAccessTokenRetireAt(t, retireAt) })
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Nil(t, listed.Data.Legacy)
legacyProof = issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
response = accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "")
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken())
var revoked []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.revoke").Order("id").Find(&revoked).Error)
require.Len(t, revoked, 2)
for i, want := range []string{
fmt.Sprintf(`"token_ref":"%s"`, scopedToken.TokenHash),
fmt.Sprintf(`"legacy":true,"token_ref":"%s"`, model.AccessTokenFingerprint(legacy)),
} {
require.NotNil(t, revoked[i].Other.Op)
params, err := common.Marshal(revoked[i].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), want)
}
var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error)
encoded, err := common.Marshal(history)
require.NoError(t, err)
for _, secret := range []string{scoped, legacy, browser, proof, legacyProof, otherRaw, otherProof, "Authorization"} {
assert.NotContains(t, string(encoded), secret)
}
}
// The scope dictionary labels stored grants, so it must not shrink to what the
// viewer can grant today.
func TestAccessTokenScopeDictionaryIgnoresTheViewersGrants(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleCommonUser).Error)
router := newAccessTokenTestRouter()
_, browser := createAccessTokenTestSession(t, user.Id, "dictionary-session")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens/scopes", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var dictionary struct {
Data struct {
Resources []struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []struct {
Action string `json:"action"`
LabelKey string `json:"label_key"`
} `json:"actions"`
} `json:"resources"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &dictionary))
labels := map[string]string{}
for _, resource := range dictionary.Data.Resources {
for _, action := range resource.Actions {
labels[resource.Resource+":"+action.Action] = resource.LabelKey + " / " + action.LabelKey
}
}
assert.Equal(t, "Profile / View", labels["profile:read"])
assert.Equal(t, "API keys / Reveal full keys", labels["api_key:reveal"])
assert.Equal(t, "Users / Edit", labels["user:write"])
assert.Equal(t, "System settings / Edit", labels["option:write"])
for _, permission := range authz.AllPermissions() {
assert.Contains(t, labels, service.AccessTokenScopeOf(permission))
}
response = accessTokenRequest(router, "GET", "/api/user/access_tokens/catalog", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.NotContains(t, response.Body.String(), `"resource":"user"`, "the grant catalog stays filtered")
}
func TestAccessTokenCreationChecksGrantBeforeConsumingProof(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "create-session")
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, "", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
expiresAt := time.Now().Unix() + 7*24*60*60
for _, test := range []struct {
name, body, code string
role, status int
proofScopes []string
proofExpiry int64
}{
{"common user cannot grant user management", `{"name":"ci","scopes":["user:write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleCommonUser, http.StatusBadRequest, []string{"user:write"}, 0},
{"administrator cannot grant a permission they lack", `{"name":"ci","scopes":["channel:sensitive_write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleAdminUser, http.StatusBadRequest, []string{"channel:sensitive_write"}, 0},
{"unknown scope", `{"name":"ci","scopes":["profile:admin"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:admin"}, 0},
{"empty grant", `{"name":"ci","scopes":[],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"expiry under one hour", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, time.Now().Unix()+60), "ACCESS_TOKEN_EXPIRY_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, time.Now().Unix() + 60},
{"blank name", `{"name":" ","scopes":["profile:read"],"expires_at":0}`, "ACCESS_TOKEN_NAME_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"proof for other scopes", `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`, "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
{"proof for other expiry", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, expiresAt), "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
} {
t.Run(test.name, func(t *testing.T) {
require.NoError(t, model.DB.Model(user).Update("role", test.role).Error)
proof := issueAccessTokenGenerateProof(t, identity, test.proofExpiry, test.proofScopes...)
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected grant must not spend the verification")
})
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
assert.Empty(t, tokens)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
proof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read", "profile:read")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":" ci ","scopes":["profile:read","usage:read","profile:read"],"expires_at":0}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var created struct {
Data struct {
Token string `json:"token"`
Item struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
TokenHint string `json:"token_hint"`
ExpiresAt int64 `json:"expires_at"`
Scopes []string `json:"scopes"`
} `json:"item"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &created))
raw := created.Data.Token
require.True(t, strings.HasPrefix(raw, model.AccessTokenPrefix))
assert.Len(t, raw, len(model.AccessTokenPrefix)+43)
assert.Equal(t, "ci", created.Data.Item.Name)
assert.Equal(t, []string{"profile:read", "usage:read"}, created.Data.Item.Scopes)
assert.Zero(t, created.Data.Item.ExpiresAt, "expires_at 0 creates a token that never expires")
assert.Equal(t, model.AccessTokenFingerprint(raw), created.Data.Item.TokenRef)
assert.Equal(t, raw[len(raw)-4:], created.Data.Item.TokenHint)
var columns []map[string]any
require.NoError(t, model.DB.Table("user_access_tokens").Find(&columns).Error)
require.Len(t, columns, 1)
encodedRow, err := common.Marshal(columns[0])
require.NoError(t, err)
assert.NotContains(t, string(encodedRow), raw, "the database stores only the token hash")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken(), "creating a token never replaces the legacy token")
var generated model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.generate").First(&generated).Error)
require.NotNil(t, generated.Other.Op)
params, err := common.Marshal(generated.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, created.Data.Item.TokenRef))
assert.Contains(t, string(params), `"scopes":["profile:read","usage:read"]`)
var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error)
encoded, err := common.Marshal(history)
require.NoError(t, err)
for _, secret := range []string{raw, browser, proof} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAccessTokenUpdateRenamesFreelyAndChangesGrantOnlyWithProof(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "update-session")
raw, token := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", token.Id)
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
_, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
response := accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":" renamed "}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err := model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name)
assert.Equal(t, []string{"profile:read"}, stored.GetScopes(), "a rename keeps the grant")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":"ci","scopes":["usage:read"]}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
generateProof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read")
for _, test := range []struct {
name, path, body, proof, code string
status int
}{
{"permission the owner lacks", tokenPath, `{"name":"ci","scopes":["channel:sensitive_write"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "channel:sensitive_write"), "ACCESS_TOKEN_SCOPE_FORBIDDEN", http.StatusBadRequest},
{"empty grant", tokenPath, `{"name":"ci","scopes":[]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "ACCESS_TOKEN_SCOPE_INVALID", http.StatusBadRequest},
{"another user's token", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "ACCESS_TOKEN_NOT_FOUND", http.StatusNotFound},
{"proof for another token", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"proof for another grant", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"creation proof", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, generateProof, "SECURITY_PROOF_SCOPE_MISMATCH", http.StatusForbidden},
} {
t.Run(test.name, func(t *testing.T) {
response := accessTokenRequest(router, "PATCH", test.path, browser, test.proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected change must not spend the verification")
})
}
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name, "a rejected grant change does not apply the rename either")
assert.Equal(t, []string{"profile:read"}, stored.GetScopes())
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
// The proof binds the grant as a set, so its order and repeats do not matter.
widenProof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read", "profile:read", "profile:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, widenProof, `{"name":"ci","scopes":["profile:read","usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, []string{"profile:read", "usage:read"}, stored.GetScopes())
proof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"," usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var updated struct {
Data struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &updated))
assert.Equal(t, "ci", updated.Data.Name)
assert.Equal(t, []string{"usage:read"}, updated.Data.Scopes)
response = accessTokenRequest(router, "GET", "/api/user/self", raw, "", "")
assert.Equal(t, http.StatusForbidden, response.Code, "a narrowed grant applies to the next request")
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_SCOPE_DENIED"`)
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"]}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"access_token.rename", "access_token.update"}).Order("id").Find(&audits).Error)
require.Len(t, audits, 3)
assert.Equal(t, "access_token.rename", audits[0].Action)
assert.Equal(t, "access_token.update", audits[1].Action)
assert.Equal(t, "access_token.update", audits[2].Action)
require.NotNil(t, audits[2].Other.Op)
params, err := common.Marshal(audits[2].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"previous_scopes":["profile:read","usage:read"]`)
assert.Contains(t, string(params), `"scopes":["usage:read"]`)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, token.TokenHash))
encoded, err := common.Marshal(audits)
require.NoError(t, err)
for _, secret := range []string{raw, browser, widenProof, proof, generateProof} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAccessTokenAuditsResultsAndExcludesBrowserSessions(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{"success": false}) })
router.GET("/public", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
router.GET("/rate-limited", func(c *gin.Context) { c.AbortWithStatusJSON(429, gin.H{"success": false}) }, middleware.UserAuth())
router.GET("/read/:id", middleware.UserAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
router.POST("/write", middleware.AdminAuth(), func(c *gin.Context) {
recordManageAudit(c, "option.update", map[string]any{"key": "safe-setting"})
c.JSON(200, gin.H{"success": true})
})
router.POST("/business-failure", middleware.AdminAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": false, "message": "secret-response"}) })
router.GET("/forbidden", middleware.RootAuth(), func(c *gin.Context) { c.Status(204) })
cases := []struct {
method, path string
status int
success bool
}{{"GET", "/missing", 404, false}, {"GET", "/public", 200, true}, {"GET", "/rate-limited", 429, false}, {"GET", "/read/sensitive-id?password=secret-query", 200, true}, {"POST", "/write", 200, true}, {"POST", "/business-failure", 200, false}, {"GET", "/forbidden", 403, false}}
for _, tc := range cases {
response := auditRequest(router, tc.method, tc.path, pat)
require.Equal(t, tc.status, response.Code)
var access model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryAccessToken).First(&access).Error)
assert.Equal(t, tc.success, access.Success)
assert.Equal(t, tc.status, access.Status)
assert.Equal(t, user.Id, access.UserId)
assert.Equal(t, "192.0.2.8", access.Ip)
assert.Equal(t, "audit-test-client", access.UserAgent)
assert.NotContains(t, access.Route, "sensitive-id")
}
var operationCount int64
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Where("category = ?", model.AuditCategoryOperation).Count(&operationCount).Error)
assert.EqualValues(t, 2, operationCount, "manual operation must not be duplicated by fallback")
now := time.Now().Unix()
session := &model.UserSession{SID: "audit-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}
require.NoError(t, model.CreateUserSession(session))
jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1})
require.NoError(t, err)
assert.Equal(t, 200, auditRequest(router, "GET", "/read/123", jwt).Code)
assert.Equal(t, 401, auditRequest(router, "GET", "/read/123", "unknown-token").Code)
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{Category: model.AuditCategoryAccessToken}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 7, total)
encoded, err := common.Marshal(entries)
require.NoError(t, err)
for _, secret := range []string{pat, jwt, "secret-query", "secret-response", "body-must-not-be-logged", "sensitive-id"} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
metadata := model.AuditOther{
Op: &model.AuditOperation{Action: "generic"},
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
RootInfo: model.AuditFields{"private": "root-only"},
}
for _, owner := range []int{user.Id, user.Id + 1} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: owner, Username: fmt.Sprint(owner), Category: model.AuditCategorySecurity, Success: false, Other: metadata})
}
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs)
router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs)
response := auditRequest(router, "GET", "/api/audit/self?username=other&user_id=2&category=security&success=false&page_size=1", pat)
var result struct {
Success bool
Data struct {
Items []model.AuditLog
Total int
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success)
require.Equal(t, 1, result.Data.Total)
require.Len(t, result.Data.Items, 1)
assert.Equal(t, user.Id, result.Data.Items[0].UserId)
assert.NotContains(t, response.Body.String(), "admin_info")
assert.NotContains(t, response.Body.String(), "root-only")
var payload struct {
Data struct {
Items []struct {
Other map[string]any `json:"other"`
}
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload), "audit metadata must be a JSON object, not an encoded string")
require.Len(t, payload.Data.Items, 1)
assert.Contains(t, payload.Data.Items[0].Other, "op")
require.NoError(t, authz.SetUserPermissions(user.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}}))
response = auditRequest(router, "GET", "/api/audit?category=security", pat)
assert.Contains(t, response.Body.String(), "admin_info")
assert.NotContains(t, response.Body.String(), "root-only")
for _, query := range []string{"success=bad", "category=bad", "token_ref=secret", "start_timestamp=-1", "start_timestamp=2&end_timestamp=1", "p=-1", "page_size=-1"} {
assert.Contains(t, auditRequest(router, "GET", "/api/audit/self?"+query, pat).Body.String(), `"success":false`)
}
require.NoError(t, model.LOG_DB.Callback().Query().Before("gorm:query").Register("audit:fail", func(tx *gorm.DB) {
if tx.Statement.Table == "audit_logs" {
tx.AddError(errors.New("audit store unavailable"))
}
}))
_, err := model.GetUserAccessTokenStatus(user.Id)
require.Error(t, err, "audit query failure must not look like never used")
model.LOG_DB.Callback().Query().Remove("audit:fail")
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) }))
_, err = model.RevokeUserAccessToken(user.Id)
require.Error(t, err)
model.DB.Callback().Update().Remove("audit:write-fail")
status, err := model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.Equal(t, model.AccessTokenFingerprint(pat), status.TokenRef)
}
func TestAuditRoleVisibilityAndPermissions(t *testing.T) {
admin, pat := setupAccessTokenAudit(t)
rootToken := "root-audit-token"
root := &model.User{Username: "root-audit", Role: common.RoleRootUser, Status: common.UserStatusEnabled, AuthVersion: 1, AccessToken: &rootToken, AffCode: "root-audit"}
require.NoError(t, model.DB.Create(root).Error)
metadata := model.AuditOther{
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
RootInfo: model.AuditFields{"private": "root-only"},
}
for i, role := range []int{1, 10, 100, 0, -1, 99} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: admin.Id, Username: admin.Username, Category: model.AuditCategorySecurity, RequestId: fmt.Sprintf("role-%d", role), CreatedAt: int64(100 + i), Other: metadata})
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: 100, UserId: root.Id, Username: root.Username, Category: model.AuditCategorySecurity, RequestId: "root-owned", Other: metadata})
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs)
router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs)
now := time.Now().Unix()
session := &model.UserSession{SID: "audit-permissions-session", UserID: admin.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}
require.NoError(t, model.CreateUserSession(session))
jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: admin.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1})
require.NoError(t, err)
for _, credential := range []string{pat, jwt} {
assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code)
}
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}})
}))
require.NoError(t, authz.ReloadPolicy())
for _, credential := range []string{pat, jwt} {
for _, endpoint := range []string{"/api/audit", "/api/audit/self"} {
response := auditRequest(router, "GET", endpoint+"?category=security&page_size=1", credential)
assert.Equal(t, http.StatusOK, response.Code)
var result struct {
Data struct {
Total int
Items []model.AuditLog
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, 2, result.Data.Total)
require.Len(t, result.Data.Items, 1)
assert.Equal(t, 10, result.Data.Items[0].ActorRole)
assert.NotContains(t, response.Body.String(), "root-only")
for _, filter := range []string{"request_id=role-100", "username=" + root.Username, "request_id=role-0"} {
filtered := auditRequest(router, "GET", endpoint+"?category=security&"+filter, credential)
// self ignores supplied usernames, while still excluding every root row.
if endpoint == "/api/audit/self" && strings.HasPrefix(filter, "username=") {
continue
}
assert.Contains(t, filtered.Body.String(), `"total":0`)
}
}
}
rootSelf := auditRequest(router, "GET", "/api/audit/self?category=security", rootToken)
assert.Contains(t, rootSelf.Body.String(), `"actor_role":100`)
assert.NotContains(t, rootSelf.Body.String(), "admin_info")
rootAll := auditRequest(router, "GET", "/api/audit?category=security", rootToken)
assert.Contains(t, rootAll.Body.String(), `"total":7`)
assert.Contains(t, rootAll.Body.String(), "root-only")
require.NoError(t, authz.SetUserPermissions(admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}}))
for _, credential := range []string{pat, jwt} {
assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code)
assert.Equal(t, http.StatusOK, auditRequest(router, "GET", "/api/audit/self", credential).Code)
}
}
func TestAuditRoleSnapshotSurvivesActorChanges(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
user.Role = common.RoleRootUser
require.NoError(t, model.DB.Model(user).Update("role", user.Role).Error)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.POST("/change-role", middleware.UserAuth(), func(c *gin.Context) {
recordLoginAudit(user, c)
recordUserSecurityAudit(c, user.Id, "user.security_verify", nil)
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{999}}, auditOperatorInfo(c))
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
c.Status(200)
})
require.Equal(t, http.StatusOK, auditRequest(router, "POST", "/change-role", pat).Code)
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, entries)
status, err := model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.Nil(t, status.LastUsedAt, "a root request must not leak through the last-use summary after demotion")
require.NoError(t, model.DB.Unscoped().Delete(user).Error)
entries, total, err = model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 4, total)
for _, entry := range entries {
assert.Equal(t, common.RoleRootUser, entry.ActorRole)
}
}
func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/event", nil)
c.Set("id", user.Id)
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Set(common.RequestIdKey, "correlated-request")
recordLoginAudit(user, c)
for _, action := range []string{"user.passkey_register", "user.passkey_delete", "user.2fa_setup", "user.2fa_enable", "user.2fa_disable_self", "user.2fa_backup_codes", "user.security_verify"} {
recordUserSecurityAudit(c, user.Id, action, nil)
}
recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
model.RecordLog(user.Id, typ, "business entry")
}
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
assert.Len(t, audits, 10)
for _, entry := range audits {
assert.Equal(t, "correlated-request", entry.RequestId)
assert.Equal(t, user.Role, entry.ActorRole)
}
var logs []model.Log
require.NoError(t, model.LOG_DB.Find(&logs).Error)
assert.Len(t, logs, 4)
_, err := model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
require.NoError(t, err)
var count int64
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&count).Error)
assert.EqualValues(t, 10, count)
}
// Released schemas copied from v1.0.0-rc.33; only the Go type names differ.
type releasedAuditUser struct {
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"`
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
}
func (releasedAuditUser) TableName() string { return "users" }
type releasedAuditLog struct {
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
Other string `json:"other"`
}
func (releasedAuditLog) TableName() string { return "logs" }
// External tests create a new database per case on a loopback-only disposable
// instance. They never drop databases or tables supplied through an environment variable.
func newAuditTestDatabase(t *testing.T, kind, dsn string) (*gorm.DB, string) {
t.Helper()
if kind == "sqlite" {
path := t.TempDir() + "/audit.db"
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
require.NoError(t, err)
return db, path
}
require.NotEmpty(t, dsn)
name := fmt.Sprintf("newapi_audit_%d", time.Now().UnixNano())
var original, isolated gorm.Dialector
var newDSN string
if kind == "mysql" {
config, err := sqlmysql.ParseDSN(dsn)
require.NoError(t, err)
require.Equal(t, "tcp", config.Net)
host, _, err := net.SplitHostPort(config.Addr)
require.NoError(t, err)
require.True(t, net.ParseIP(host).IsLoopback(), "database tests only permit loopback instances")
original = mysql.Open(dsn)
config.DBName = name
newDSN = config.FormatDSN()
isolated = mysql.Open(newDSN)
} else {
parsed, err := url.Parse(dsn)
require.NoError(t, err)
require.True(t, net.ParseIP(parsed.Hostname()).IsLoopback(), "database tests only permit loopback instances")
parsed.Path = "/" + name
newDSN = parsed.String()
if kind == "clickhouse" {
original = clickhouse.Open(dsn)
isolated = clickhouse.Open(newDSN)
} else {
original = postgres.Open(dsn)
isolated = postgres.Open(newDSN)
}
}
admin, err := gorm.Open(original, &gorm.Config{})
require.NoError(t, err)
// No IF NOT EXISTS: a collision fails before any test data can be written.
createSQL := "CREATE DATABASE " + name
if kind == "mysql" {
createSQL += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
}
require.NoError(t, admin.Exec(createSQL).Error)
sqlDB, err := admin.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
db, err := gorm.Open(isolated, &gorm.Config{})
require.NoError(t, err)
t.Logf("isolated database: %s (%s)", name, kind)
t.Cleanup(func() {
connection, err := db.DB()
if err == nil {
_ = connection.Close()
}
})
return db, newDSN
}
func verifyAuditRoleStorage(t *testing.T) {
t.Helper()
for i, role := range []int{1, 10, 100, 0, 99} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: 1, Username: "role-owner", CreatedAt: int64(200 + i), Category: model.AuditCategoryOperation, RequestId: fmt.Sprintf("matrix-role-%d", role)})
}
filter := model.AuditLogFilter{Category: model.AuditCategoryOperation}
visible, total, err := model.GetAuditLogs(filter, 0, 1, common.RoleAdminUser)
require.NoError(t, err)
assert.EqualValues(t, 2, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleAdminUser, visible[0].ActorRole)
visible, total, err = model.GetAuditLogs(filter, 1, 1, common.RoleCommonUser)
require.NoError(t, err)
assert.EqualValues(t, 2, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleCommonUser, visible[0].ActorRole)
filter.RequestId = "matrix-role-100"
visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, visible)
visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleRootUser, visible[0].ActorRole)
}
func verifyAuditJSONStorage(t *testing.T) {
t.Helper()
columns, err := model.LOG_DB.Migrator().ColumnTypes(&model.AuditLog{})
require.NoError(t, err)
var otherType string
for _, column := range columns {
if column.Name() == "other" {
otherType = strings.ToLower(column.DatabaseTypeName())
}
}
assert.Equal(t, "json", otherType)
metadata := model.AuditOther{
Op: &model.AuditOperation{Action: "channel.update", Params: model.AuditFields{
"id": 42, "name": "渠道", "changed_fields": []string{}, "large_id": uint64(9007199254740993),
"extra": map[string]any{"attempts": 0, "permitted": false, "ratio": 1.25, "targets": []int{1, 2}},
}},
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
AuditInfo: &model.AuditRequestInfo{Method: "PUT", Route: "/api/channel/", Path: "/api/channel/", Status: 200, Success: false},
RootInfo: model.AuditFields{"private": "root-only"},
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", Category: model.AuditCategorySecurity, RequestId: "matrix-json", Other: metadata})
filter := model.AuditLogFilter{RequestId: "matrix-json"}
entries, total, err := model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, entries, 1)
stored, err := common.Marshal(entries[0].Other)
require.NoError(t, err)
expected, err := common.Marshal(metadata)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(stored))
require.NotNil(t, entries[0].Other.Op)
assert.Equal(t, "channel.update", entries[0].Other.Op.Action)
require.NotNil(t, entries[0].Other.AuditInfo)
assert.False(t, entries[0].Other.AuditInfo.Success)
var details struct {
Op struct {
Params struct {
LargeId uint64 `json:"large_id"`
}
}
}
require.NoError(t, common.Unmarshal(stored, &details))
assert.EqualValues(t, 9007199254740993, details.Op.Params.LargeId, "JSON numbers must retain their type and precision")
for _, role := range []int{common.RoleCommonUser, common.RoleAdminUser} {
entries, _, err = model.GetAuditLogs(filter, 0, 20, role)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Nil(t, entries[0].Other.RootInfo)
if role == common.RoleCommonUser {
assert.Nil(t, entries[0].Other.AdminInfo)
assert.Nil(t, entries[0].Other.AuditInfo)
} else {
assert.NotNil(t, entries[0].Other.AdminInfo)
assert.NotNil(t, entries[0].Other.AuditInfo)
}
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", RequestId: "matrix-json-empty", Other: model.AuditOther{}})
entries, _, err = model.GetAuditLogs(model.AuditLogFilter{RequestId: "matrix-json-empty"}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.Len(t, entries, 1)
empty, err := common.Marshal(entries[0].Other)
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(empty))
for range 2 {
require.NoError(t, model.InitLogDB())
}
entries, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, entries, 1)
stored, err = common.Marshal(entries[0].Other)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(stored), "repeated startup must retain structured audit metadata")
}
func TestAuditOtherDatabaseEncoding(t *testing.T) {
const payload = `{"op":{"action":"channel.update","params":{"id":9007199254740993,"nested":{"count":0,"enabled":false}}},"admin_info":{"admin_id":1,"admin_username":"root","admin_role":100,"auth_method":"session"},"audit_info":{"method":"PUT","route":"/api/channel/","path":"/api/channel/","status":200,"success":false},"root_info":{"generation":18446744073709551615}}`
for _, input := range []any{payload, []byte(payload)} {
var other model.AuditOther
require.NoError(t, other.Scan(input))
require.NotNil(t, other.Op)
assert.Equal(t, "channel.update", other.Op.Action)
require.NotNil(t, other.AdminInfo)
assert.Equal(t, 100, other.AdminInfo.AdminRole)
require.NotNil(t, other.AuditInfo)
assert.False(t, other.AuditInfo.Success)
encoded, err := other.Value()
require.NoError(t, err)
text, ok := encoded.(string)
require.True(t, ok, "PostgreSQL simple protocol requires a string parameter")
assert.JSONEq(t, payload, text)
assert.Contains(t, text, "9007199254740993")
assert.Contains(t, text, "18446744073709551615")
for _, empty := range []any{nil, "", []byte(`null`), "{}"} {
require.NoError(t, other.Scan(input))
require.NoError(t, other.Scan(empty))
encoded, err = other.Value()
require.NoError(t, err)
assert.Equal(t, "{}", encoded, "empty input must clear fields from the previous row")
}
}
var invalid model.AuditOther
assert.Error(t, invalid.Scan(42))
assert.Error(t, invalid.Scan([]byte(`{broken`)))
}
func TestAuditDatabaseMatrix(t *testing.T) {
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
previousMaster, previousSQLite := common.IsMasterNode, common.SQLitePath
common.IsMasterNode = true
common.RedisEnabled = false
t.Cleanup(func() {
common.IsMasterNode, common.SQLitePath = previousMaster, previousSQLite
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
cases := []struct {
name, env string
typ common.DatabaseType
}{
{"sqlite", "", common.DatabaseTypeSQLite}, {"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL}, {"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dsn := os.Getenv(tc.env)
if tc.env != "" && dsn == "" {
t.Skip(tc.env + " is not configured")
}
for _, upgrade := range []bool{false, true} {
t.Run(fmt.Sprintf("upgrade=%v", upgrade), func(t *testing.T) {
db, isolatedDSN := newAuditTestDatabase(t, tc.name, dsn)
t.Setenv("LOG_SQL_DSN", "")
if tc.name == "sqlite" {
common.SQLitePath = isolatedDSN
t.Setenv("SQL_DSN", "local")
} else {
t.Setenv("SQL_DSN", isolatedDSN)
}
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(tc.typ, tc.typ)
versionSQL := "SELECT version()"
if tc.name == "sqlite" {
versionSQL = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionSQL).Scan(&version).Error)
t.Logf("database version: %s", version)
if upgrade {
require.NoError(t, db.AutoMigrate(&releasedAuditUser{}, &releasedAuditLog{}))
legacy := "released-token"
require.NoError(t, db.Create(&releasedAuditUser{Username: "released-owner", Password: "placeholder", AccessToken: &legacy, AffCode: "released-aff", Quota: 1234}).Error)
require.NoError(t, db.Create(&releasedAuditLog{UserId: 1, Type: model.LogTypeLogin, Content: "historical login", CreatedAt: 100, RequestId: "legacy-request"}).Error)
}
for range 2 {
require.NoError(t, model.InitDB())
require.NoError(t, model.InitLogDB())
}
if !upgrade {
require.NoError(t, db.Create(&model.User{Username: "fresh-owner", Password: "placeholder", AffCode: "fresh-aff"}).Error)
}
status, err := model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
assert.Equal(t, upgrade, status.Exists)
assert.Nil(t, status.CreatedAt)
if upgrade {
assert.Equal(t, model.AccessTokenFingerprint("released-token"), status.TokenRef)
legacyUser, validationErr := model.ValidateAccessToken("released-token")
require.NoError(t, validationErr)
require.NotNil(t, legacyUser)
var user model.User
require.NoError(t, db.First(&user, 1).Error)
assert.Equal(t, 1234, user.Quota)
var old model.Log
require.NoError(t, db.First(&old).Error)
assert.Equal(t, "historical login", old.Content)
}
require.NoError(t, db.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "matrix-token").Error)
require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade")
scoped, scopedToken := createScopedAccessToken(t, 1, 0, "profile:read")
found, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(scoped))
require.NoError(t, err)
assert.Equal(t, scopedToken.Id, found.Id)
assert.Equal(t, []string{"profile:read"}, found.GetScopes())
require.Error(t, db.Create(&model.UserAccessToken{UserId: 1, Name: "duplicate", TokenHash: scopedToken.TokenHash, TokenHint: "dupe", Scopes: found.Scopes}).Error, "token hashes stay unique")
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: 99, Category: model.AuditCategoryAccessToken, TokenRef: scopedToken.TokenHash, Ip: "192.0.2.1", Success: true})
byRef, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, TokenRef: scopedToken.TokenHash}, 0, 10, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, byRef, 1, "audit rows of a scoped token are found by its token_ref")
_, err = model.DeleteUserAccessToken(1, scopedToken.Id)
require.NoError(t, err)
require.NoError(t, model.LOG_DB.Where("token_ref = ?", scopedToken.TokenHash).Delete(&model.AuditLog{}).Error)
for _, timestamp := range []int64{101, 102, 103} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102})
}
first, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 2, common.RoleCommonUser)
require.NoError(t, err)
assert.EqualValues(t, 3, total)
require.Len(t, first, 2)
assert.EqualValues(t, 103, first[0].CreatedAt)
second, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 2, 2, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, second, 1)
assert.EqualValues(t, 101, second[0].CreatedAt)
failure := false
failed, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, Success: &failure}, 0, 10, 1)
require.NoError(t, err)
require.Len(t, failed, 1)
assert.EqualValues(t, 102, failed[0].CreatedAt)
status, err = model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
require.NotNil(t, status.LastUsedAt)
assert.EqualValues(t, 103, *status.LastUsedAt)
_, err = model.RevokeUserAccessToken(1)
require.NoError(t, err)
_, err = model.RevokeUserAccessToken(1)
require.NoError(t, err)
require.NoError(t, model.MigrateAuditLogs())
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 10, 1)
require.NoError(t, err)
assert.EqualValues(t, 3, total)
verifyAuditRoleStorage(t)
verifyAuditJSONStorage(t)
require.NoError(t, authz.Init(model.DB))
assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}})
}))
require.NoError(t, authz.ReloadPolicy())
assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, authz.Init(model.DB))
require.NoError(t, authz.Init(model.DB))
assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}})
}))
require.NoError(t, authz.ReloadPolicy())
assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
assert.True(t, authz.Can(1, common.RoleRootUser, authz.AuditRead))
})
}
})
}
}
func TestIndependentAuditLogStores(t *testing.T) {
_, _ = setupAccessTokenAudit(t)
previousMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() { common.IsMasterNode = previousMaster })
for _, tc := range []struct{ kind, env string }{{"mysql", "AUDIT_MYSQL_DSN"}, {"postgres", "AUDIT_POSTGRES_DSN"}, {"clickhouse", "AUDIT_CLICKHOUSE_DSN"}} {
for _, upgrade := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/upgrade=%v", tc.kind, upgrade), func(t *testing.T) {
dsn := os.Getenv(tc.env)
if dsn == "" {
t.Skip(tc.env + " is not configured")
}
logDB, isolatedDSN := newAuditTestDatabase(t, tc.kind, dsn)
if upgrade {
if tc.kind == "clickhouse" {
require.NoError(t, logDB.Exec(releasedClickHouseLogSchema).Error)
} else {
require.NoError(t, logDB.AutoMigrate(&releasedAuditLog{}))
}
require.NoError(t, logDB.Create(&releasedAuditLog{UserId: 1, Username: "legacy", CreatedAt: time.Now().Unix(), Type: model.LogTypeLogin, Content: "retained historical login", RequestId: "legacy-split-request"}).Error)
}
t.Setenv("LOG_SQL_DSN", isolatedDSN)
t.Setenv("LOG_SQL_CLICKHOUSE_TTL_DAYS", "7")
require.NoError(t, model.InitLogDB())
require.NoError(t, model.InitLogDB())
if upgrade {
var old model.Log
require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error)
assert.Equal(t, "retained historical login", old.Content)
}
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "independent-pat").Error)
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403})
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, entries, 1)
assert.False(t, entries[0].Success)
status, err := model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
require.NotNil(t, status.LastUsedAt)
assert.Equal(t, "192.0.2.8", status.LastUsedIp)
model.RecordLog(1, model.LogTypeTopup, "independent business")
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
require.NoError(t, err)
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
var mainCount int64
require.NoError(t, model.DB.Model(&model.AuditLog{}).Count(&mainCount).Error)
assert.Zero(t, mainCount)
verifyAuditRoleStorage(t)
verifyAuditJSONStorage(t)
if tc.kind == "clickhouse" {
var create string
require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE audit_logs").Scan(&create).Error)
assert.NotContains(t, strings.ToUpper(create), "TTL")
require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE logs").Scan(&create).Error)
assert.Contains(t, strings.ToUpper(create), "TTL")
}
})
}
}
}
// ClickHouse logs schema from v1.0.0-rc.33.
const releasedClickHouseLogSchema = `
CREATE TABLE IF NOT EXISTS logs (
id Int64 DEFAULT 0,
user_id Int32 DEFAULT 0,
created_at Int64 DEFAULT 0,
type Int32 DEFAULT 0,
content String DEFAULT '',
username String DEFAULT '',
token_name String DEFAULT '',
model_name String DEFAULT '',
quota Int32 DEFAULT 0,
prompt_tokens Int32 DEFAULT 0,
completion_tokens Int32 DEFAULT 0,
use_time Int32 DEFAULT 0,
is_stream UInt8 DEFAULT 0,
channel_id Int32 DEFAULT 0,
token_id Int32 DEFAULT 0,
` + "`group`" + ` String DEFAULT '',
ip String DEFAULT '',
request_id String DEFAULT '',
upstream_request_id String DEFAULT '',
other String DEFAULT ''
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(toDateTime(created_at))
ORDER BY (created_at, request_id)`