feat: 接入后台消息治理与登录审计

This commit is contained in:
richjohnrambo
2026-09-07 11:53:24 +08:00
parent 90185b6c88
commit 6b20a633ce
17 changed files with 1062 additions and 45 deletions
+7
View File
@@ -240,6 +240,13 @@ type TenantInternalAdminQueryAdapter interface {
TenantInternalAdminQueryOne(tenantID t.TenantID, query string, args []any) (map[string]any, error)
}
type TenantSecurityAuditAdapter interface {
TenantLoginAuditCreate(t.TenantID, *t.LoginAuditEvent) error
TenantLoginFailureCreate(t.TenantID, *t.LoginFailureEvent) error
TenantLoginAuditClose(t.TenantID, string, time.Time) error
TenantMessageAdminApply(t.TenantID, *t.MessageAdminAction) error
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
+10
View File
@@ -272,6 +272,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
}
if err = ensureIMAdminGovernanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure admin governance schema: " + err.Error())
}
if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
}
@@ -512,6 +515,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMAdminGovernanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMFileReviewTables(db); err != nil {
db.Close()
return nil, nil, err
@@ -950,6 +957,9 @@ func (a *adapter) CreateDb(reset bool) error {
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
return err
}
if err = ensureIMAdminGovernanceTables(tx); err != nil {
return err
}
if err = ensureIMFileReviewTables(tx); err != nil {
return err
}
@@ -0,0 +1,64 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '', account_mask VARCHAR(128) NOT NULL DEFAULT '',
login_type VARCHAR(24) NOT NULL, event_type VARCHAR(24) NOT NULL DEFAULT 'login',
session_id VARCHAR(96) NOT NULL DEFAULT '', device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown', app_version VARCHAR(32) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '', risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
INDEX idx_im_user_login_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT,
account_type VARCHAR(24) NOT NULL DEFAULT '', account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '', failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '', detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '', user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium', risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '', attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_login_failure_account_time(account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time(user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time(ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
topic VARCHAR(64) NOT NULL, seqid BIGINT NOT NULL, sender_user_id BIGINT,
action VARCHAR(16) NOT NULL, action_status VARCHAR(16) NOT NULL DEFAULT 'pending',
reason VARCHAR(500) NOT NULL DEFAULT '', actor_admin_id BIGINT NOT NULL DEFAULT 0,
request_id VARCHAR(64) NOT NULL DEFAULT '', before_snapshot JSON, after_snapshot JSON,
failure_reason VARCHAR(96) NOT NULL DEFAULT '', failure_message VARCHAR(1000) NOT NULL DEFAULT '',
processed_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_message_admin_action_id(action_id),
UNIQUE INDEX uk_im_message_admin_command(command_id),
INDEX idx_im_message_admin_target(conversation_type, topic, seqid, id DESC),
INDEX idx_im_message_admin_status(action_status, created_at, id),
INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,28 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined)
}
}
+166
View File
@@ -0,0 +1,166 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil || event.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
_, err = a.db.Exec(`INSERT INTO im_user_login_logs
(user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask,
event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion,
event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata))
return err
}
func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginFailureCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
var userID any
if !event.UserID.IsZero() {
userID = decodeUID(event.UserID)
}
_, err = a.db.Exec(`INSERT INTO im_login_failure_logs
(user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail,
event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent,
event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata))
return err
}
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || strings.TrimSpace(sessionID) == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
}
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE session_id=? AND logout_at IS NULL", at, sessionID)
return err
}
func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminAction) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || action == nil || action.Topic == "" || action.SeqID <= 0 || (action.Action != "hide" && action.Action != "delete") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageAdminApply(id, action)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var existing int64
if err = tx.GetContext(ctx, &existing, "SELECT COUNT(*) FROM im_message_admin_actions WHERE command_id=?", action.CommandID); err != nil {
return err
}
if existing > 0 {
return tx.Commit()
}
sender, beforeStatus, err := lockAdminMessage(ctx, tx, action.ConversationType, action.Topic, action.SeqID)
if err != nil {
return err
}
before, _ := json.Marshal(map[string]any{"status": beforeStatus, "sender_user_id": sender})
afterStatus := "hidden"
if action.Action == "delete" {
afterStatus = "deleted"
}
if action.ConversationType == "group" {
_, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
} else {
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
}
if err != nil {
return err
}
after, _ := json.Marshal(map[string]any{"status": afterStatus, "sender_user_id": sender})
_, err = tx.ExecContext(ctx, `INSERT INTO im_message_admin_actions
(action_id,command_id,conversation_type,topic,seqid,sender_user_id,action,action_status,reason,actor_admin_id,request_id,before_snapshot,after_snapshot,processed_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)`,
action.ActionID, action.CommandID, action.ConversationType, action.Topic, action.SeqID, sender,
action.Action, "success", action.Reason, action.ActorAdminID, action.RequestID, string(before), string(after), action.ProcessedAt)
if err != nil {
return err
}
return tx.Commit()
}
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic string, seqID int64) (int64, string, error) {
if conversationType == "group" {
var row struct {
Sender int64 `db:"from_user"`
Status string `db:"status"`
}
err := tx.GetContext(ctx, &row, "SELECT from_user,status FROM im_group_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
return row.Sender, row.Status, err
}
var row struct {
Sender int64 `db:"from"`
DeletedAt sql.NullTime `db:"deletedat"`
RecalledAt sql.NullTime `db:"recalled_at"`
}
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
status := "normal"
if row.RecalledAt.Valid {
status = "recalled"
}
if row.DeletedAt.Valid {
status = "deleted"
}
return row.Sender, status, err
}
func nullableJSON(raw []byte) any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return string(raw)
}