Commit d0c4305a by Calcium-Ion Committed by GitHub

feat(audit): add localized security audit logs (#5462)

parent 27b2b2c4
...@@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up ...@@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up
### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md` ### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`
When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document. When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.
### Rule 8: Pull Requests — Identify AI-Generated Contributions When Appropriate
When creating a pull request:
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers (for example, the recurring top authors in `git log`). Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
...@@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up ...@@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up
### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md` ### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`
When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document. When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.
### Rule 8: Pull Requests — Identify AI-Generated Contributions When Appropriate
When creating a pull request:
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers (for example, the recurring top authors in `git log`). Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
...@@ -66,4 +66,10 @@ const ( ...@@ -66,4 +66,10 @@ const (
// ContextKeyLanguage stores the user's language preference for i18n // ContextKeyLanguage stores the user's language preference for i18n
ContextKeyLanguage ContextKey = "language" ContextKeyLanguage ContextKey = "language"
ContextKeyIsStream ContextKey = "is_stream" ContextKeyIsStream ContextKey = "is_stream"
// ContextKeyAuditLogged marks that the current request has already recorded
// a manage/operation audit log inside the handler. When set, the admin-audit
// fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged"
) )
package controller
import (
"fmt"
"os"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
"channel.delete": "Deleted channel ${name} (ID: ${id})",
"channel.delete_batch": "Batch deleted ${count} channels",
"channel.delete_disabled": "Deleted all disabled channels (${count})",
"channel.key_view": "Viewed channel key ${name} (ID: ${id})",
"channel.tag_disable": "Disabled channels with tag ${tag}",
"channel.tag_enable": "Enabled channels with tag ${tag}",
"channel.tag_edit": "Edited channels with tag ${tag}",
"channel.tag_batch_set": "Batch set tag for ${count} channels",
"channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})",
"channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})",
"channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})",
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
}
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]interface{}) string {
tmpl, ok := auditContentTemplates[action]
if !ok {
return action
}
return os.Expand(tmpl, func(key string) string {
if v, ok := params[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
}
// auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。
func auditOperatorInfo(c *gin.Context) map[string]interface{} {
return map[string]interface{}{
"admin_id": c.GetInt("id"),
"admin_username": c.GetString("username"),
"admin_role": c.GetInt("role"),
}
}
// markAuditLogged 标记当前请求已在 handler 内手动记录审计日志,
// 使鉴权链路中的审计兜底(finishAdminAudit)跳过兜底记录,避免重复。
func markAuditLogged(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyAuditLogged, true)
}
// recordManageAudit 记录一条由操作者本人归属的管理/高危审计日志(资源类操作:
// 渠道 / 系统设置 / 兑换码等)。content 由 action+params 自动渲染。
func recordManageAudit(c *gin.Context, action string, params map[string]interface{}) {
recordManageAuditFor(c, c.GetInt("id"), action, params)
}
// recordManageAuditFor 记录一条归属于 logUserId 的管理审计日志(面向用户的操作:
// 对目标用户的额度调整 / 解绑 / 2FA 等,使该用户也能在自己的日志中看到)。
func recordManageAuditFor(c *gin.Context, logUserId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(logUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil)
markAuditLogged(c)
}
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(userId, auditContentEN(action, params), c.ClientIP(), action, params, nil, nil)
}
...@@ -404,7 +404,6 @@ func GetChannel(c *gin.Context) { ...@@ -404,7 +404,6 @@ func GetChannel(c *gin.Context) {
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件) // GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 // 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) { func GetChannelKey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id")) channelId, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
...@@ -423,8 +422,11 @@ func GetChannelKey(c *gin.Context) { ...@@ -423,8 +422,11 @@ func GetChannelKey(c *gin.Context) {
return return
} }
// 记录操作日志 // 记录操作审计日志(高危:查看渠道密钥)
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) recordManageAudit(c, "channel.key_view", map[string]interface{}{
"id": channelId,
"name": channel.Name,
})
// 返回渠道密钥 // 返回渠道密钥
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -677,6 +679,11 @@ func AddChannel(c *gin.Context) { ...@@ -677,6 +679,11 @@ func AddChannel(c *gin.Context) {
return return
} }
service.ResetProxyClientCache() service.ResetProxyClientCache()
recordManageAudit(c, "channel.create", map[string]interface{}{
"name": addChannelRequest.Channel.Name,
"type": addChannelRequest.Channel.Type,
"count": len(channels),
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -686,6 +693,10 @@ func AddChannel(c *gin.Context) { ...@@ -686,6 +693,10 @@ func AddChannel(c *gin.Context) {
func DeleteChannel(c *gin.Context) { func DeleteChannel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
channelName := ""
if existing, err := model.GetChannelById(id, false); err == nil && existing != nil {
channelName = existing.Name
}
channel := model.Channel{Id: id} channel := model.Channel{Id: id}
err := channel.Delete() err := channel.Delete()
if err != nil { if err != nil {
...@@ -693,6 +704,10 @@ func DeleteChannel(c *gin.Context) { ...@@ -693,6 +704,10 @@ func DeleteChannel(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.delete", map[string]interface{}{
"id": id,
"name": channelName,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -707,6 +722,9 @@ func DeleteDisabledChannel(c *gin.Context) { ...@@ -707,6 +722,9 @@ func DeleteDisabledChannel(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.delete_disabled", map[string]interface{}{
"count": rows,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -743,6 +761,9 @@ func DisableTagChannels(c *gin.Context) { ...@@ -743,6 +761,9 @@ func DisableTagChannels(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.tag_disable", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -766,6 +787,9 @@ func EnableTagChannels(c *gin.Context) { ...@@ -766,6 +787,9 @@ func EnableTagChannels(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.tag_enable", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -818,6 +842,9 @@ func EditTagChannels(c *gin.Context) { ...@@ -818,6 +842,9 @@ func EditTagChannels(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.tag_edit", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -846,6 +873,9 @@ func DeleteChannelBatch(c *gin.Context) { ...@@ -846,6 +873,9 @@ func DeleteChannelBatch(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.delete_batch", map[string]interface{}{
"count": len(channelBatch.Ids),
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -981,6 +1011,31 @@ func UpdateChannel(c *gin.Context) { ...@@ -981,6 +1011,31 @@ func UpdateChannel(c *gin.Context) {
} }
model.InitChannelCache() model.InitChannelCache()
service.ResetProxyClientCache() service.ResetProxyClientCache()
// 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。
changedFields := make([]string, 0)
if channel.Status != originChannel.Status {
changedFields = append(changedFields, "status")
}
if channel.Models != originChannel.Models {
changedFields = append(changedFields, "models")
}
if channel.Group != originChannel.Group {
changedFields = append(changedFields, "group")
}
if channel.Type != originChannel.Type {
changedFields = append(changedFields, "type")
}
if !equalStringPtr(channel.BaseURL, originChannel.BaseURL) {
changedFields = append(changedFields, "base_url")
}
if channel.Key != "" && channel.Key != originChannel.Key {
changedFields = append(changedFields, "key")
}
recordManageAudit(c, "channel.update", map[string]interface{}{
"id": channel.Id,
"name": channel.Name,
"changed_fields": changedFields,
})
channel.Key = "" channel.Key = ""
clearChannelInfo(&channel.Channel) clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -991,6 +1046,17 @@ func UpdateChannel(c *gin.Context) { ...@@ -991,6 +1046,17 @@ func UpdateChannel(c *gin.Context) {
return return
} }
// equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func FetchModels(c *gin.Context) { func FetchModels(c *gin.Context) {
var req struct { var req struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
...@@ -1127,6 +1193,9 @@ func BatchSetChannelTag(c *gin.Context) { ...@@ -1127,6 +1193,9 @@ func BatchSetChannelTag(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.tag_batch_set", map[string]interface{}{
"count": len(channelBatch.Ids),
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -1224,6 +1293,11 @@ func CopyChannel(c *gin.Context) { ...@@ -1224,6 +1293,11 @@ func CopyChannel(c *gin.Context) {
return return
} }
model.InitChannelCache() model.InitChannelCache()
recordManageAudit(c, "channel.copy", map[string]interface{}{
"sourceId": id,
"id": clone.Id,
"name": clone.Name,
})
// success // success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
} }
...@@ -1285,6 +1359,16 @@ func ManageMultiKeys(c *gin.Context) { ...@@ -1285,6 +1359,16 @@ func ManageMultiKeys(c *gin.Context) {
return return
} }
// get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。
if request.Action == "get_key_status" {
markAuditLogged(c)
} else {
recordManageAudit(c, "channel.multi_key_manage", map[string]interface{}{
"action": request.Action,
"id": channel.Id,
})
}
lock := model.GetChannelPollingLock(channel.Id) lock := model.GetChannelPollingLock(channel.Id)
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
......
...@@ -717,6 +717,9 @@ func ApplyChannelUpstreamModelUpdates(c *gin.Context) { ...@@ -717,6 +717,9 @@ func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
refreshChannelRuntimeCache() refreshChannelRuntimeCache()
} }
recordManageAudit(c, "channel.upstream_apply", map[string]interface{}{
"id": channel.Id,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -912,6 +915,9 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) { ...@@ -912,6 +915,9 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
refreshChannelRuntimeCache() refreshChannelRuntimeCache()
} }
recordManageAudit(c, "channel.upstream_apply_all", map[string]interface{}{
"count": len(results),
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
......
...@@ -337,6 +337,10 @@ func UpdateOption(c *gin.Context) { ...@@ -337,6 +337,10 @@ func UpdateOption(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
recordManageAudit(c, "option.update", map[string]interface{}{
"key": option.Key,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
......
...@@ -143,6 +143,7 @@ func PasskeyRegisterFinish(c *gin.Context) { ...@@ -143,6 +143,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
return return
} }
recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Passkey 注册成功", "message": "Passkey 注册成功",
...@@ -168,6 +169,7 @@ func PasskeyDelete(c *gin.Context) { ...@@ -168,6 +169,7 @@ func PasskeyDelete(c *gin.Context) {
return return
} }
recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Passkey 已解绑", "message": "Passkey 已解绑",
...@@ -335,7 +337,6 @@ func PasskeyLoginFinish(c *gin.Context) { ...@@ -335,7 +337,6 @@ func PasskeyLoginFinish(c *gin.Context) {
} }
setupLogin(modelUser, c) setupLogin(modelUser, c)
return
} }
func AdminResetPasskey(c *gin.Context) { func AdminResetPasskey(c *gin.Context) {
...@@ -373,6 +374,10 @@ func AdminResetPasskey(c *gin.Context) { ...@@ -373,6 +374,10 @@ func AdminResetPasskey(c *gin.Context) {
return return
} }
recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]interface{}{
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Passkey 已重置", "message": "Passkey 已重置",
......
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
...@@ -110,6 +111,11 @@ func AddRedemption(c *gin.Context) { ...@@ -110,6 +111,11 @@ func AddRedemption(c *gin.Context) {
} }
keys = append(keys, key) keys = append(keys, key)
} }
recordManageAudit(c, "redemption.create", map[string]interface{}{
"name": redemption.Name,
"count": redemption.Count,
"quota": logger.LogQuota(redemption.Quota),
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
......
...@@ -541,15 +541,7 @@ func AdminDisable2FA(c *gin.Context) { ...@@ -541,15 +541,7 @@ func AdminDisable2FA(c *gin.Context) {
return return
} }
// 记录操作日志:管理员身份通过 admin_info 传递,避免在非管理员可见的日志内容中泄露。 recordManageAuditFor(c, userId, "user.2fa_disable", nil)
adminId := c.GetInt("id")
adminName := c.GetString("username")
adminInfo := map[string]interface{}{
"admin_id": adminId,
"admin_username": adminName,
}
model.RecordLogWithAdminInfo(userId, model.LogTypeManage,
"管理员强制禁用了用户的两步验证", adminInfo)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
......
...@@ -90,6 +90,43 @@ func Login(c *gin.Context) { ...@@ -90,6 +90,43 @@ func Login(c *gin.Context) {
setupLogin(&user, c) setupLogin(&user, c)
} }
// loginMethodFromContext 根据请求路径推导登录方式,用于登录审计日志。
func loginMethodFromContext(c *gin.Context) string {
switch c.FullPath() {
case "/api/user/login":
return "password"
case "/api/user/login/2fa":
return "2fa"
case "/api/user/passkey/login/finish":
return "passkey"
case "/api/oauth/wechat":
return "wechat"
case "/api/oauth/telegram/login":
return "telegram"
case "/api/oauth/:provider":
if provider := c.Param("provider"); provider != "" {
return "oauth:" + provider
}
return "oauth"
default:
return "unknown"
}
}
// recordLoginAudit 记录登录成功审计日志(对所有用户启用,仅记录成功,不记录失败)。
func recordLoginAudit(user *model.User, c *gin.Context) {
method := loginMethodFromContext(c)
ip := c.ClientIP()
extra := map[string]interface{}{
"login_method": method,
"user_agent": c.Request.UserAgent(),
}
content := fmt.Sprintf("Logged in successfully via %s", method)
model.RecordLoginLog(user.Id, user.Username, content, ip, "login", map[string]interface{}{
"method": method,
}, extra)
}
// setup session & cookies and then return user info // setup session & cookies and then return user info
func setupLogin(user *model.User, c *gin.Context) { func setupLogin(user *model.User, c *gin.Context) {
model.UpdateUserLastLoginAt(user.Id) model.UpdateUserLastLoginAt(user.Id)
...@@ -104,6 +141,7 @@ func setupLogin(user *model.User, c *gin.Context) { ...@@ -104,6 +141,7 @@ func setupLogin(user *model.User, c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
return return
} }
recordLoginAudit(user, c)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "", "message": "",
"success": true, "success": true,
...@@ -599,6 +637,10 @@ func UpdateUser(c *gin.Context) { ...@@ -599,6 +637,10 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
"username": originUser.Username,
"id": updatedUser.Id,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -636,7 +678,10 @@ func AdminClearUserBinding(c *gin.Context) { ...@@ -636,7 +678,10 @@ func AdminClearUserBinding(c *gin.Context) {
return return
} }
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username)) recordManageAuditFor(c, user.Id, "user.binding_clear", map[string]interface{}{
"bindingType": bindingType,
"username": user.Username,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
...@@ -797,6 +842,10 @@ func DeleteUser(c *gin.Context) { ...@@ -797,6 +842,10 @@ func DeleteUser(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordManageAuditFor(c, originUser.Id, "user.delete", map[string]interface{}{
"username": originUser.Username,
"id": originUser.Id,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -857,6 +906,10 @@ func CreateUser(c *gin.Context) { ...@@ -857,6 +906,10 @@ func CreateUser(c *gin.Context) {
return return
} }
recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]interface{}{
"username": cleanUser.Username,
"role": cleanUser.Role,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -941,12 +994,6 @@ func ManageUser(c *gin.Context) { ...@@ -941,12 +994,6 @@ func ManageUser(c *gin.Context) {
} }
user.Role = common.RoleCommonUser user.Role = common.RoleCommonUser
case "add_quota": case "add_quota":
adminName := c.GetString("username")
adminId := c.GetInt("id")
adminInfo := map[string]interface{}{
"admin_id": adminId,
"admin_username": adminName,
}
switch req.Mode { switch req.Mode {
case "add": case "add":
if req.Value <= 0 { if req.Value <= 0 {
...@@ -957,8 +1004,9 @@ func ManageUser(c *gin.Context) { ...@@ -957,8 +1004,9 @@ func ManageUser(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage, recordManageAuditFor(c, user.Id, "user.quota_add", map[string]interface{}{
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)), adminInfo) "quota": logger.LogQuota(req.Value),
})
case "subtract": case "subtract":
if req.Value <= 0 { if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero) common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
...@@ -968,16 +1016,19 @@ func ManageUser(c *gin.Context) { ...@@ -968,16 +1016,19 @@ func ManageUser(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage, recordManageAuditFor(c, user.Id, "user.quota_subtract", map[string]interface{}{
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)), adminInfo) "quota": logger.LogQuota(req.Value),
})
case "override": case "override":
oldQuota := user.Quota oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil { if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage, recordManageAuditFor(c, user.Id, "user.quota_override", map[string]interface{}{
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)), adminInfo) "from": logger.LogQuota(oldQuota),
"to": logger.LogQuota(req.Value),
})
default: default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
...@@ -1005,6 +1056,11 @@ func ManageUser(c *gin.Context) { ...@@ -1005,6 +1056,11 @@ func ManageUser(c *gin.Context) {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
} }
} }
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
"username": user.Username,
"id": user.Id,
})
clearUser := model.User{ clearUser := model.User{
Role: user.Role, Role: user.Role,
Status: user.Status, Status: user.Status,
......
package middleware
import (
"bytes"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
)
// auditResponseWriter 包装 gin.ResponseWriter,捕获响应状态码并将响应体复制一份到
// 有限大小的缓冲区,用于判断业务是否成功(解析响应 JSON 的 success 字段)。
// 缓冲区有上限,避免大响应(如密钥导出)占用过多内存;超出上限则不再缓存,
// 此时仅依据 HTTP 状态码判断成败。
type auditResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
maxSize int
}
func (w *auditResponseWriter) Write(b []byte) (int, error) {
if w.body.Len() < w.maxSize {
remain := w.maxSize - w.body.Len()
if remain >= len(b) {
w.body.Write(b)
} else {
w.body.Write(b[:remain])
}
}
return w.ResponseWriter.Write(b)
}
func (w *auditResponseWriter) WriteString(s string) (int, error) {
return w.Write([]byte(s))
}
// auditRouteActions 将「METHOD + 路由模板」映射为语言无关的操作标识 action。
// 这些是未被 handler 手动埋点的写操作,由中间件兜底记录;前端依据 action 用 i18n 本地化展示。
// 未命中的写操作回退为 action="generic",前端展示 "METHOD route"。
var auditRouteActions = map[string]string{
// 用户管理
"POST /api/user/topup/complete": "user.topup_complete",
"DELETE /api/user/:id/reset_passkey": "user.reset_passkey",
"DELETE /api/user/:id/oauth/bindings/:provider_id": "user.oauth_unbind",
// 系统设置(root)
"POST /api/option/payment_compliance": "option.payment_compliance",
"POST /api/option/rest_model_ratio": "option.reset_ratio",
"DELETE /api/option/channel_affinity_cache": "option.clear_affinity_cache",
// 自定义 OAuth(root)
"POST /api/custom-oauth-provider/": "custom_oauth.create",
"PUT /api/custom-oauth-provider/:id": "custom_oauth.update",
"DELETE /api/custom-oauth-provider/:id": "custom_oauth.delete",
// 性能/缓存(root)
"DELETE /api/performance/disk_cache": "performance.clear_disk_cache",
"POST /api/performance/gc": "performance.gc",
"DELETE /api/performance/logs": "performance.clear_logs",
// 兑换码
"PUT /api/redemption/": "redemption.update",
"DELETE /api/redemption/:id": "redemption.delete",
"DELETE /api/redemption/invalid": "redemption.delete_invalid",
// 预填组
"POST /api/prefill_group/": "prefill_group.create",
"PUT /api/prefill_group/": "prefill_group.update",
"DELETE /api/prefill_group/:id": "prefill_group.delete",
// 供应商
"POST /api/vendors/": "vendor.create",
"PUT /api/vendors/": "vendor.update",
"DELETE /api/vendors/:id": "vendor.delete",
// 模型元数据
"POST /api/models/": "model.create",
"PUT /api/models/": "model.update",
"DELETE /api/models/:id": "model.delete",
"POST /api/models/sync_upstream": "model.sync_upstream",
// 部署
"POST /api/deployments/": "deployment.create",
"PUT /api/deployments/:id": "deployment.update",
"DELETE /api/deployments/:id": "deployment.delete",
// 订阅(管理员)
"POST /api/subscription/admin/plans": "subscription.plan_create",
"PUT /api/subscription/admin/plans/:id": "subscription.plan_update",
"POST /api/subscription/admin/bind": "subscription.bind",
// 日志
"DELETE /api/log/": "log.clear",
}
// beginAdminAudit 在管理/root 写操作进入 handler 前包装 ResponseWriter,
// 以便事后解析响应判断业务是否成功。仅对写方法(POST/PUT/PATCH/DELETE)生效;
// 只读请求返回 nil,调用方据此跳过事后兜底记录。
//
// 该函数由 authHelper 在鉴权通过、c.Next() 之前调用:因为任何管理/root 接口都
// 必然经过 AdminAuth/RootAuth,将审计兜底内聚到鉴权链路即可保证「新增接口自动留痕」,
// 无需在路由上再单独挂一层审计中间件(避免漏挂)。
func beginAdminAudit(c *gin.Context) *auditResponseWriter {
method := c.Request.Method
if method != "POST" && method != "PUT" && method != "PATCH" && method != "DELETE" {
return nil
}
writer := &auditResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
maxSize: 64 * 1024,
}
c.Writer = writer
return writer
}
// finishAdminAudit 在 c.Next() 之后对管理/高危写操作做兜底审计记录。
// 若 handler 内已手动埋点(设置 ContextKeyAuditLogged),则跳过,避免重复。
func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) {
if writer == nil {
return
}
method := c.Request.Method
// handler 已手动记录更精细的审计日志,跳过兜底。
if common.GetContextKeyBool(c, constant.ContextKeyAuditLogged) {
return
}
operatorId := c.GetInt("id")
operatorName := c.GetString("username")
operatorRole := c.GetInt("role")
ip := c.ClientIP()
status := writer.Status()
success := auditResponseSuccess(status, writer.body.Bytes())
route := c.FullPath()
action := auditRouteActions[method+" "+route]
if action == "" {
action = "generic"
}
routeParams := map[string]string{}
for _, p := range c.Params {
routeParams[p.Key] = p.Value
}
// op.params 为语言无关参数,供前端 i18n 渲染;generic 时携带 method/route。
opParams := map[string]interface{}{}
if action == "generic" {
opParams["method"] = method
opParams["route"] = route
}
// content 为英文兜底文本(导出/经典前端用)。
content := method + " " + route
adminInfo := map[string]interface{}{
"admin_id": operatorId,
"admin_username": operatorName,
"admin_role": operatorRole,
}
auditInfo := map[string]interface{}{
"method": method,
"route": route,
"path": c.Request.URL.Path,
"status": status,
"success": success,
}
if len(routeParams) > 0 {
auditInfo["params"] = routeParams
}
gopool.Go(func() {
model.RecordOperationAuditLog(operatorId, content, ip, action, opParams, adminInfo, auditInfo)
})
}
// auditResponseSuccess 依据 HTTP 状态码与响应体推断操作是否成功。
// 优先解析响应 JSON 中的 success 字段;无法解析时退回到状态码判断。
func auditResponseSuccess(status int, body []byte) bool {
if status >= 400 {
return false
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) > 0 && trimmed[0] == '{' {
var resp struct {
Success *bool `json:"success"`
}
if err := common.Unmarshal(trimmed, &resp); err == nil && resp.Success != nil {
return *resp.Success
}
}
return status < 400
}
...@@ -153,7 +153,17 @@ func authHelper(c *gin.Context, minRole int) { ...@@ -153,7 +153,17 @@ func authHelper(c *gin.Context, minRole int) {
c.Set("user_group", session.Get("group")) c.Set("user_group", session.Get("group"))
c.Set("use_access_token", useAccessToken) c.Set("use_access_token", useAccessToken)
// 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth
// 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。
// handler 内手动埋点者会设置 ContextKeyAuditLogged,finishAdminAudit 据此跳过。
var auditWriter *auditResponseWriter
if minRole >= common.RoleAdminUser {
auditWriter = beginAdminAudit(c)
}
c.Next() c.Next()
finishAdminAudit(c, auditWriter)
} }
func TryUserAuth() func(c *gin.Context) { func TryUserAuth() func(c *gin.Context) {
......
...@@ -64,6 +64,7 @@ const ( ...@@ -64,6 +64,7 @@ const (
LogTypeSystem = 4 LogTypeSystem = 4
LogTypeError = 5 LogTypeError = 5
LogTypeRefund = 6 LogTypeRefund = 6
LogTypeLogin = 7
) )
func formatUserLogs(logs []*Log, startIdx int) { func formatUserLogs(logs []*Log, startIdx int) {
...@@ -74,6 +75,8 @@ func formatUserLogs(logs []*Log, startIdx int) { ...@@ -74,6 +75,8 @@ func formatUserLogs(logs []*Log, startIdx int) {
if otherMap != nil { if otherMap != nil {
// Remove admin-only debug fields. // Remove admin-only debug fields.
delete(otherMap, "admin_info") delete(otherMap, "admin_info")
// Remove operation-audit details (operator/route info), admin-only.
delete(otherMap, "audit_info")
// delete(otherMap, "reject_reason") // delete(otherMap, "reject_reason")
delete(otherMap, "stream_status") delete(otherMap, "stream_status")
} }
...@@ -130,6 +133,74 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m ...@@ -130,6 +133,74 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m
} }
} }
// buildOpField 构建语言无关的操作描述(写入 Other.op)。
// 前端依据 action(稳定操作标识) + params(结构化参数) 在渲染期用 i18n 本地化展示,
// 因此不在数据库中存储自然语言句子。
func buildOpField(action string, params map[string]interface{}) map[string]interface{} {
op := map[string]interface{}{
"action": action,
}
if len(params) > 0 {
op["params"] = params
}
return op
}
// RecordLoginLog 记录用户登录成功的审计日志(type=LogTypeLogin)。
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
// content 为英文兜底文本(用于导出/经典前端);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) {
other := map[string]interface{}{}
for k, v := range extra {
other[k] = v
}
other["op"] = buildOpField(action, params)
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeLogin,
Content: content,
Ip: ip,
Other: common.MapToJsonStr(other),
}
if err := LOG_DB.Create(log).Error; err != nil {
common.SysLog("failed to record login log: " + err.Error())
}
}
// RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。
// logUserId 为日志归属者(面向用户的操作如额度调整归属目标用户,资源类操作如渠道/系统设置归属操作者),
// username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
func RecordOperationAuditLog(logUserId int, content string, ip string, action string, params map[string]interface{}, adminInfo map[string]interface{}, auditInfo map[string]interface{}) {
username, _ := GetUsernameById(logUserId, false)
other := map[string]interface{}{
"op": buildOpField(action, params),
}
if len(adminInfo) > 0 {
other["admin_info"] = adminInfo
}
if len(auditInfo) > 0 {
other["audit_info"] = auditInfo
}
log := &Log{
UserId: logUserId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeManage,
Content: content,
Ip: ip,
Other: common.MapToJsonStr(other),
}
if err := LOG_DB.Create(log).Error; err != nil {
common.SysLog("failed to record operation audit log: " + err.Error())
}
}
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) { func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
username, _ := GetUsernameById(userId, false) username, _ := GetUsernameById(userId, false)
adminInfo := map[string]interface{}{ adminInfo := map[string]interface{}{
......
...@@ -46,6 +46,7 @@ import { ...@@ -46,6 +46,7 @@ import {
hasAnyCacheTokens, hasAnyCacheTokens,
parseLogOther, parseLogOther,
isViolationFeeLog, isViolationFeeLog,
renderAuditContent,
} from '../../lib/format' } from '../../lib/format'
import { import {
isDisplayableLogType, isDisplayableLogType,
...@@ -100,6 +101,13 @@ function buildDetailSegments( ...@@ -100,6 +101,13 @@ function buildDetailSegments(
other: LogOtherData | null, other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] { ): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the
// structured op descriptor instead of the raw (English-fallback) content.
if (log.type === 3 || log.type === 7) {
const text = renderAuditContent(other, t)
return text ? [{ text }] : []
}
if (log.type === 6) { if (log.type === 6) {
return [{ text: t('Async task refund') }] return [{ text: t('Async task refund') }]
} }
......
...@@ -29,6 +29,7 @@ import { ...@@ -29,6 +29,7 @@ import {
ShieldCheck, ShieldCheck,
UserCog, UserCog,
Info, Info,
LogIn,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency' import { formatBillingCurrencyFromUSD } from '@/lib/currency'
...@@ -52,6 +53,7 @@ import { ...@@ -52,6 +53,7 @@ import {
isViolationFeeLog, isViolationFeeLog,
getFirstResponseTimeColor, getFirstResponseTimeColor,
getResponseTimeColor, getResponseTimeColor,
renderAuditContent,
} from '../../lib/format' } from '../../lib/format'
import { import {
getLogTypeConfig, getLogTypeConfig,
...@@ -60,6 +62,17 @@ import { ...@@ -60,6 +62,17 @@ import {
} from '../../lib/utils' } from '../../lib/utils'
import type { LogOtherData } from '../../types' import type { LogOtherData } from '../../types'
// Maps a channel-update changed-field token (as recorded by the backend audit)
// to its i18n label key for display in the audit details.
const CHANNEL_FIELD_LABELS: Record<string, string> = {
status: 'Status',
models: 'Models',
group: 'Group',
type: 'Type',
base_url: 'Base URL',
key: 'Key',
}
function timingTextColorClass( function timingTextColorClass(
variant: 'success' | 'warning' | 'danger' variant: 'success' | 'warning' | 'danger'
): string { ): string {
...@@ -461,6 +474,41 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -461,6 +474,41 @@ export function DetailsDialog(props: DetailsDialogProps) {
return `ID: ${id}` return `ID: ${id}`
})() })()
// Localized operation text rendered from the language-independent op
// descriptor (shared by audit type=3 and login type=7).
const operationText = renderAuditContent(other, t)
const auditRoute = isManage && props.isAdmin ? other?.audit_info : undefined
// Channel update records which fields changed (stable field tokens); render
// them with their localized labels for admins.
const changedFieldTokens =
isManage && props.isAdmin && Array.isArray(other?.op?.params?.changed_fields)
? (other.op.params.changed_fields as string[])
: []
const changedFieldsText = changedFieldTokens
.map((field) => t(CHANNEL_FIELD_LABELS[field] ?? field))
.join(', ')
const showManageAuditSection =
isManage && props.isAdmin && (operationText != null || auditRoute != null)
// Login audit (type=7); visible to the log owner, not admin-only.
const isLogin = props.log.type === 7
const loginAuditFields = isLogin
? ([
other?.login_method && {
label: t('Login Method'),
value: String(other.login_method),
},
props.log.ip && {
label: t('IP Address'),
value: props.log.ip,
},
other?.user_agent && {
label: t('User Agent'),
value: String(other.user_agent),
},
].filter(Boolean) as Array<{ label: string; value: string }>)
: []
const conversionChain = const conversionChain =
other && Array.isArray(other.request_conversion) other && Array.isArray(other.request_conversion)
? other.request_conversion.filter(Boolean) ? other.request_conversion.filter(Boolean)
...@@ -749,6 +797,62 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -749,6 +797,62 @@ export function DetailsDialog(props: DetailsDialogProps) {
/> />
)} )}
{/* Operation audit info (type=3, admin only) */}
{showManageAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
label={t('Operation Audit Info')}
>
{operationText != null && (
<DetailRow label={t('Operation')} value={operationText} />
)}
{changedFieldsText !== '' && (
<DetailRow
label={t('Changed Fields')}
value={changedFieldsText}
/>
)}
{auditRoute?.method && auditRoute?.route && (
<DetailRow
label={t('Request')}
value={`${auditRoute.method} ${auditRoute.route}`}
mono
/>
)}
{auditRoute?.status != null && (
<DetailRow
label={t('Result')}
value={
auditRoute.success
? `${t('Success')} (${auditRoute.status})`
: `${t('Failed')} (${auditRoute.status})`
}
mono
/>
)}
</DetailSection>
)}
{/* Login audit info (type=7) */}
{isLogin && loginAuditFields.length > 0 && (
<DetailSection
icon={<LogIn className='size-3.5' aria-hidden='true' />}
label={t('Login Info')}
>
{operationText != null && (
<DetailRow label={t('Operation')} value={operationText} />
)}
{loginAuditFields.map((field, idx) => (
<DetailRow
key={idx}
label={field.label}
value={field.value}
mono
/>
))}
</DetailSection>
)}
{/* Audio/WebSocket token breakdown */} {/* Audio/WebSocket token breakdown */}
{hasAudioTokens && other && ( {hasAudioTokens && other && (
<DetailSection <DetailSection
......
...@@ -58,6 +58,7 @@ export const LOG_TYPE_ENUM = { ...@@ -58,6 +58,7 @@ export const LOG_TYPE_ENUM = {
SYSTEM: 4, SYSTEM: 4,
ERROR: 5, ERROR: 5,
REFUND: 6, REFUND: 6,
LOGIN: 7,
} as const } as const
/** /**
...@@ -95,6 +96,7 @@ export const LOG_TYPES = [ ...@@ -95,6 +96,7 @@ export const LOG_TYPES = [
{ value: 4, label: 'System', color: 'purple' }, { value: 4, label: 'System', color: 'purple' },
{ value: 5, label: 'Error', color: 'red' }, { value: 5, label: 'Error', color: 'red' },
{ value: 6, label: 'Refund', color: 'blue' }, { value: 6, label: 'Refund', color: 'blue' },
{ value: 7, label: 'Login', color: 'teal' },
] as const ] as const
/** /**
......
...@@ -298,3 +298,109 @@ export function formatDuration( ...@@ -298,3 +298,109 @@ export function formatDuration(
return { durationSec, variant: durationSec > 60 ? 'red' : 'green' } return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
} }
/**
* Maps a language-independent audit/login operation `action` to an i18n
* template string (the template itself is the i18n key, with {{placeholders}}).
*
* The backend stores only `action` + structured `params` in `other.op`; the UI
* renders localized content at display time so audit/login logs are fully
* translatable instead of being frozen to whatever language was written to DB.
*/
const AUDIT_TEMPLATES: Record<string, string> = {
login: 'Logged in successfully via {{method}}',
// User management
'user.create': 'Created user {{username}} (role {{role}})',
'user.update': 'Updated user {{username}} (ID: {{id}})',
'user.delete': 'Deleted user {{username}} (ID: {{id}})',
'user.manage': 'Performed {{action}} on user {{username}} (ID: {{id}})',
'user.quota_add': 'Increased user quota by {{quota}}',
'user.quota_subtract': 'Decreased user quota by {{quota}}',
'user.quota_override': 'Overrode user quota from {{from}} to {{to}}',
'user.binding_clear': 'Cleared {{bindingType}} binding for user {{username}}',
'user.2fa_disable': 'Force-disabled two-factor authentication for the user',
'user.passkey_register': 'Registered a passkey',
'user.passkey_delete': 'Deleted a passkey',
'user.topup_complete': 'Completed top-up order for the user',
'user.reset_passkey': 'Reset the user passkey',
'user.oauth_unbind': 'Removed an OAuth binding for the user',
// System settings
'option.update': 'Updated system setting {{key}}',
'option.payment_compliance': 'Confirmed payment compliance',
'option.reset_ratio': 'Reset model ratios',
'option.clear_affinity_cache': 'Cleared channel affinity cache',
// Custom OAuth
'custom_oauth.create': 'Created a custom OAuth provider',
'custom_oauth.update': 'Updated a custom OAuth provider',
'custom_oauth.delete': 'Deleted a custom OAuth provider',
// Performance / cache
'performance.clear_disk_cache': 'Cleared disk cache',
'performance.gc': 'Triggered garbage collection',
'performance.clear_logs': 'Cleared log files',
// Channel
'channel.create': 'Created channel {{name}} (type {{type}}, count {{count}})',
'channel.update': 'Updated channel {{name}} (ID: {{id}})',
'channel.delete': 'Deleted channel {{name}} (ID: {{id}})',
'channel.delete_batch': 'Batch deleted {{count}} channels',
'channel.delete_disabled': 'Deleted all disabled channels ({{count}})',
'channel.key_view': 'Viewed channel key {{name}} (ID: {{id}})',
'channel.tag_disable': 'Disabled channels with tag {{tag}}',
'channel.tag_enable': 'Enabled channels with tag {{tag}}',
'channel.tag_edit': 'Edited channels with tag {{tag}}',
'channel.tag_batch_set': 'Batch set tag for {{count}} channels',
'channel.copy':
'Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})',
'channel.multi_key_manage':
'Multi-key management {{action}} on channel (ID: {{id}})',
'channel.upstream_apply':
'Applied upstream model changes to channel (ID: {{id}})',
'channel.upstream_apply_all':
'Applied upstream model changes to {{count}} channels',
// Redemption codes
'redemption.create':
'Created {{count}} redemption codes named {{name}} ({{quota}} each)',
'redemption.update': 'Updated a redemption code',
'redemption.delete': 'Deleted a redemption code',
'redemption.delete_invalid': 'Deleted invalid redemption codes',
// Prefill groups
'prefill_group.create': 'Created a prefill group',
'prefill_group.update': 'Updated a prefill group',
'prefill_group.delete': 'Deleted a prefill group',
// Vendors
'vendor.create': 'Created a vendor',
'vendor.update': 'Updated a vendor',
'vendor.delete': 'Deleted a vendor',
// Model metadata
'model.create': 'Created a model',
'model.update': 'Updated a model',
'model.delete': 'Deleted a model',
'model.sync_upstream': 'Synced upstream models',
// Deployments
'deployment.create': 'Created a deployment',
'deployment.update': 'Updated a deployment',
'deployment.delete': 'Deleted a deployment',
// Subscriptions
'subscription.plan_create': 'Created a subscription plan',
'subscription.plan_update': 'Updated a subscription plan',
'subscription.bind': 'Bound a subscription',
// Logs
'log.clear': 'Cleared historical logs',
// Generic middleware fallback
generic: '{{method}} {{route}}',
}
/**
* Render the localized content of an audit/login log from its structured
* `other.op` descriptor. Returns null when the log has no recognized action,
* letting callers fall back to the raw `content` field.
*/
export function renderAuditContent(
other: LogOtherData | null | undefined,
t: (key: string, opts?: Record<string, unknown>) => string
): string | null {
const op = other?.op
if (!op?.action) return null
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
return t(template, (op.params ?? {}) as Record<string, unknown>)
}
...@@ -106,10 +106,29 @@ export interface LogOtherData { ...@@ -106,10 +106,29 @@ export interface LogOtherData {
server_ip?: string server_ip?: string
version?: string version?: string
node_name?: string node_name?: string
// Manage audit fields (type=3, admin only) // Operator identity for audit logs (type=3, admin only)
admin_username?: string admin_username?: string
admin_id?: number | string admin_id?: number | string
admin_role?: number
} }
// Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates.
op?: {
action?: string
params?: Record<string, string | number | boolean | string[]>
}
// Operation audit details written by the admin-audit fallback in authHelper (type=3, admin only)
audit_info?: {
method?: string
route?: string
path?: string
status?: number
success?: boolean
params?: Record<string, string>
}
// Login audit fields (type=7); visible to the log owner
login_method?: string
user_agent?: string
request_path?: string request_path?: string
request_conversion?: string[] request_conversion?: string[]
ws?: boolean ws?: boolean
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment