270 lines
9.5 KiB
Go
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") == ""
|
|
}
|