@h-ai/audit 1.0.0-beta.22 → 1.0.0-beta.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.ja.md +3 -3
- package/README.md +3 -3
- package/dist/index.d.ts +4 -17
- package/dist/index.js +137 -154
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
package/README.en.md
CHANGED
|
@@ -181,7 +181,7 @@ New records include category (system/business), outcome (success/failure/denied)
|
|
|
181
181
|
|
|
182
182
|
`retention: { enabled: true, minDays: 90 }` activates a shared database policy. Cleanup honors the actual minimum even with cleanup(0); triggers reject protected UPDATE, DELETE and conflicting replacement writes. An unconfigured fresh framework installation does not force retention protection: the default cleanup argument of 90 days alone is no immutability guarantee. Reinitialization without retention preserves the database policy; explicit trusted startup configuration can disable it. This covers ordinary row writes; database-owner DDL/privileges and stronger archive/storage protection belong to deployment management.
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
`audit.init()` prepares the current tables and retention policy and fails if their structure is incompatible. List results use the event-time actor snapshot for the username and return `null` when it is unavailable. Failed writes return an error and emit correlated diagnostics. Close audit before closing RelDB so started writes can finish.
|
|
185
185
|
|
|
186
186
|
Redaction matches whole fields, including objects, arrays and sensitive values produced by custom JSON projections. capture returns not-initialized failure before initialization or after close; only an initialized module with capture disabled skips automatic events.
|
|
187
187
|
|
package/README.ja.md
CHANGED
|
@@ -133,7 +133,7 @@ SQLite、PostgreSQL、MySQL で読み取り契約は共通です。`createdAt`
|
|
|
133
133
|
|
|
134
134
|
- `audit.init(config)`:監査モジュールを初期化。
|
|
135
135
|
- `audit.log(input)`:監査ログを記録。
|
|
136
|
-
- `audit.list(options)`:`PaginatedResult<AuditLogWithUser>`(items/total/page/pageSize
|
|
136
|
+
- `audit.list(options)`:`PaginatedResult<AuditLogWithUser>`(items/total/page/pageSize、イベント時のユーザー名)を返します。core がページ入力を正規化し、既定 pageSize は 20 です。返されたメタデータを直接利用してください。
|
|
137
137
|
- `audit.getUserRecent(userId, limit)`:ユーザーの最近の操作を取得。
|
|
138
138
|
- `audit.cleanup(days)`:古いログを削除。
|
|
139
139
|
- `audit.getStats(days)`:操作統計を取得。
|
|
@@ -165,7 +165,7 @@ SQLite、PostgreSQL、MySQL で読み取り契約は共通です。`createdAt`
|
|
|
165
165
|
|
|
166
166
|
`retention: { enabled: true, minDays: 90 }` は共有DBの最低保持を有効にします。cleanup(0) も最低期間を守り、トリガーは保護対象の UPDATE・DELETE・衝突置換を拒否します。未設定の新規フレームワークは保持保護を強制せず、削除引数の既定90日だけでは不変保証になりません。retention を省略した再初期化はDB設定を継続し、信頼できる起動設定で明示的に無効化できます。通常の行書込を対象とし、DB所有者のDDL・権限や独立アーカイブなどは配備側で管理します。
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
`audit.init()` は現在のテーブル構造と保持ポリシーを準備し、構造が一致しない場合は失敗します。一覧のユーザー名はイベント時のスナップショットを使用し、名称がない場合は `null` です。書き込み失敗時はエラーと関連診断を返します。開始済みの書き込みを待つため、RelDB の前に audit を終了します。
|
|
169
169
|
|
|
170
170
|
マスキングは項目全体に適用し、オブジェクト、配列、独自 JSON 変換の機密値も隠します。未初期化または close 後の capture は未初期化エラーを返し、初期化済みモジュールで capture を無効にした場合だけ自動イベントを省略します。
|
|
171
171
|
|
|
@@ -252,7 +252,7 @@ await audit.init({
|
|
|
252
252
|
|
|
253
253
|
> 注:`audit.close()` も `HaiResult<void>` を返します。`audit.helper.passwordResetRequest()` のメール情報は既定の業務個人情報設定でマスクされます。
|
|
254
254
|
|
|
255
|
-
`audit.init()` は自動建表とリポジトリーの準備完了を待って成功します。DDL 失敗時は `isInitialized=false` のままで、DB
|
|
255
|
+
`audit.init()` は自動建表とリポジトリーの準備完了を待って成功します。DDL 失敗時は `isInitialized=false` のままで、DB を修正後に再試行できます。一覧・統計・削除も同じ準備完了を待ちます。初期化中の close は遅れた公開を無効化します。init の HaiResult を確認後に記録・検索し、共有 DB の接続は所有しないため audit の終了後に DB を終了します。
|
|
256
256
|
|
|
257
257
|
## 開発とテスト
|
|
258
258
|
|
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ SQLite、PostgreSQL、MySQL 的读取结果保持同一契约:`createdAt` 为
|
|
|
133
133
|
|
|
134
134
|
- `audit.init(config)` - 初始化审计模块
|
|
135
135
|
- `audit.log(input)` - 记录审计日志
|
|
136
|
-
- `audit.list(options)` - 返回统一 `PaginatedResult<AuditLogWithUser>`(items/total/page/pageSize
|
|
136
|
+
- `audit.list(options)` - 返回统一 `PaginatedResult<AuditLogWithUser>`(items/total/page/pageSize,含事件时的用户名);页码/条数复用 core 分页规范化,默认 pageSize 为 20,调用方直接使用返回元数据。
|
|
137
137
|
- `audit.getUserRecent(userId, limit)` - 获取用户最近活动
|
|
138
138
|
- `audit.cleanup(days)` - 清理旧日志
|
|
139
139
|
- `audit.getStats(days, filters)` - 获取按分类等条件筛选的操作统计
|
|
@@ -165,7 +165,7 @@ SQLite、PostgreSQL、MySQL 的读取结果保持同一契约:`createdAt` 为
|
|
|
165
165
|
|
|
166
166
|
`retention: { enabled: true, minDays: 90 }` 开启最低保留。共享数据库保存策略,清理使用实际最低期限,`cleanup(0)` 不能绕过;数据库触发器拒绝保护期内的 UPDATE、DELETE 和冲突覆盖写。框架首次未配置时不强制保护,清理参数默认90天本身不构成不可变保证。未传 retention 的重初始化沿用既有策略,只有可信启动配置显式关闭才解除。该保护针对正常行级写入;数据库所有者的 DDL/权限、独立归档和更强存储保护需由部署管理。
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
`audit.init()` 准备当前表结构和留存策略,结构不符时返回初始化失败。列表中的用户名来自事件时的身份快照,缺失时为 `null`。写入失败返回失败并输出关联诊断。`audit.close()` 等待已开始的写入,随后可关闭 RelDB。
|
|
169
169
|
|
|
170
170
|
脱敏匹配整个字段:对象、数组以及自定义 JSON 投影内的敏感值同样隐藏。关闭或尚未初始化 Audit 时,capture 返回未初始化失败;只有已初始化模块的关闭采集配置才跳过自动事件。
|
|
171
171
|
|
|
@@ -252,7 +252,7 @@ await audit.init({
|
|
|
252
252
|
|
|
253
253
|
> 说明:`audit.close()` 也返回 `HaiResult<void>`;`audit.helper.passwordResetRequest()` 的邮箱详情默认经过业务隐私脱敏。
|
|
254
254
|
|
|
255
|
-
`audit.init()` 会等待自动建表和仓库就绪才返回成功;DDL 失败保持 `isInitialized=false
|
|
255
|
+
`audit.init()` 会等待自动建表和仓库就绪才返回成功;DDL 失败保持 `isInitialized=false`,修正数据库后可重试。列表、统计和清理也使用同一就绪屏障;初始化期间关闭会使迟到结果失效。先检查 init 的 HaiResult,再立即记录或查询;audit 不拥有共享 RelDB 的连接,关闭 audit 后再关闭数据库。
|
|
256
256
|
|
|
257
257
|
## 开发测试
|
|
258
258
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { StorageFunctions } from '@h-ai/storage';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import * as _h_ai_core from '@h-ai/core';
|
|
5
5
|
import { HaiResult, PaginationOptionsInput, PaginatedResult } from '@h-ai/core';
|
|
6
|
-
import * as _h_ai_reldb from '@h-ai/reldb';
|
|
7
6
|
import { DmlWithTxOperations } from '@h-ai/reldb';
|
|
8
7
|
import { SchedulerInitInput } from '@h-ai/scheduler';
|
|
9
8
|
|
|
@@ -219,7 +218,7 @@ interface AuditLog extends AuditEventFields {
|
|
|
219
218
|
/**
|
|
220
219
|
* 审计日志记录(含用户名)
|
|
221
220
|
*
|
|
222
|
-
*
|
|
221
|
+
* 用户名来自事件时的身份快照;无名称时为 null。
|
|
223
222
|
* @example
|
|
224
223
|
* ```ts
|
|
225
224
|
* import type { AuditLogWithUser } from '@h-ai/audit'
|
|
@@ -227,7 +226,7 @@ interface AuditLog extends AuditEventFields {
|
|
|
227
226
|
* ```
|
|
228
227
|
*/
|
|
229
228
|
interface AuditLogWithUser extends AuditLog {
|
|
230
|
-
/**
|
|
229
|
+
/** 事件时的用户名;未捕获到名称时为 null。 */
|
|
231
230
|
username: string | null;
|
|
232
231
|
}
|
|
233
232
|
/**
|
|
@@ -476,7 +475,7 @@ interface AuditFunctions {
|
|
|
476
475
|
tx?: DmlWithTxOperations;
|
|
477
476
|
}) => Promise<HaiResult<void>>;
|
|
478
477
|
/**
|
|
479
|
-
*
|
|
478
|
+
* 分页查询审计日志列表,用户名来自事件时的身份快照。
|
|
480
479
|
*
|
|
481
480
|
* @param options - 查询过滤与分页选项
|
|
482
481
|
* @returns 成功时返回 PaginatedResult(items/total/page/pageSize);失败时返回 QUERY_FAILED
|
|
@@ -633,17 +632,5 @@ interface AuditAlertReport {
|
|
|
633
632
|
*/
|
|
634
633
|
declare const audit: AuditFunctions;
|
|
635
634
|
|
|
636
|
-
|
|
637
|
-
* 只读预检或迁移审计字段、保留设置和写入保护;升级前停旧写入并备份。
|
|
638
|
-
* @example
|
|
639
|
-
* ```ts
|
|
640
|
-
* import { migrateAudit } from '@h-ai/audit'
|
|
641
|
-
* const plan = await migrateAudit({ dryRun: true }) // RelDB 已初始化;不改写记录。
|
|
642
|
-
* ```
|
|
643
|
-
*/
|
|
644
|
-
declare function migrateAudit(options?: {
|
|
645
|
-
dryRun?: boolean;
|
|
646
|
-
}): Promise<HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
|
|
647
|
-
|
|
648
|
-
export { AUDIT_BACKUP_PREFIX, AuditInitConfigSchema, HaiAuditError, audit, migrateAudit };
|
|
635
|
+
export { AUDIT_BACKUP_PREFIX, AuditInitConfigSchema, HaiAuditError, audit };
|
|
649
636
|
export type { AuditActorSnapshot, AuditAlertReport, AuditBackupInfo, AuditBackupOperations, AuditBackupOptions, AuditContext, AuditEventFields, AuditFunctions, AuditHelper, AuditInitConfig, AuditInitConfigInput, AuditLog, AuditLogWithUser, AuditStatItem, CreateAuditLogInput, CrudAuditInput, ListAuditLogsOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { core, ok, err } from '@h-ai/core';
|
|
3
|
-
import {
|
|
3
|
+
import { validateIdentifiers, BaseReldbCrudRepository, reldb } from '@h-ai/reldb';
|
|
4
4
|
import { createHash } from 'crypto';
|
|
5
5
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
6
6
|
import { Buffer } from 'buffer';
|
|
@@ -100,11 +100,11 @@ var AuditInitConfigSchema = z.object({
|
|
|
100
100
|
/** 额外脱敏的详情字段名,不区分大小写;例如 customerPhone,不要将秘密放进 action/resourceId。 */
|
|
101
101
|
fields: z.array(z.string().trim().min(1).max(256)).max(1e3).default([])
|
|
102
102
|
}).default({ enabled: true, fields: [] }),
|
|
103
|
-
/**
|
|
103
|
+
/** 事件写入时用于捕获用户名的用户表名。 */
|
|
104
104
|
userTable: z.string().default("hai_iam_users"),
|
|
105
|
-
/**
|
|
105
|
+
/** 用于查找事件操作者的用户表主键列名。 */
|
|
106
106
|
userIdColumn: z.string().default("id"),
|
|
107
|
-
/**
|
|
107
|
+
/** 用于写入身份快照的用户名列名。 */
|
|
108
108
|
userNameColumn: z.string().default("username")
|
|
109
109
|
});
|
|
110
110
|
var storage = new AsyncLocalStorage();
|
|
@@ -129,6 +129,7 @@ function withAuditContext(context, operation) {
|
|
|
129
129
|
|
|
130
130
|
// messages/en-US.json
|
|
131
131
|
var en_US_default = {
|
|
132
|
+
audit_timestampPrecisionRequired: "The audit event timestamp must support millisecond precision",
|
|
132
133
|
audit_eventFieldsInvalid: "Invalid event fields",
|
|
133
134
|
audit_identityMismatch: "Actor snapshot does not match the event identity",
|
|
134
135
|
audit_queryInvalid: "Invalid filters or dates",
|
|
@@ -165,6 +166,7 @@ var en_US_default = {
|
|
|
165
166
|
|
|
166
167
|
// messages/ja-JP.json
|
|
167
168
|
var ja_JP_default = {
|
|
169
|
+
audit_timestampPrecisionRequired: "\u76E3\u67FB\u30A4\u30D9\u30F3\u30C8\u306E\u6642\u523B\u5217\u306B\u306F\u30DF\u30EA\u79D2\u7CBE\u5EA6\u304C\u5FC5\u8981\u3067\u3059",
|
|
168
170
|
audit_eventFieldsInvalid: "\u30A4\u30D9\u30F3\u30C8\u9805\u76EE\u304C\u7121\u52B9\u3067\u3059",
|
|
169
171
|
audit_identityMismatch: "\u8EAB\u5143\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3068\u64CD\u4F5C\u8005\u304C\u4E00\u81F4\u3057\u307E\u305B\u3093",
|
|
170
172
|
audit_queryInvalid: "\u691C\u7D22\u6761\u4EF6\u307E\u305F\u306F\u65E5\u4ED8\u304C\u7121\u52B9\u3067\u3059",
|
|
@@ -201,6 +203,7 @@ var ja_JP_default = {
|
|
|
201
203
|
|
|
202
204
|
// messages/zh-CN.json
|
|
203
205
|
var zh_CN_default = {
|
|
206
|
+
audit_timestampPrecisionRequired: "\u5BA1\u8BA1\u4E8B\u4EF6\u65F6\u95F4\u5217\u5FC5\u987B\u652F\u6301\u6BEB\u79D2\u7CBE\u5EA6",
|
|
204
207
|
audit_eventFieldsInvalid: "\u4E8B\u4EF6\u5B57\u6BB5\u65E0\u6548",
|
|
205
208
|
audit_identityMismatch: "\u8EAB\u4EFD\u5FEB\u7167\u4E0E\u64CD\u4F5C\u8005\u4E0D\u4E00\u81F4",
|
|
206
209
|
audit_queryInvalid: "\u7B5B\u9009\u6761\u4EF6\u6216\u65E5\u671F\u65E0\u6548",
|
|
@@ -1050,142 +1053,98 @@ function createHelper(logFn) {
|
|
|
1050
1053
|
}
|
|
1051
1054
|
};
|
|
1052
1055
|
}
|
|
1053
|
-
var
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
};
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1056
|
+
var mysqlLock = "SHA2(CONCAT(DATABASE(), ':hai_audit_policy'), 256)";
|
|
1057
|
+
var triggerOperations = [
|
|
1058
|
+
["hai_audit_protect_update", "UPDATE"],
|
|
1059
|
+
["hai_audit_protect_delete", "DELETE"],
|
|
1060
|
+
["hai_audit_protect_insert", "INSERT"]
|
|
1061
|
+
];
|
|
1062
|
+
async function installTriggers(sql) {
|
|
1063
|
+
const type = reldb.config?.type;
|
|
1064
|
+
const existing = type === "sqlite" ? await sql.query("SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='hai_audit_logs'") : await sql.query(`SELECT trigger_name AS name FROM information_schema.triggers WHERE trigger_schema=${type === "mysql" ? "DATABASE()" : "current_schema()"} AND event_object_table='hai_audit_logs'`);
|
|
1065
|
+
if (!existing.success)
|
|
1066
|
+
return existing;
|
|
1067
|
+
const names = new Set(existing.data.map((row) => row.name));
|
|
1068
|
+
const missing = triggerOperations.filter(([name]) => !names.has(name));
|
|
1069
|
+
if (!missing.length)
|
|
1070
|
+
return ok(void 0);
|
|
1071
|
+
const enabled = "COALESCE((SELECT enabled FROM hai_audit_policy WHERE id=1),1)=1";
|
|
1072
|
+
const days = "COALESCE((SELECT min_days FROM hai_audit_policy WHERE id=1),90)";
|
|
1073
|
+
const cutoff = type === "sqlite" ? `CAST((julianday('now')-2440587.5)*86400000 AS INTEGER) - ${days}*86400000` : type === "postgresql" ? `CURRENT_TIMESTAMP - (${days} * INTERVAL '1 day')` : `TIMESTAMPADD(DAY, -${days}, CURRENT_TIMESTAMP(3))`;
|
|
1074
|
+
const existingProtected = `${enabled} AND EXISTS(SELECT 1 FROM hai_audit_logs a WHERE a.id=NEW.id AND a.created_at >= ${cutoff})`;
|
|
1075
|
+
if (type === "postgresql") {
|
|
1076
|
+
const fn = await sql.execute(`CREATE OR REPLACE FUNCTION hai_audit_protect() RETURNS trigger AS $$ BEGIN IF TG_OP='INSERT' THEN IF ${existingProtected} THEN RAISE EXCEPTION 'Audit record is protected by retention policy'; END IF; RETURN NEW; END IF; IF ${enabled} AND OLD.created_at >= ${cutoff} THEN RAISE EXCEPTION 'Audit record is protected by retention policy'; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql`);
|
|
1077
|
+
if (!fn.success)
|
|
1078
|
+
return fn;
|
|
1079
|
+
}
|
|
1080
|
+
for (const [name, operation] of missing) {
|
|
1081
|
+
const protectedRow = operation === "INSERT" ? existingProtected : `${enabled} AND OLD.created_at >= ${cutoff}`;
|
|
1082
|
+
const prefix = `CREATE TRIGGER ${name} BEFORE ${operation} ON hai_audit_logs`;
|
|
1083
|
+
const statement = type === "sqlite" ? `${prefix} WHEN ${protectedRow} BEGIN SELECT RAISE(ABORT, 'Audit record is protected by retention policy'); END` : type === "postgresql" ? `${prefix} FOR EACH ROW EXECUTE FUNCTION hai_audit_protect()` : `${prefix} FOR EACH ROW BEGIN IF ${protectedRow} THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit record is protected by retention policy'; END IF; END`;
|
|
1084
|
+
const created = type === "mysql" ? await sql.query(statement) : await sql.execute(statement);
|
|
1085
|
+
if (!created.success)
|
|
1086
|
+
return created;
|
|
1087
|
+
}
|
|
1088
|
+
return ok(void 0);
|
|
1066
1089
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const
|
|
1094
|
-
if (!
|
|
1095
|
-
return
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
return created;
|
|
1104
|
-
return sql.execute(reldb.config?.type === "mysql" ? "INSERT IGNORE INTO hai_audit_policy (id, enabled, min_days) VALUES (1, 0, 90)" : "INSERT INTO hai_audit_policy (id, enabled, min_days) VALUES (1, 0, 90) ON CONFLICT(id) DO NOTHING");
|
|
1105
|
-
}
|
|
1106
|
-
},
|
|
1107
|
-
{
|
|
1108
|
-
version: 3,
|
|
1109
|
-
name: "retention-write-guards",
|
|
1110
|
-
async needed(sql) {
|
|
1111
|
-
const table2 = await reldb.schemaColumns("hai_audit_logs", sql);
|
|
1112
|
-
if (!table2.success || !table2.data.length) {
|
|
1113
|
-
return table2.success ? ok(false) : table2;
|
|
1114
|
-
}
|
|
1115
|
-
const found = await existingTriggers(sql);
|
|
1116
|
-
return found.success ? ok(triggers.some((name) => !found.data.includes(name))) : found;
|
|
1117
|
-
},
|
|
1118
|
-
async apply(sql) {
|
|
1119
|
-
const found = await existingTriggers(sql);
|
|
1120
|
-
if (!found.success)
|
|
1121
|
-
return found;
|
|
1122
|
-
const enabled = "COALESCE((SELECT enabled FROM hai_audit_policy WHERE id=1),1)=1";
|
|
1123
|
-
const days = "COALESCE((SELECT min_days FROM hai_audit_policy WHERE id=1),90)";
|
|
1124
|
-
const cutoff = reldb.config?.type === "sqlite" ? `CAST((julianday('now')-2440587.5)*86400000 AS INTEGER) - ${days}*86400000` : reldb.config?.type === "postgresql" ? `CURRENT_TIMESTAMP - (${days} * INTERVAL '1 day')` : `TIMESTAMPADD(DAY, -${days}, CURRENT_TIMESTAMP(3))`;
|
|
1125
|
-
const existingProtected = `${enabled} AND EXISTS(SELECT 1 FROM hai_audit_logs a WHERE a.id=NEW.id AND a.created_at >= ${cutoff})`;
|
|
1126
|
-
if (reldb.config?.type === "postgresql") {
|
|
1127
|
-
const fn = await sql.execute(`CREATE OR REPLACE FUNCTION hai_audit_protect() RETURNS trigger AS $$ BEGIN IF TG_OP='INSERT' THEN IF ${existingProtected} THEN RAISE EXCEPTION 'Audit record is protected by retention policy'; END IF; RETURN NEW; END IF; IF ${enabled} AND OLD.created_at >= ${cutoff} THEN RAISE EXCEPTION 'Audit record is protected by retention policy'; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql`);
|
|
1128
|
-
if (!fn.success)
|
|
1129
|
-
return fn;
|
|
1130
|
-
}
|
|
1131
|
-
for (const [index, operation] of ["UPDATE", "DELETE", "INSERT"].entries()) {
|
|
1132
|
-
const name = triggers[index];
|
|
1133
|
-
if (found.data.includes(name))
|
|
1134
|
-
continue;
|
|
1135
|
-
const protectedRow = operation === "INSERT" ? existingProtected : `${enabled} AND OLD.created_at >= ${cutoff}`;
|
|
1136
|
-
const statement = reldb.config?.type === "sqlite" ? `CREATE TRIGGER ${name} BEFORE ${operation} ON hai_audit_logs WHEN ${protectedRow} BEGIN SELECT RAISE(ABORT, 'Audit record is protected by retention policy'); END` : reldb.config?.type === "postgresql" ? `CREATE TRIGGER ${name} BEFORE ${operation} ON hai_audit_logs FOR EACH ROW EXECUTE FUNCTION hai_audit_protect()` : `CREATE TRIGGER ${name} BEFORE ${operation} ON hai_audit_logs FOR EACH ROW BEGIN IF ${protectedRow} THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit record is protected by retention policy'; END IF; END`;
|
|
1137
|
-
const created = reldb.config?.type === "mysql" ? await sql.query(statement) : await sql.execute(statement);
|
|
1138
|
-
if (!created.success)
|
|
1139
|
-
return created;
|
|
1140
|
-
}
|
|
1141
|
-
return ok(void 0);
|
|
1142
|
-
}
|
|
1143
|
-
},
|
|
1144
|
-
{
|
|
1145
|
-
version: 4,
|
|
1146
|
-
name: "millisecond-event-time",
|
|
1147
|
-
async needed(sql) {
|
|
1148
|
-
if (reldb.config?.type !== "mysql")
|
|
1149
|
-
return ok(false);
|
|
1150
|
-
const column = await sql.get("SELECT DATETIME_PRECISION AS fractional_digits FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?", ["hai_audit_logs", "created_at"]);
|
|
1151
|
-
return column.success ? ok(Boolean(column.data && Number(column.data.fractional_digits ?? 0) < 3)) : column;
|
|
1152
|
-
},
|
|
1153
|
-
async apply(sql) {
|
|
1154
|
-
const changed = await sql.execute("ALTER TABLE hai_audit_logs MODIFY COLUMN created_at DATETIME(3)");
|
|
1155
|
-
return changed.success ? ok(void 0) : changed;
|
|
1090
|
+
async function installTimeIndex(sql) {
|
|
1091
|
+
if (reldb.config?.type !== "mysql")
|
|
1092
|
+
return sql.execute("CREATE INDEX IF NOT EXISTS hai_audit_event_time_idx ON hai_audit_logs (created_at)");
|
|
1093
|
+
const index = await sql.get("SELECT index_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? LIMIT 1", ["hai_audit_logs", "hai_audit_event_time_idx"]);
|
|
1094
|
+
if (!index.success)
|
|
1095
|
+
return index;
|
|
1096
|
+
return index.data ? ok(void 0) : sql.execute("CREATE INDEX hai_audit_event_time_idx ON hai_audit_logs (created_at)");
|
|
1097
|
+
}
|
|
1098
|
+
async function initializeAuditStorage() {
|
|
1099
|
+
const policy = await reldb.sql.execute("CREATE TABLE IF NOT EXISTS hai_audit_policy (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL, min_days INTEGER NOT NULL)");
|
|
1100
|
+
if (!policy.success)
|
|
1101
|
+
return policy;
|
|
1102
|
+
const claims = await reldb.sql.execute("CREATE TABLE IF NOT EXISTS hai_audit_alert_claims (id VARCHAR(64) PRIMARY KEY, rule_id VARCHAR(64) NOT NULL, target_id VARCHAR(64) NOT NULL, window_start BIGINT NOT NULL, status VARCHAR(16) NOT NULL, queue_id VARCHAR(64), error_code VARCHAR(128))");
|
|
1103
|
+
if (!claims.success)
|
|
1104
|
+
return claims;
|
|
1105
|
+
const mysql = reldb.config?.type === "mysql";
|
|
1106
|
+
const seeded = await reldb.sql.execute(mysql ? "INSERT INTO hai_audit_policy (id, enabled, min_days) VALUES (1, 0, 90) ON DUPLICATE KEY UPDATE id=id" : "INSERT INTO hai_audit_policy (id, enabled, min_days) VALUES (1, 0, 90) ON CONFLICT(id) DO NOTHING");
|
|
1107
|
+
if (!seeded.success)
|
|
1108
|
+
return seeded;
|
|
1109
|
+
const started = await reldb.tx.begin();
|
|
1110
|
+
if (!started.success)
|
|
1111
|
+
return started;
|
|
1112
|
+
const tx = started.data;
|
|
1113
|
+
let namedLock = false;
|
|
1114
|
+
try {
|
|
1115
|
+
if (mysql) {
|
|
1116
|
+
const lock = await tx.get(`SELECT GET_LOCK(${mysqlLock}, 30) AS acquired`);
|
|
1117
|
+
if (!lock.success)
|
|
1118
|
+
return lock;
|
|
1119
|
+
if (Number(lock.data?.acquired) !== 1)
|
|
1120
|
+
return err(HaiAuditError.CONFIG_ERROR, auditM("audit_retentionPolicyFailed"));
|
|
1121
|
+
namedLock = true;
|
|
1122
|
+
} else {
|
|
1123
|
+
const lock = await tx.execute("UPDATE hai_audit_policy SET id=id WHERE id=1");
|
|
1124
|
+
if (!lock.success)
|
|
1125
|
+
return lock;
|
|
1156
1126
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
version: 6,
|
|
1172
|
-
name: "event-time-index",
|
|
1173
|
-
async needed(sql) {
|
|
1174
|
-
const table2 = await reldb.schemaColumns("hai_audit_logs", sql);
|
|
1175
|
-
if (!table2.success || !table2.data.length) {
|
|
1176
|
-
return table2.success ? ok(false) : table2;
|
|
1177
|
-
}
|
|
1178
|
-
const found = reldb.config?.type === "sqlite" ? await sql.get("SELECT name FROM sqlite_master WHERE type='index' AND name='hai_audit_event_time_idx'") : reldb.config?.type === "postgresql" ? await sql.get("SELECT indexname FROM pg_indexes WHERE schemaname=current_schema() AND tablename='hai_audit_logs' AND indexname='hai_audit_event_time_idx'") : await sql.get("SELECT index_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='hai_audit_logs' AND index_name='hai_audit_event_time_idx' LIMIT 1");
|
|
1179
|
-
return found.success ? ok(!found.data) : found;
|
|
1180
|
-
},
|
|
1181
|
-
async apply(sql) {
|
|
1182
|
-
const created = await sql.execute("CREATE INDEX hai_audit_event_time_idx ON hai_audit_logs (created_at)");
|
|
1183
|
-
return created.success ? ok(void 0) : created;
|
|
1127
|
+
const retained = await auditRetentionDays(tx);
|
|
1128
|
+
if (!retained.success)
|
|
1129
|
+
return retained;
|
|
1130
|
+
const triggers = await installTriggers(tx);
|
|
1131
|
+
if (!triggers.success)
|
|
1132
|
+
return triggers;
|
|
1133
|
+
const index = await installTimeIndex(tx);
|
|
1134
|
+
if (!index.success)
|
|
1135
|
+
return index;
|
|
1136
|
+
if (namedLock) {
|
|
1137
|
+
const released = await tx.get(`SELECT RELEASE_LOCK(${mysqlLock})`);
|
|
1138
|
+
if (!released.success)
|
|
1139
|
+
return released;
|
|
1140
|
+
namedLock = false;
|
|
1184
1141
|
}
|
|
1142
|
+
return await tx.commit();
|
|
1143
|
+
} finally {
|
|
1144
|
+
if (namedLock)
|
|
1145
|
+
await tx.get(`SELECT RELEASE_LOCK(${mysqlLock})`);
|
|
1146
|
+
await tx.rollback();
|
|
1185
1147
|
}
|
|
1186
|
-
];
|
|
1187
|
-
function migrateAudit(options = {}) {
|
|
1188
|
-
return reldb.migrateSchema("audit", steps, options);
|
|
1189
1148
|
}
|
|
1190
1149
|
async function configureAuditRetention(policy) {
|
|
1191
1150
|
if (!policy)
|
|
@@ -1196,12 +1155,14 @@ async function configureAuditRetention(policy) {
|
|
|
1196
1155
|
]);
|
|
1197
1156
|
return result.success && result.data.changes === 1 ? ok(void 0) : err(HaiAuditError.CONFIG_ERROR, auditM("audit_retentionPolicyFailed"), result.success ? void 0 : result.error);
|
|
1198
1157
|
}
|
|
1199
|
-
async function auditRetentionDays() {
|
|
1200
|
-
const result = await
|
|
1158
|
+
async function auditRetentionDays(sql = reldb.sql) {
|
|
1159
|
+
const result = await sql.get("SELECT enabled, min_days FROM hai_audit_policy WHERE id=1");
|
|
1201
1160
|
if (!result.success || !result.data || ![0, 1].includes(Number(result.data.enabled)) || !Number.isInteger(Number(result.data.min_days)) || Number(result.data.min_days) < 1)
|
|
1202
|
-
return err(HaiAuditError.CLEANUP_FAILED, auditM("audit_retentionPolicyFailed"));
|
|
1161
|
+
return err(HaiAuditError.CLEANUP_FAILED, auditM("audit_retentionPolicyFailed"), result.success ? void 0 : result.error);
|
|
1203
1162
|
return ok(Number(result.data.enabled) === 1 ? Number(result.data.min_days) : 0);
|
|
1204
1163
|
}
|
|
1164
|
+
|
|
1165
|
+
// src/audit-repository-log.ts
|
|
1205
1166
|
var logger4 = core.logger.child({ module: "audit", scope: "repository" });
|
|
1206
1167
|
var AUDIT_TABLE = "hai_audit_logs";
|
|
1207
1168
|
function normalizeAuditLog(row) {
|
|
@@ -1219,14 +1180,18 @@ function normalizeAuditLog(row) {
|
|
|
1219
1180
|
var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
1220
1181
|
repoConfig;
|
|
1221
1182
|
isSqlite;
|
|
1183
|
+
mysqlTable;
|
|
1184
|
+
readiness;
|
|
1222
1185
|
/**
|
|
1223
1186
|
* @param config - 仓库配置(用户表映射)
|
|
1224
1187
|
*/
|
|
1225
1188
|
constructor(config) {
|
|
1189
|
+
const mysql = reldb.config?.type === "mysql";
|
|
1226
1190
|
super(reldb, {
|
|
1227
1191
|
table: AUDIT_TABLE,
|
|
1228
1192
|
idColumn: "id",
|
|
1229
1193
|
generateId: () => core.id.withPrefix("audit_"),
|
|
1194
|
+
createTableIfNotExists: !mysql,
|
|
1230
1195
|
fields: [
|
|
1231
1196
|
{
|
|
1232
1197
|
fieldName: "id",
|
|
@@ -1360,6 +1325,20 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1360
1325
|
});
|
|
1361
1326
|
this.repoConfig = config;
|
|
1362
1327
|
this.isSqlite = this.db.config?.type === "sqlite";
|
|
1328
|
+
if (mysql) {
|
|
1329
|
+
this.mysqlTable = this.db.ddl.raw(`CREATE TABLE IF NOT EXISTS ${AUDIT_TABLE} (
|
|
1330
|
+
id VARCHAR(255) NOT NULL PRIMARY KEY, user_id VARCHAR(255), action VARCHAR(255) NOT NULL,
|
|
1331
|
+
resource VARCHAR(255) NOT NULL, resource_id VARCHAR(255), details JSON, ip_address VARCHAR(255),
|
|
1332
|
+
user_agent VARCHAR(255), category VARCHAR(255), outcome VARCHAR(255), severity VARCHAR(255),
|
|
1333
|
+
request_id VARCHAR(255), trace_id VARCHAR(255), scope_id VARCHAR(255), actor_snapshot JSON,
|
|
1334
|
+
created_at DATETIME(3)
|
|
1335
|
+
)`);
|
|
1336
|
+
}
|
|
1337
|
+
this.readiness = this.validateSchema();
|
|
1338
|
+
}
|
|
1339
|
+
async ensureReady() {
|
|
1340
|
+
const mysql = await this.mysqlTable;
|
|
1341
|
+
return mysql ?? super.ensureReady();
|
|
1363
1342
|
}
|
|
1364
1343
|
/**
|
|
1365
1344
|
* 等待基类完成建表及字段校验;init和自定义SQL共享此屏障,失败不发布仓库。
|
|
@@ -1367,7 +1346,16 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1367
1346
|
* @example const ready = await repository.ready(); if (!ready.success) return ready
|
|
1368
1347
|
*/
|
|
1369
1348
|
ready() {
|
|
1370
|
-
return this.
|
|
1349
|
+
return this.readiness;
|
|
1350
|
+
}
|
|
1351
|
+
async validateSchema() {
|
|
1352
|
+
const schema = await this.ensureSchema();
|
|
1353
|
+
if (!schema.success || !this.mysqlTable)
|
|
1354
|
+
return schema;
|
|
1355
|
+
const column = await this.db.sql.get("SELECT DATETIME_PRECISION AS digits FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?", [AUDIT_TABLE, "created_at"]);
|
|
1356
|
+
if (!column.success)
|
|
1357
|
+
return column;
|
|
1358
|
+
return Number(column.data?.digits) >= 3 ? ok(void 0) : err(HaiAuditError.CONFIG_ERROR, auditM("audit_timestampPrecisionRequired"));
|
|
1371
1359
|
}
|
|
1372
1360
|
/**
|
|
1373
1361
|
* 将 Date 转为适合当前数据库类型的 SQL 参数
|
|
@@ -1505,7 +1493,7 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1505
1493
|
}
|
|
1506
1494
|
}
|
|
1507
1495
|
/**
|
|
1508
|
-
*
|
|
1496
|
+
* 分页查询日志列表,用户名来自事件时的身份快照。
|
|
1509
1497
|
*
|
|
1510
1498
|
* @param options - 过滤条件与分页参数
|
|
1511
1499
|
* @returns 成功时返回 PaginatedResult(items/total/page/pageSize);失败时返回 QUERY_FAILED
|
|
@@ -1516,7 +1504,6 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1516
1504
|
return ready;
|
|
1517
1505
|
logger4.debug("Querying audit logs", { userId: options.userId, action: options.action, resource: options.resource });
|
|
1518
1506
|
const { conditions, params } = this.filters(options);
|
|
1519
|
-
const { userTable, userIdColumn, userNameColumn } = this.repoConfig;
|
|
1520
1507
|
const quote = this.db.config?.type === "mysql" ? "`" : '"';
|
|
1521
1508
|
const identifier = (name) => `${quote}${name.replaceAll(quote, quote + quote)}${quote}`;
|
|
1522
1509
|
const sortColumns = {
|
|
@@ -1543,9 +1530,8 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1543
1530
|
sql: `SELECT a.id, a.user_id AS ${identifier("userId")}, a.action, a.resource, a.resource_id AS ${identifier("resourceId")},
|
|
1544
1531
|
a.details, a.ip_address AS ${identifier("ipAddress")}, a.user_agent AS ${identifier("userAgent")},
|
|
1545
1532
|
a.created_at AS ${identifier("createdAt")}, a.category, a.outcome, a.severity,
|
|
1546
|
-
a.request_id AS ${identifier("requestId")}, a.trace_id AS ${identifier("traceId")}, a.scope_id AS ${identifier("scopeId")}, a.actor_snapshot AS ${identifier("actorSnapshot")}
|
|
1533
|
+
a.request_id AS ${identifier("requestId")}, a.trace_id AS ${identifier("traceId")}, a.scope_id AS ${identifier("scopeId")}, a.actor_snapshot AS ${identifier("actorSnapshot")}
|
|
1547
1534
|
FROM ${AUDIT_TABLE} a
|
|
1548
|
-
LEFT JOIN ${identifier(userTable)} u ON a.user_id = u.${identifier(userIdColumn)}
|
|
1549
1535
|
${whereClause}
|
|
1550
1536
|
ORDER BY a.${sortColumns[sortBy]} ${direction.toUpperCase()}, a.id ${direction.toUpperCase()}`,
|
|
1551
1537
|
params,
|
|
@@ -1564,10 +1550,10 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1564
1550
|
}
|
|
1565
1551
|
return ok({
|
|
1566
1552
|
...result.data,
|
|
1567
|
-
items: result.data.items.map((row) =>
|
|
1568
|
-
|
|
1569
|
-
username:
|
|
1570
|
-
})
|
|
1553
|
+
items: result.data.items.map((row) => {
|
|
1554
|
+
const record = normalizeAuditLog(row);
|
|
1555
|
+
return { ...record, username: record.actorSnapshot?.username ?? null };
|
|
1556
|
+
})
|
|
1571
1557
|
});
|
|
1572
1558
|
} catch (error) {
|
|
1573
1559
|
logger4.error("Failed to query audit logs", { error });
|
|
@@ -1869,9 +1855,6 @@ var audit = {
|
|
|
1869
1855
|
identifierResult.error
|
|
1870
1856
|
);
|
|
1871
1857
|
}
|
|
1872
|
-
const migrated = await migrateAudit();
|
|
1873
|
-
if (!migrated.success)
|
|
1874
|
-
return migrated;
|
|
1875
1858
|
const candidate = new AuditLogRepository({
|
|
1876
1859
|
userTable: parsed.userTable,
|
|
1877
1860
|
userIdColumn: parsed.userIdColumn,
|
|
@@ -1883,7 +1866,7 @@ var audit = {
|
|
|
1883
1866
|
params: { error: ready.error.message }
|
|
1884
1867
|
}), ready.error);
|
|
1885
1868
|
}
|
|
1886
|
-
const protection = await
|
|
1869
|
+
const protection = await initializeAuditStorage();
|
|
1887
1870
|
if (!protection.success)
|
|
1888
1871
|
return protection;
|
|
1889
1872
|
const configured = await configureAuditRetention(parsed.retention);
|
|
@@ -1947,7 +1930,7 @@ var audit = {
|
|
|
1947
1930
|
return written.success ? ok(void 0) : written;
|
|
1948
1931
|
},
|
|
1949
1932
|
/**
|
|
1950
|
-
*
|
|
1933
|
+
* 分页查询审计日志列表,用户名来自事件时的身份快照。
|
|
1951
1934
|
*
|
|
1952
1935
|
* @param options - 过滤条件与分页参数
|
|
1953
1936
|
* @returns 成功时返回 PaginatedResult(items/total/page/pageSize);未初始化时返回 NOT_INITIALIZED
|
|
@@ -2111,6 +2094,6 @@ var audit = {
|
|
|
2111
2094
|
}
|
|
2112
2095
|
};
|
|
2113
2096
|
|
|
2114
|
-
export { AUDIT_BACKUP_PREFIX, AuditInitConfigSchema, HaiAuditError, audit
|
|
2097
|
+
export { AUDIT_BACKUP_PREFIX, AuditInitConfigSchema, HaiAuditError, audit };
|
|
2115
2098
|
//# sourceMappingURL=index.js.map
|
|
2116
2099
|
//# sourceMappingURL=index.js.map
|