Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ var auditContentTemplates = map[string]string{

"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",

"token.admin_create": "Created token for user ${target_username} (ID: ${target_user_id})",
"token.admin_update": "Updated token ${token_name} (ID: ${token_id}) of user ${target_username}",
"token.admin_delete": "Deleted token ${token_name} (ID: ${token_id}) of user ${target_username}",
"token.admin_batch_delete": "Batch deleted ${count} tokens of user ${target_username}",
}

// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
Expand Down
157 changes: 138 additions & 19 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,27 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
// Root 可不传 user_id 看全部,也可传 ?user_id=X 筛选
if c.GetInt("role") == common.RoleRootUser {
filterUserId, _ := strconv.Atoi(c.Query("user_id"))
tokens, err := model.GetAllTokensAdmin(filterUserId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
total, _ := model.CountTokensAdmin(filterUserId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
} else {
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
}
Comment on lines +122 to 142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle the administrator count error.

Line 130 discards an error from CountTokensAdmin. If the count query fails, this handler returns a successful page with total = 0. Return the database error instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/token.go` around lines 122 - 142, Handle the error returned by
CountTokensAdmin in the root-user branch before setting page totals; call
common.ApiError with the database error and return on failure, while preserving
the existing successful pagination flow.

total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
}

Expand All @@ -137,13 +150,25 @@ func SearchTokens(c *gin.Context) {

pageInfo := common.GetPageQuery(c)

tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
// Root 可不传 user_id 搜全部,也可传 ?user_id=X 筛选
if c.GetInt("role") == common.RoleRootUser {
filterUserId, _ := strconv.Atoi(c.Query("user_id"))
tokens, total, err := model.SearchTokensAdmin(filterUserId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
} else {
tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
}

Expand All @@ -154,7 +179,18 @@ func GetToken(c *gin.Context) {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
// Root 可查看任意用户的令牌,非 Root 仅限自己的
var token *model.Token
if c.GetInt("role") == common.RoleRootUser {
token, err = model.GetTokenById(id)
if err == nil {
if user, uErr := model.GetUserCache(token.UserId); uErr == nil {
token.Username = user.Username
}
}
} else {
token, err = model.GetTokenByIds(id, userId)
}
if err != nil {
common.ApiError(c, err)
return
Expand All @@ -181,7 +217,13 @@ func GetTokenKey(c *gin.Context) {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
// Root 可查看任意令牌的 Key,非 Root 仅限自己的
var token *model.Token
if c.GetInt("role") == common.RoleRootUser {
token, err = model.GetTokenById(id)
} else {
token, err = model.GetTokenByIds(id, userId)
}
if err != nil {
common.ApiError(c, err)
return
Expand Down Expand Up @@ -269,6 +311,18 @@ func AddToken(c *gin.Context) {
return
}
token := request.Token

// Root 可通过 user_id 为其他用户创建令牌
isAdminCreate := c.GetInt("role") == common.RoleRootUser && token.UserId > 0 && token.UserId != c.GetInt("id")
if isAdminCreate {
if _, err := model.GetUserCache(token.UserId); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
Comment on lines +318 to +321

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use the resolved target username in the creation audit.

Line 318 validates the target user but discards it. cleanToken.Username is never populated by Insert, so token.admin_create records an empty ${target_username}. Retain the resolved user and pass targetUser.Username to recordManageAuditFor.

Also applies to: 391-395

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/token.go` around lines 318 - 321, Retain the user returned by
model.GetUserCache in the token creation flow instead of discarding it, then
pass targetUser.Username to recordManageAuditFor so the token.admin_create audit
receives the resolved target username. Apply the same change to the
corresponding flow around the additional referenced block.

} else {
token.UserId = c.GetInt("id")
}

if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
Expand All @@ -287,7 +341,7 @@ func AddToken(c *gin.Context) {
}
// 检查用户令牌数量是否已达上限
maxTokens := operation_setting.GetMaxUserTokens()
count, err := model.CountUserTokens(c.GetInt("id"))
count, err := model.CountUserTokens(token.UserId)
if err != nil {
common.ApiError(c, err)
return
Expand All @@ -314,7 +368,7 @@ func AddToken(c *gin.Context) {
return
}
cleanToken := model.Token{
UserId: c.GetInt("id"),
UserId: token.UserId,
Name: token.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Expand All @@ -334,6 +388,13 @@ func AddToken(c *gin.Context) {
common.ApiError(c, err)
return
}
if isAdminCreate {
recordManageAuditFor(c, token.UserId, "token.admin_create", map[string]interface{}{
"target_user_id": token.UserId,
"target_username": cleanToken.Username,
"token_name": cleanToken.Name,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -343,7 +404,29 @@ func AddToken(c *gin.Context) {
func DeleteToken(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId)
// Root 可删除任意用户的令牌,非 Root 仅限自己的
var err error
if c.GetInt("role") == common.RoleRootUser {
token, tErr := model.GetTokenById(id)
if tErr != nil {
common.ApiError(c, tErr)
return
}
if token.UserId != userId {
targetUser, _ := model.GetUserCache(token.UserId)
if targetUser != nil {
recordManageAuditFor(c, token.UserId, "token.admin_delete", map[string]interface{}{
"target_user_id": token.UserId,
"target_username": targetUser.Username,
"token_id": token.Id,
"token_name": token.Name,
})
}
Comment on lines +416 to +424

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not gate required administrator audits on the user cache.

If model.GetUserCache fails, both paths complete the mutation without an audit record. recordManageAuditFor already accepts the target user ID. Use the cache lookup only to enrich target_username, not to decide whether to record the action.

  • controller/token.go#L416-L424: Always record the cross-user deletion after the delete succeeds.
  • controller/token.go#L514-L522: Always record the cross-user update after the update succeeds.
📍 Affects 1 file
  • controller/token.go#L416-L424 (this comment)
  • controller/token.go#L514-L522
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/token.go` around lines 416 - 424, In controller/token.go lines
416-424, always call recordManageAuditFor after the cross-user deletion
succeeds; use GetUserCache only to optionally populate target_username. Apply
the same change in controller/token.go lines 514-522 for the cross-user update,
ensuring both audits are recorded even when the cache lookup fails.

}
err = token.Delete()
Comment on lines +418 to +426

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Write the deletion audit after a successful delete.

This code records token.admin_delete before token.Delete(). If the delete fails, the audit log reports an action that did not occur. Defer the audit until after the delete result is confirmed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/token.go` around lines 418 - 426, Move the recordManageAuditFor
call for the token.admin_delete event to after token.Delete() succeeds, ensuring
failed deletions do not create an audit entry. Preserve the existing audit
fields and only record the event after confirming the deletion returned no
error.

} else {
err = model.DeleteTokenById(id, userId)
}
if err != nil {
common.ApiError(c, err)
return
Expand Down Expand Up @@ -379,7 +462,13 @@ func UpdateToken(c *gin.Context) {
return
}
}
cleanToken, err := model.GetTokenByIds(token.Id, userId)
// Root 可更新任意用户的令牌,非 Root 仅限自己的
var cleanToken *model.Token
if c.GetInt("role") == common.RoleRootUser {
cleanToken, err = model.GetTokenById(token.Id)
} else {
cleanToken, err = model.GetTokenByIds(token.Id, userId)
}
if err != nil {
common.ApiError(c, err)
return
Expand Down Expand Up @@ -421,6 +510,17 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
if cleanToken.UserId != userId {
targetUser, _ := model.GetUserCache(cleanToken.UserId)
if targetUser != nil {
recordManageAuditFor(c, cleanToken.UserId, "token.admin_update", map[string]interface{}{
"target_user_id": cleanToken.UserId,
"target_username": targetUser.Username,
"token_id": cleanToken.Id,
"token_name": cleanToken.Name,
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -440,6 +540,21 @@ func DeleteTokenBatch(c *gin.Context) {
}
userId := c.GetInt("id")
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
// Root 可批量删除任意用户的令牌
if err != nil && c.GetInt("role") == common.RoleRootUser {
for _, id := range tokenBatch.Ids {
_, tErr := model.GetTokenById(id)
if tErr != nil {
common.ApiError(c, tErr)
return
}
}
count, err = model.BatchDeleteTokensAdmin(tokenBatch.Ids)
if err != nil {
common.ApiError(c, err)
return
}
}
Comment on lines +543 to +557

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Branch on the root role before calling owner-scoped batch methods.

BatchDeleteTokens and GetTokenKeysByIds return a nil error when requested IDs belong to another user; they return an empty or partial result. The err != nil fallback therefore does not run for the normal cross-user case. Root users cannot reliably delete or retrieve other users’ token keys.

  • controller/token.go#L543-L557: For root users, validate all requested IDs and call the administrator batch delete path directly.
  • controller/token.go#L581-L584: For root users, call GetTokenKeysByIdsAdmin directly and validate that all requested IDs were returned.
📍 Affects 1 file
  • controller/token.go#L543-L557 (this comment)
  • controller/token.go#L581-L584
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/token.go` around lines 543 - 557, Update controller/token.go lines
543-557 so the root-role branch is checked before the owner-scoped
BatchDeleteTokens fallback, validates every requested ID, and calls
BatchDeleteTokensAdmin directly. In controller/token.go lines 581-584, branch on
the root role before GetTokenKeysByIds, use GetTokenKeysByIdsAdmin, and validate
that all requested IDs are returned.

if err != nil {
common.ApiError(c, err)
return
Expand All @@ -463,6 +578,10 @@ func GetTokenKeysBatch(c *gin.Context) {
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
// Root 可批量获取任意令牌的 Key
if err != nil && c.GetInt("role") == common.RoleRootUser {
tokens, err = model.GetTokenKeysByIdsAdmin(tokenBatch.Ids)
}
if err != nil {
common.ApiError(c, err)
return
Expand Down
Loading