Files
new-api-custom/model/audit_log.go
T

270 lines
9.5 KiB
Go

package model
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
AuditCategoryLogin = "login"
AuditCategorySecurity = "security"
AuditCategoryOperation = "operation"
AuditCategoryAccessToken = "access_token"
)
// AuditLog is retained independently of usage logs and their cleanup/TTL policy.
// TokenRef identifies a PAT generation without storing its bearer credential.
type AuditLog struct {
Id int `json:"id"`
EventId string `json:"event_id" gorm:"type:varchar(64);uniqueIndex"`
UserId int `json:"user_id" gorm:"index:idx_audit_user_time,priority:1"`
Username string `json:"username" gorm:"type:varchar(64);index"`
ActorRole int `json:"actor_role"` // Immutable role of the actor when the event began, not the log owner.
CreatedAt int64 `json:"created_at" gorm:"type:bigint;index:idx_audit_user_time,priority:2;index:idx_audit_token_time,priority:2;index"`
Category string `json:"category" gorm:"type:varchar(24);index"`
Action string `json:"action" gorm:"type:varchar(128)"`
TokenRef string `json:"token_ref" gorm:"type:varchar(64);index:idx_audit_token_time,priority:1"`
AuthMethod string `json:"auth_method" gorm:"type:varchar(24)"`
Ip string `json:"ip" gorm:"type:varchar(64)"`
UserAgent string `json:"user_agent" gorm:"type:varchar(512)"`
Method string `json:"method" gorm:"type:varchar(16)"`
Route string `json:"route" gorm:"type:varchar(255)"`
Status int `json:"status"`
Success bool `json:"success"`
RequestId string `json:"request_id" gorm:"type:varchar(64);index"`
Content string `json:"content" gorm:"type:text"`
Other AuditOther `json:"other" gorm:"type:json"`
}
type AuditLogFilter struct {
SelfView bool // Server-selected metadata projection, independent of the viewer's actual role.
UserId int
Username string
Category string
TokenRef string
ExcludeTokenRef string
RequestId string
StartTimestamp int64
EndTimestamp int64
Success *bool
}
func AccessTokenFingerprint(token string) string {
// PostgreSQL returns CHAR(32) tokens padded with spaces. Normalize that
// storage padding so persisted tokens and incoming credentials share a ref.
token = strings.TrimRight(token, " ")
if token == "" {
return ""
}
digest := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", digest)
}
// RecordAuditLog captures safe request metadata only; raw URLs, query strings,
// credentials and response/request bodies must never enter this table.
func RecordAuditLog(c *gin.Context, entry AuditLog) {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
entry.RequestId = c.GetString(common.RequestIdKey)
entry.Ip = c.ClientIP()
entry.UserAgent = c.Request.UserAgent()
entry.Method = c.Request.Method
entry.Route = c.FullPath()
if entry.Status == 0 {
entry.Status = c.Writer.Status()
}
if entry.AuthMethod == "" {
entry.AuthMethod = "session"
if c.GetBool("use_access_token") {
entry.AuthMethod = "access_token"
}
}
}
if entry.CreatedAt == 0 {
entry.CreatedAt = common.GetTimestamp()
}
if entry.RequestId == "" {
entry.RequestId = common.NewRequestId()
}
if entry.EventId == "" {
entry.EventId = common.NewRequestId()
}
switch entry.ActorRole {
case common.RoleCommonUser, common.RoleAdminUser, common.RoleRootUser:
default:
logger.LogError(ctx, fmt.Sprintf("audit actor role unavailable (request_id=%s, actor_role=%d)", entry.RequestId, entry.ActorRole))
entry.ActorRole = 0 // Unknown actors remain visible to root only.
}
if entry.Username == "" {
entry.Username, _ = GetUsernameById(entry.UserId, false)
}
ua := []rune(entry.UserAgent)
if len(ua) > 512 {
entry.UserAgent = string(ua[:512])
}
if LOG_DB == nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): log database unavailable", entry.RequestId))
return
}
var row any = &entry
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
encoded, err := common.Marshal(entry.Other)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
return
}
// The ClickHouse GORM insert callback passes structs to the native
// driver without resolving their Valuer. Bind this column's JSON
// encoding while retaining AuditOther in the domain and API models.
row = &struct {
AuditLog `gorm:"embedded"`
EncodedOther string `gorm:"column:other;type:json"`
}{AuditLog: entry, EncodedOther: string(encoded)}
}
if err := LOG_DB.Table("audit_logs").Create(row).Error; err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
}
}
func GetAuditLogs(filter AuditLogFilter, start, limit, viewerRole int) ([]*AuditLog, int64, error) {
query := LOG_DB.Model(&AuditLog{})
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
// Decode native JSON through database/sql as text for AuditOther.Scan.
// Preserve numeric metadata instead of returning quoted Int64 values.
query = query.WithContext(clickhouse.Context(query.Statement.Context, clickhouse.WithSettings(clickhouse.Settings{
"output_format_native_write_json_as_string": 1,
"output_format_json_quote_64bit_integers": 0,
})))
}
if viewerRole < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
if filter.UserId > 0 {
query = query.Where("user_id = ?", filter.UserId)
}
if filter.Username != "" {
query = query.Where("username = ?", filter.Username)
}
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.TokenRef != "" {
query = query.Where("token_ref = ?", filter.TokenRef)
}
if filter.ExcludeTokenRef != "" {
query = query.Where("token_ref <> ?", filter.ExcludeTokenRef)
}
if filter.RequestId != "" {
query = query.Where("request_id = ?", filter.RequestId)
}
if filter.StartTimestamp > 0 {
query = query.Where("created_at >= ?", filter.StartTimestamp)
}
if filter.EndTimestamp > 0 {
query = query.Where("created_at <= ?", filter.EndTimestamp)
}
if filter.Success != nil {
query = query.Where("success = ?", *filter.Success)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
logs := make([]*AuditLog, 0)
if err := query.Order("created_at DESC").Order("event_id DESC").Offset(start).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
visibility := logOtherVisibilityUser
if !filter.SelfView && viewerRole >= common.RoleRootUser {
visibility = logOtherVisibilityRoot
} else if !filter.SelfView && viewerRole >= common.RoleAdminUser {
visibility = logOtherVisibilityAdmin
}
for _, entry := range logs {
if visibility != logOtherVisibilityRoot {
entry.Other.RootInfo = nil
}
if visibility == logOtherVisibilityUser {
entry.Other.AdminInfo = nil
entry.Other.AuditInfo = nil
}
}
return logs, total, nil
}
type UserAccessTokenStatus struct {
Exists bool `json:"exists"`
TokenRef string `json:"token_ref"`
CreatedAt *int64 `json:"created_at"`
LastUsedAt *int64 `json:"last_used_at"`
LastUsedIp string `json:"last_used_ip"`
}
func GetUserAccessTokenStatus(userId int) (*UserAccessTokenStatus, error) {
var user User
if err := DB.Select("id", "role", "access_token", "access_token_created_at").First(&user, userId).Error; err != nil {
return nil, err
}
status := &UserAccessTokenStatus{Exists: user.GetAccessToken() != ""}
if !status.Exists {
return status, nil
}
status.TokenRef = AccessTokenFingerprint(user.GetAccessToken())
status.CreatedAt = user.AccessTokenCreatedAt
var latest AuditLog
query := LOG_DB.Select("created_at", "ip").Where("user_id = ? AND token_ref = ? AND category = ?", userId, status.TokenRef, AuditCategoryAccessToken)
if user.Role < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
err := query.Order("created_at DESC").Order("event_id DESC").Take(&latest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return status, nil
}
if err != nil {
return nil, err
}
status.LastUsedAt = &latest.CreatedAt
status.LastUsedIp = latest.Ip
return status, nil
}
// MigrateAuditLogs also supports independently configured ClickHouse log stores.
// No TTL clause or usage-log cleanup integration is intentional.
func MigrateAuditLogs() error {
if !common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return LOG_DB.AutoMigrate(&AuditLog{})
}
return LOG_DB.Exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id Int64 DEFAULT 0, event_id String, user_id Int64, username String, actor_role Int32,
created_at Int64, category String, action String, token_ref String,
auth_method String, ip String, user_agent String, method String, route String,
status Int32, success UInt8, request_id String, content String, other JSON
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(toDateTime(created_at))
ORDER BY (created_at, event_id)`).Error
}
func ValidAuditCategory(category string) bool {
return category == "" || category == AuditCategoryLogin || category == AuditCategorySecurity || category == AuditCategoryOperation || category == AuditCategoryAccessToken
}
func ValidTokenFingerprint(value string) bool {
if value == "" {
return true
}
if len(value) != 64 {
return false
}
return strings.Trim(value, "0123456789abcdef") == ""
}