mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-09 09:57:06 +08:00
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.
1259 lines
66 KiB
Go
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)`
|