From 204043382c5f1dc2cebcf7bee5b55576d2b5c856 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 23 Sep 2026 15:51:29 +0800 Subject: [PATCH] fix: scope login audit records by tenant --- docs/sql/IM-后台消息与登录审计SQL.sql | 4 ++- server/db/mysql/admin_governance_schema.go | 5 ++-- .../db/mysql/admin_governance_schema_test.go | 14 +++++++--- server/db/mysql/tenant_security_audit.go | 8 +++--- server/db/mysql/tenant_security_audit_test.go | 26 +++++++++++++++++++ 5 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 server/db/mysql/tenant_security_audit_test.go diff --git a/docs/sql/IM-后台消息与登录审计SQL.sql b/docs/sql/IM-后台消息与登录审计SQL.sql index 2a5d8ca..2f20a71 100644 --- a/docs/sql/IM-后台消息与登录审计SQL.sql +++ b/docs/sql/IM-后台消息与登录审计SQL.sql @@ -1,8 +1,9 @@ -- MySQL 8.0. Admin message governance and authentication audit tables. --- Execute this script in each tenant business database. +-- Execute this script in the shared meeting_im database; tenant_id provides tenant isolation. CREATE TABLE IF NOT EXISTS im_user_login_logs ( id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '', @@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs ( metadata JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), + INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC), 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), diff --git a/server/db/mysql/admin_governance_schema.go b/server/db/mysql/admin_governance_schema.go index f3d2bea..8396d96 100644 --- a/server/db/mysql/admin_governance_schema.go +++ b/server/db/mysql/admin_governance_schema.go @@ -6,7 +6,7 @@ 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, + id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, 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', @@ -16,7 +16,8 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error { 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), + PRIMARY KEY(id), INDEX idx_im_user_login_tenant_time(tenant_id, login_at DESC, id DESC), + 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) diff --git a/server/db/mysql/admin_governance_schema_test.go b/server/db/mysql/admin_governance_schema_test.go index 76f9d18..948ec86 100644 --- a/server/db/mysql/admin_governance_schema_test.go +++ b/server/db/mysql/admin_governance_schema_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) { +func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T) { exec := &recordingGroupSchemaExecutor{} if err := ensureIMAdminGovernanceTables(exec); err != nil { t.Fatal(err) @@ -27,7 +27,15 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) { t.Fatalf("hot login state table must not be created: %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) + if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") { + t.Fatalf("admin governance schema must use the active shared database: %s", joined) + } + start := strings.Index(joined, "create table if not exists im_user_login_logs") + if start < 0 { + t.Fatal("missing im_user_login_logs") + } + end := strings.Index(joined[start:], ") engine=") + if end < 0 || !strings.Contains(joined[start:start+end], "tenant_id bigint not null") { + t.Fatal("im_user_login_logs must declare tenant_id") } } diff --git a/server/db/mysql/tenant_security_audit.go b/server/db/mysql/tenant_security_audit.go index 1dee782..4481f3c 100644 --- a/server/db/mysql/tenant_security_audit.go +++ b/server/db/mysql/tenant_security_audit.go @@ -28,9 +28,9 @@ func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent 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, + (tenant_id,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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + int64(id), 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 @@ -93,7 +93,7 @@ func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time 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) + _, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID) return err } diff --git a/server/db/mysql/tenant_security_audit_test.go b/server/db/mysql/tenant_security_audit_test.go new file mode 100644 index 0000000..46550a5 --- /dev/null +++ b/server/db/mysql/tenant_security_audit_test.go @@ -0,0 +1,26 @@ +//go:build mysql + +package mysql + +import ( + "os" + "strings" + "testing" +) + +func TestLoginAuditQueriesAreTenantScoped(t *testing.T) { + source, err := os.ReadFile("tenant_security_audit.go") + if err != nil { + t.Fatal(err) + } + body := string(source) + for _, required := range []string{ + "(tenant_id,user_id,username", + "(tenant_id,user_id,account_type", + "WHERE tenant_id=? AND session_id=?", + } { + if !strings.Contains(body, required) { + t.Fatalf("login audit query is missing tenant scope %q", required) + } + } +}