feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,269 @@
|
||||
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") == ""
|
||||
}
|
||||
Reference in new issue
Block a user