@h-ai/audit 0.1.0-alpha.71 → 0.1.0-alpha.73
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 +71 -13
- package/README.ja.md +71 -13
- package/README.md +72 -14
- package/dist/index.d.ts +257 -8
- package/dist/index.js +1050 -149
- package/dist/index.js.map +1 -1
- package/package.json +23 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,63 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { core, ok, err } from '@h-ai/core';
|
|
3
|
-
import { validateIdentifiers,
|
|
3
|
+
import { migrateSchema, reldb, validateIdentifiers, schemaColumns, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
6
|
+
import { Buffer } from 'buffer';
|
|
4
7
|
|
|
5
8
|
// src/audit-config.ts
|
|
6
9
|
var AuditInitConfigSchema = z.object({
|
|
10
|
+
/** 以固定时间窗口统计已提交事件;目标和事件由应用配置,不内置业务角色。 */
|
|
11
|
+
alerts: z.object({
|
|
12
|
+
enabled: z.boolean().default(false),
|
|
13
|
+
maxNotificationsPerCheck: z.number().int().min(1).max(1e3).default(100),
|
|
14
|
+
rules: z.array(z.object({
|
|
15
|
+
id: z.string().regex(/^[\w-]{1,64}$/),
|
|
16
|
+
actions: z.array(z.string().trim().min(1).max(128)).min(1).max(50),
|
|
17
|
+
resource: z.string().trim().min(1).max(256).optional(),
|
|
18
|
+
outcomes: z.array(z.enum(["success", "failure", "denied"])).max(3).default(["failure", "denied"]),
|
|
19
|
+
minimumSeverity: z.enum(["info", "warning", "error", "critical"]).default("info"),
|
|
20
|
+
severity: z.enum(["warning", "error", "critical"]).default("warning"),
|
|
21
|
+
threshold: z.number().int().min(1).max(1e9).default(1),
|
|
22
|
+
windowSeconds: z.number().int().min(60).max(86400).default(300),
|
|
23
|
+
targets: z.array(z.object({
|
|
24
|
+
id: z.string().regex(/^[\w-]{1,64}$/),
|
|
25
|
+
provider: z.string().trim().min(1).max(128),
|
|
26
|
+
to: z.string().trim().min(1).max(1e3)
|
|
27
|
+
})).min(1).max(20).refine((targets) => new Set(targets.map((target) => target.id)).size === targets.length)
|
|
28
|
+
})).max(20).default([]).refine((rules) => new Set(rules.map((rule) => rule.id)).size === rules.length),
|
|
29
|
+
schedule: z.object({
|
|
30
|
+
enabled: z.boolean().default(false),
|
|
31
|
+
cron: z.string().trim().min(1).max(128).default("* * * * *"),
|
|
32
|
+
timezone: z.string().trim().min(1).max(128).default("UTC")
|
|
33
|
+
}).default({ enabled: false, cron: "* * * * *", timezone: "UTC" })
|
|
34
|
+
}).default({ enabled: false, maxNotificationsPerCheck: 100, rules: [], schedule: { enabled: false, cron: "* * * * *", timezone: "UTC" } }).refine((alerts) => !alerts.enabled || alerts.rules.length > 0),
|
|
35
|
+
/** 可选的流式归档;自动执行由宿主现有 Scheduler 装配。 */
|
|
36
|
+
backup: z.object({
|
|
37
|
+
enabled: z.boolean().default(false),
|
|
38
|
+
batchSize: z.number().int().min(1).max(1e3).default(200),
|
|
39
|
+
maxRecordBytes: z.number().int().min(1024).max(64 * 1024 * 1024).default(1024 * 1024),
|
|
40
|
+
schedule: z.object({
|
|
41
|
+
enabled: z.boolean().default(false),
|
|
42
|
+
cron: z.string().trim().min(1).max(128).default("0 2 * * *"),
|
|
43
|
+
timezone: z.string().trim().min(1).max(128).default("UTC")
|
|
44
|
+
}).default({ enabled: false, cron: "0 2 * * *", timezone: "UTC" })
|
|
45
|
+
}).default({ enabled: false, batchSize: 200, maxRecordBytes: 1024 * 1024, schedule: { enabled: false, cron: "0 2 * * *", timezone: "UTC" } }),
|
|
46
|
+
/** 框架入口自动采集;显式 audit.log 不受此开关影响。 */
|
|
47
|
+
capture: z.object({
|
|
48
|
+
enabled: z.boolean().default(true),
|
|
49
|
+
includeReads: z.boolean().default(false)
|
|
50
|
+
}).default({ enabled: true, includeReads: false }),
|
|
51
|
+
/** 未传时沿用数据库保留设置;显式关闭才解除保护。 */
|
|
52
|
+
retention: z.object({
|
|
53
|
+
enabled: z.boolean().default(false),
|
|
54
|
+
minDays: z.number().int().min(1).max(36500).default(90)
|
|
55
|
+
}).optional(),
|
|
56
|
+
/** 凭据始终脱敏;此开关仅控制业务隐私字段。 */
|
|
57
|
+
privacy: z.object({
|
|
58
|
+
enabled: z.boolean().default(true),
|
|
59
|
+
fields: z.array(z.string().trim().min(1).max(256)).max(1e3).default([])
|
|
60
|
+
}).default({ enabled: true, fields: [] }),
|
|
7
61
|
/** 用户表名,用于 list 查询时 LEFT JOIN 获取用户名 */
|
|
8
62
|
userTable: z.string().default("hai_iam_users"),
|
|
9
63
|
/** 用户表主键列名,用于 JOIN 条件 */
|
|
@@ -11,57 +65,25 @@ var AuditInitConfigSchema = z.object({
|
|
|
11
65
|
/** 用户表用户名列名,用于 SELECT 输出 */
|
|
12
66
|
userNameColumn: z.string().default("username")
|
|
13
67
|
});
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
return ok(void 0);
|
|
68
|
+
var storage = new AsyncLocalStorage();
|
|
69
|
+
function getAuditContext() {
|
|
70
|
+
return storage.getStore() ?? {};
|
|
19
71
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
26
|
-
async logout(userId, ip, ua) {
|
|
27
|
-
const result = await logFn({ userId, action: "logout", resource: "auth", ipAddress: ip, userAgent: ua });
|
|
28
|
-
return toVoid(result);
|
|
29
|
-
},
|
|
30
|
-
async register(userId, ip, ua) {
|
|
31
|
-
const result = await logFn({ userId, action: "register", resource: "auth", resourceId: userId, ipAddress: ip, userAgent: ua });
|
|
32
|
-
return toVoid(result);
|
|
33
|
-
},
|
|
34
|
-
async passwordResetRequest(email, ip, ua) {
|
|
35
|
-
const result = await logFn({
|
|
36
|
-
action: "password_reset_request",
|
|
37
|
-
resource: "auth",
|
|
38
|
-
details: { email },
|
|
39
|
-
ipAddress: ip,
|
|
40
|
-
userAgent: ua
|
|
41
|
-
});
|
|
42
|
-
return toVoid(result);
|
|
43
|
-
},
|
|
44
|
-
async passwordResetComplete(userId, ip, ua) {
|
|
45
|
-
const result = await logFn({ userId, action: "password_reset", resource: "auth", ipAddress: ip, userAgent: ua });
|
|
46
|
-
return toVoid(result);
|
|
47
|
-
},
|
|
48
|
-
async crud(input) {
|
|
49
|
-
const result = await logFn({
|
|
50
|
-
userId: input.userId,
|
|
51
|
-
action: input.action,
|
|
52
|
-
resource: input.resource,
|
|
53
|
-
resourceId: input.resourceId,
|
|
54
|
-
details: input.details,
|
|
55
|
-
ipAddress: input.ip,
|
|
56
|
-
userAgent: input.ua
|
|
57
|
-
});
|
|
58
|
-
return toVoid(result);
|
|
59
|
-
}
|
|
60
|
-
};
|
|
72
|
+
function withAuditContext(context, operation) {
|
|
73
|
+
const previous = getAuditContext();
|
|
74
|
+
const actor = Object.hasOwn(context, "actor") ? context.actor : previous.actor;
|
|
75
|
+
const snapshot = actor ? { ...actor, ...actor.roles ? { roles: [...actor.roles] } : {} } : actor;
|
|
76
|
+
return storage.run({ ...previous, ...context, actor: snapshot }, operation);
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
// messages/en-US.json
|
|
64
80
|
var en_US_default = {
|
|
81
|
+
audit_eventFieldsInvalid: "Invalid event fields",
|
|
82
|
+
audit_identityMismatch: "Actor snapshot does not match the event identity",
|
|
83
|
+
audit_queryInvalid: "Invalid filters or dates",
|
|
84
|
+
audit_eventProcessingFailed: "Event processing failed",
|
|
85
|
+
audit_sortInvalid: "Invalid sort field or direction",
|
|
86
|
+
audit_retentionPolicyFailed: "Unable to read or update the audit retention policy",
|
|
65
87
|
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
66
88
|
audit_notInitialized: "Audit module not initialized, call audit.init() first",
|
|
67
89
|
audit_initFailed: "Audit module initialization failed: {error}",
|
|
@@ -72,11 +94,32 @@ var en_US_default = {
|
|
|
72
94
|
audit_invalidStatCount: "Audit count is outside the valid range and cannot be displayed safely",
|
|
73
95
|
audit_configError: "Audit config validation failed: {error}",
|
|
74
96
|
audit_initInProgress: "Audit module initialization is already in progress",
|
|
75
|
-
audit_invalidInput: "Invalid audit input for {field}: {reason}"
|
|
97
|
+
audit_invalidInput: "Invalid audit input for {field}: {reason}",
|
|
98
|
+
audit_backupDisabled: "Audit backup is disabled",
|
|
99
|
+
audit_backupFailed: "Audit backup operation failed",
|
|
100
|
+
audit_backupInvalid: "Invalid audit backup input",
|
|
101
|
+
audit_backupConflict: "This backup ID belongs to a different export request",
|
|
102
|
+
audit_backupStorageRequired: "Audit backups require initialized storage",
|
|
103
|
+
audit_backupPrivateStorageRequired: "Audit backups require private storage; remove the public base URL and restrict archive reads",
|
|
104
|
+
audit_backupVerificationFailed: "Audit archive verification could not complete",
|
|
105
|
+
audit_backupTaskName: "Audit log backup",
|
|
106
|
+
audit_alertsDisabled: "Audit alerts are disabled",
|
|
107
|
+
audit_alertsFailed: "Audit alert check or notification failed",
|
|
108
|
+
audit_alertReachRequired: "Alerts require initialized Reach and valid notification providers",
|
|
109
|
+
audit_alertSubject: "[{severity}] Security alert: {rule}",
|
|
110
|
+
audit_alertTaskName: "Audit anomaly alert check",
|
|
111
|
+
audit_unknownTask: "Unknown audit scheduled task",
|
|
112
|
+
audit_eventTimeUnavailable: "A valid database audit time is unavailable"
|
|
76
113
|
};
|
|
77
114
|
|
|
78
115
|
// messages/ja-JP.json
|
|
79
116
|
var ja_JP_default = {
|
|
117
|
+
audit_eventFieldsInvalid: "\u30A4\u30D9\u30F3\u30C8\u9805\u76EE\u304C\u7121\u52B9\u3067\u3059",
|
|
118
|
+
audit_identityMismatch: "\u8EAB\u5143\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3068\u64CD\u4F5C\u8005\u304C\u4E00\u81F4\u3057\u307E\u305B\u3093",
|
|
119
|
+
audit_queryInvalid: "\u691C\u7D22\u6761\u4EF6\u307E\u305F\u306F\u65E5\u4ED8\u304C\u7121\u52B9\u3067\u3059",
|
|
120
|
+
audit_eventProcessingFailed: "\u30A4\u30D9\u30F3\u30C8\u51E6\u7406\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
|
|
121
|
+
audit_sortInvalid: "\u4E26\u3079\u66FF\u3048\u9805\u76EE\u307E\u305F\u306F\u65B9\u5411\u304C\u7121\u52B9\u3067\u3059",
|
|
122
|
+
audit_retentionPolicyFailed: "\u76E3\u67FB\u306E\u6700\u4F4E\u4FDD\u6301\u30DD\u30EA\u30B7\u30FC\u3092\u8AAD\u307F\u53D6\u308A\u307E\u305F\u306F\u66F4\u65B0\u3067\u304D\u307E\u305B\u3093",
|
|
80
123
|
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
81
124
|
audit_notInitialized: "\u76E3\u67FB\u30E2\u30B8\u30E5\u30FC\u30EB\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Baudit.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
82
125
|
audit_initFailed: "\u76E3\u67FB\u30E2\u30B8\u30E5\u30FC\u30EB\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
@@ -87,11 +130,32 @@ var ja_JP_default = {
|
|
|
87
130
|
audit_invalidStatCount: "\u76E3\u67FB\u4EF6\u6570\u304C\u6709\u52B9\u7BC4\u56F2\u3092\u8D85\u3048\u3066\u3044\u308B\u305F\u3081\u3001\u5B89\u5168\u306B\u8868\u793A\u3067\u304D\u307E\u305B\u3093",
|
|
88
131
|
audit_configError: "\u76E3\u67FB\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
89
132
|
audit_initInProgress: "\u76E3\u67FB\u30E2\u30B8\u30E5\u30FC\u30EB\u306E\u521D\u671F\u5316\u306F\u65E2\u306B\u5B9F\u884C\u4E2D\u3067\u3059",
|
|
90
|
-
audit_invalidInput: "{field}\u306E\u76E3\u67FB\u5165\u529B\u304C\u7121\u52B9\u3067\u3059: {reason}"
|
|
133
|
+
audit_invalidInput: "{field}\u306E\u76E3\u67FB\u5165\u529B\u304C\u7121\u52B9\u3067\u3059: {reason}",
|
|
134
|
+
audit_backupDisabled: "\u76E3\u67FB\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7\u306F\u7121\u52B9\u3067\u3059",
|
|
135
|
+
audit_backupFailed: "\u76E3\u67FB\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7\u64CD\u4F5C\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
|
|
136
|
+
audit_backupInvalid: "\u76E3\u67FB\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7\u306E\u5165\u529B\u304C\u7121\u52B9\u3067\u3059",
|
|
137
|
+
audit_backupConflict: "\u3053\u306E\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7 ID \u306F\u7570\u306A\u308B\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8\u6761\u4EF6\u3067\u4F7F\u7528\u3055\u308C\u3066\u3044\u307E\u3059",
|
|
138
|
+
audit_backupStorageRequired: "\u76E3\u67FB\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7\u306B\u306F\u521D\u671F\u5316\u6E08\u307F\u30B9\u30C8\u30EC\u30FC\u30B8\u304C\u5FC5\u8981\u3067\u3059",
|
|
139
|
+
audit_backupPrivateStorageRequired: "\u76E3\u67FB\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7\u306B\u306F\u975E\u516C\u958B\u30B9\u30C8\u30EC\u30FC\u30B8\u304C\u5FC5\u8981\u3067\u3059\u3002\u516C\u958B URL \u3092\u524A\u9664\u3057\u3001\u30A2\u30FC\u30AB\u30A4\u30D6\u306E\u8AAD\u53D6\u3092\u5236\u9650\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
140
|
+
audit_backupVerificationFailed: "\u76E3\u67FB\u30A2\u30FC\u30AB\u30A4\u30D6\u306E\u691C\u8A3C\u3092\u5B8C\u4E86\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F",
|
|
141
|
+
audit_backupTaskName: "\u76E3\u67FB\u30ED\u30B0\u306E\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7",
|
|
142
|
+
audit_alertsDisabled: "\u76E3\u67FB\u30A2\u30E9\u30FC\u30C8\u306F\u7121\u52B9\u3067\u3059",
|
|
143
|
+
audit_alertsFailed: "\u76E3\u67FB\u30A2\u30E9\u30FC\u30C8\u306E\u78BA\u8A8D\u307E\u305F\u306F\u901A\u77E5\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
|
|
144
|
+
audit_alertReachRequired: "\u521D\u671F\u5316\u6E08\u307F\u306E Reach \u3068\u6709\u52B9\u306A\u901A\u77E5\u30C1\u30E3\u30CD\u30EB\u304C\u5FC5\u8981\u3067\u3059",
|
|
145
|
+
audit_alertSubject: "[{severity}] \u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30A2\u30E9\u30FC\u30C8\uFF1A{rule}",
|
|
146
|
+
audit_alertTaskName: "\u76E3\u67FB\u7570\u5E38\u30A2\u30E9\u30FC\u30C8\u78BA\u8A8D",
|
|
147
|
+
audit_unknownTask: "\u4E0D\u660E\u306A\u76E3\u67FB\u5B9A\u671F\u30BF\u30B9\u30AF\u3067\u3059",
|
|
148
|
+
audit_eventTimeUnavailable: "\u6709\u52B9\u306A\u76E3\u67FB\u7528 DB \u6642\u523B\u3092\u53D6\u5F97\u3067\u304D\u307E\u305B\u3093"
|
|
91
149
|
};
|
|
92
150
|
|
|
93
151
|
// messages/zh-CN.json
|
|
94
152
|
var zh_CN_default = {
|
|
153
|
+
audit_eventFieldsInvalid: "\u4E8B\u4EF6\u5B57\u6BB5\u65E0\u6548",
|
|
154
|
+
audit_identityMismatch: "\u8EAB\u4EFD\u5FEB\u7167\u4E0E\u64CD\u4F5C\u8005\u4E0D\u4E00\u81F4",
|
|
155
|
+
audit_queryInvalid: "\u7B5B\u9009\u6761\u4EF6\u6216\u65E5\u671F\u65E0\u6548",
|
|
156
|
+
audit_eventProcessingFailed: "\u4E8B\u4EF6\u5904\u7406\u5931\u8D25",
|
|
157
|
+
audit_sortInvalid: "\u6392\u5E8F\u5B57\u6BB5\u6216\u65B9\u5411\u65E0\u6548",
|
|
158
|
+
audit_retentionPolicyFailed: "\u65E0\u6CD5\u8BFB\u53D6\u6216\u66F4\u65B0\u5BA1\u8BA1\u6700\u4F4E\u4FDD\u7559\u7B56\u7565",
|
|
95
159
|
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
96
160
|
audit_notInitialized: "\u5BA1\u8BA1\u6A21\u5757\u5C1A\u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 audit.init()",
|
|
97
161
|
audit_initFailed: "\u5BA1\u8BA1\u6A21\u5757\u521D\u59CB\u5316\u5931\u8D25\uFF1A{error}",
|
|
@@ -102,7 +166,22 @@ var zh_CN_default = {
|
|
|
102
166
|
audit_invalidStatCount: "\u5BA1\u8BA1\u7EDF\u8BA1\u6570\u91CF\u8D85\u51FA\u6709\u6548\u8303\u56F4\uFF0C\u65E0\u6CD5\u5B89\u5168\u663E\u793A",
|
|
103
167
|
audit_configError: "\u5BA1\u8BA1\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
|
|
104
168
|
audit_initInProgress: "\u5BA1\u8BA1\u6A21\u5757\u6B63\u5728\u521D\u59CB\u5316\u4E2D\uFF0C\u8BF7\u52FF\u5E76\u53D1\u8C03\u7528",
|
|
105
|
-
audit_invalidInput: "\u5BA1\u8BA1\u8F93\u5165\u53C2\u6570\u65E0\u6548\uFF08{field}\uFF09\uFF1A{reason}"
|
|
169
|
+
audit_invalidInput: "\u5BA1\u8BA1\u8F93\u5165\u53C2\u6570\u65E0\u6548\uFF08{field}\uFF09\uFF1A{reason}",
|
|
170
|
+
audit_backupDisabled: "\u5BA1\u8BA1\u5907\u4EFD\u672A\u542F\u7528",
|
|
171
|
+
audit_backupFailed: "\u5BA1\u8BA1\u5907\u4EFD\u64CD\u4F5C\u5931\u8D25",
|
|
172
|
+
audit_backupInvalid: "\u5BA1\u8BA1\u5907\u4EFD\u53C2\u6570\u65E0\u6548",
|
|
173
|
+
audit_backupConflict: "\u6B64\u5907\u4EFD\u7F16\u53F7\u5DF2\u7528\u4E8E\u4E0D\u540C\u7684\u5BFC\u51FA\u6761\u4EF6",
|
|
174
|
+
audit_backupStorageRequired: "\u542F\u7528\u5BA1\u8BA1\u5907\u4EFD\u9700\u8981\u5DF2\u521D\u59CB\u5316\u7684\u5B58\u50A8",
|
|
175
|
+
audit_backupPrivateStorageRequired: "\u5BA1\u8BA1\u5907\u4EFD\u9700\u8981\u79C1\u6709\u5B58\u50A8\uFF0C\u8BF7\u79FB\u9664\u516C\u5F00\u8BBF\u95EE\u57FA\u7840 URL \u5E76\u9650\u5236\u5F52\u6863\u8BFB\u53D6",
|
|
176
|
+
audit_backupVerificationFailed: "\u65E0\u6CD5\u5B8C\u6210\u5BA1\u8BA1\u5F52\u6863\u6821\u9A8C",
|
|
177
|
+
audit_backupTaskName: "\u5BA1\u8BA1\u65E5\u5FD7\u5907\u4EFD",
|
|
178
|
+
audit_alertsDisabled: "\u5BA1\u8BA1\u544A\u8B66\u672A\u542F\u7528",
|
|
179
|
+
audit_alertsFailed: "\u5BA1\u8BA1\u544A\u8B66\u68C0\u67E5\u6216\u901A\u77E5\u5931\u8D25",
|
|
180
|
+
audit_alertReachRequired: "\u544A\u8B66\u9700\u8981\u5DF2\u521D\u59CB\u5316\u7684 Reach \u548C\u6709\u6548\u901A\u77E5\u6E20\u9053",
|
|
181
|
+
audit_alertSubject: "[{severity}] \u5B89\u5168\u544A\u8B66\uFF1A{rule}",
|
|
182
|
+
audit_alertTaskName: "\u5BA1\u8BA1\u5F02\u5E38\u544A\u8B66\u68C0\u67E5",
|
|
183
|
+
audit_unknownTask: "\u672A\u77E5\u5BA1\u8BA1\u5B9A\u65F6\u4EFB\u52A1",
|
|
184
|
+
audit_eventTimeUnavailable: "\u65E0\u6CD5\u53D6\u5F97\u6709\u6548\u7684\u6570\u636E\u5E93\u5BA1\u8BA1\u65F6\u95F4"
|
|
106
185
|
};
|
|
107
186
|
|
|
108
187
|
// src/audit-i18n.ts
|
|
@@ -118,16 +197,713 @@ var AuditErrorInfo = {
|
|
|
118
197
|
STATS_FAILED: "004:500",
|
|
119
198
|
INIT_IN_PROGRESS: "005:409",
|
|
120
199
|
NOT_INITIALIZED: "010:500",
|
|
121
|
-
CONFIG_ERROR: "012:500"
|
|
200
|
+
CONFIG_ERROR: "012:500",
|
|
201
|
+
BACKUP_DISABLED: "020:403",
|
|
202
|
+
BACKUP_FAILED: "021:500",
|
|
203
|
+
BACKUP_INVALID: "022:400",
|
|
204
|
+
BACKUP_CONFLICT: "023:409",
|
|
205
|
+
ALERTS_DISABLED: "024:403",
|
|
206
|
+
ALERTS_FAILED: "025:500"
|
|
122
207
|
};
|
|
123
208
|
var HaiAuditError = core.error.buildHaiErrorsDef("audit", AuditErrorInfo);
|
|
209
|
+
var AUDIT_BACKUP_PREFIX = "hai-private/audit-backups/";
|
|
124
210
|
|
|
125
|
-
// src/audit-
|
|
126
|
-
|
|
211
|
+
// src/audit-database-time.ts
|
|
212
|
+
function auditClockSql() {
|
|
213
|
+
return reldb.config?.type === "sqlite" ? "CAST(ROUND((julianday('now')-2440587.5)*86400000) AS INTEGER)" : reldb.config?.type === "postgresql" ? "CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)" : "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3))*1000 AS SIGNED)";
|
|
214
|
+
}
|
|
215
|
+
async function readAuditTime(sql = reldb.sql) {
|
|
216
|
+
const result = await sql.get(`SELECT ${auditClockSql()} AS now_ms`);
|
|
217
|
+
if (!result.success)
|
|
218
|
+
return err(HaiAuditError.LOG_FAILED, auditM("audit_eventTimeUnavailable"), result.error);
|
|
219
|
+
const value = Number(result.data?.now_ms);
|
|
220
|
+
if (!Number.isSafeInteger(value) || value < 0 || !Number.isFinite(new Date(value).getTime()))
|
|
221
|
+
return err(HaiAuditError.LOG_FAILED, auditM("audit_eventTimeUnavailable"));
|
|
222
|
+
return ok(new Date(value));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/audit-alert-functions.ts
|
|
226
|
+
var logger = core.logger.child({ module: "audit", scope: "alerts" });
|
|
227
|
+
var levels = ["info", "warning", "error", "critical"];
|
|
228
|
+
var table = "hai_audit_alert_claims";
|
|
229
|
+
var hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
230
|
+
function createAuditAlerts(input) {
|
|
231
|
+
let active = true;
|
|
232
|
+
const controllers = /* @__PURE__ */ new Set();
|
|
233
|
+
const pending = /* @__PURE__ */ new Set();
|
|
234
|
+
async function run(signal) {
|
|
235
|
+
if (!input.config.enabled)
|
|
236
|
+
return err(HaiAuditError.ALERTS_DISABLED, auditM("audit_alertsDisabled"));
|
|
237
|
+
signal.throwIfAborted();
|
|
238
|
+
const type = reldb.config?.type;
|
|
239
|
+
const clock = auditClockSql();
|
|
240
|
+
const time = await readAuditTime();
|
|
241
|
+
if (!time.success)
|
|
242
|
+
return time;
|
|
243
|
+
const now = time.data.getTime();
|
|
244
|
+
const report = { submitted: 0, queued: 0, failed: 0, more: false };
|
|
245
|
+
let attempted = 0;
|
|
246
|
+
for (const rule of input.config.rules) {
|
|
247
|
+
const duration = rule.windowSeconds * 1e3;
|
|
248
|
+
const current = Math.floor(now / duration) * duration;
|
|
249
|
+
for (const start of [current - duration, current]) {
|
|
250
|
+
signal.throwIfAborted();
|
|
251
|
+
const end = Math.min(start + duration, now + 1);
|
|
252
|
+
const actions = [...new Set(rule.actions)].sort();
|
|
253
|
+
const outcomes = [...new Set(rule.outcomes)].sort();
|
|
254
|
+
const severities = levels.slice(levels.indexOf(rule.minimumSeverity));
|
|
255
|
+
const date = (value) => type === "sqlite" ? value : new Date(value);
|
|
256
|
+
const conditions = ["created_at >= ?", "created_at < ?", "resource <> ?", `action IN (${actions.map(() => "?").join(",")})`];
|
|
257
|
+
const params = [date(start), date(end), "audit.alert", ...actions];
|
|
258
|
+
if (outcomes.length) {
|
|
259
|
+
conditions.push(`outcome IN (${outcomes.map(() => "?").join(",")})`);
|
|
260
|
+
params.push(...outcomes);
|
|
261
|
+
}
|
|
262
|
+
if (rule.minimumSeverity !== "info") {
|
|
263
|
+
conditions.push(`severity IN (${severities.map(() => "?").join(",")})`);
|
|
264
|
+
params.push(...severities);
|
|
265
|
+
}
|
|
266
|
+
if (rule.resource) {
|
|
267
|
+
conditions.push("resource = ?");
|
|
268
|
+
params.push(rule.resource);
|
|
269
|
+
}
|
|
270
|
+
const counted = await reldb.sql.get(`SELECT COUNT(*) AS count FROM hai_audit_logs WHERE ${conditions.join(" AND ")}`, params);
|
|
271
|
+
if (!counted.success)
|
|
272
|
+
return counted;
|
|
273
|
+
const count = Number(counted.data?.count);
|
|
274
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
275
|
+
return err(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed"));
|
|
276
|
+
if (count < rule.threshold)
|
|
277
|
+
continue;
|
|
278
|
+
for (const target of rule.targets) {
|
|
279
|
+
signal.throwIfAborted();
|
|
280
|
+
const id = hash({ rule: { ...rule, actions, outcomes, targets: void 0 }, target, start });
|
|
281
|
+
if (attempted >= input.config.maxNotificationsPerCheck) {
|
|
282
|
+
const existing = await reldb.sql.get(`SELECT id FROM ${table} WHERE id=?`, [id]);
|
|
283
|
+
if (!existing.success)
|
|
284
|
+
return existing;
|
|
285
|
+
if (!existing.data)
|
|
286
|
+
report.more = true;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const selection = `SELECT ?, ?, ?, ?, 'claimed' WHERE ? <= ${clock} AND ? > ${clock}`;
|
|
290
|
+
const statement = type === "mysql" ? `INSERT IGNORE INTO ${table} (id, rule_id, target_id, window_start, status) ${selection}` : `INSERT INTO ${table} (id, rule_id, target_id, window_start, status) ${selection} ON CONFLICT(id) DO NOTHING`;
|
|
291
|
+
const claimed = await reldb.sql.execute(statement, [id, rule.id, target.id, start, start, start + duration * 2]);
|
|
292
|
+
if (!claimed.success)
|
|
293
|
+
return claimed;
|
|
294
|
+
if (claimed.data.changes === 0)
|
|
295
|
+
continue;
|
|
296
|
+
attempted++;
|
|
297
|
+
const details = { ruleId: rule.id, targetId: target.id, count, windowStart: new Date(start).toISOString(), windowEnd: new Date(start + duration).toISOString(), level: rule.severity };
|
|
298
|
+
const recorded = await input.log({ action: "audit.alert.claimed", resource: "audit.alert", resourceId: id, category: "system", severity: rule.severity, outcome: "success", details });
|
|
299
|
+
if (!recorded.success)
|
|
300
|
+
return recorded;
|
|
301
|
+
signal.throwIfAborted();
|
|
302
|
+
const sending = await reldb.sql.execute(`UPDATE ${table} SET status='sending' WHERE id=? AND status='claimed' AND ? > ${clock}`, [id, start + duration * 2]);
|
|
303
|
+
if (!sending.success)
|
|
304
|
+
return sending;
|
|
305
|
+
if (sending.data.changes !== 1)
|
|
306
|
+
continue;
|
|
307
|
+
const sent = await input.reach.send({ provider: target.provider, to: target.to, subject: auditM("audit_alertSubject", { params: { severity: rule.severity, rule: rule.id } }), body: JSON.stringify(details), idempotencyKey: id });
|
|
308
|
+
const accepted = sent.success && sent.data.success;
|
|
309
|
+
const status = accepted ? sent.data.deferred ? "queued" : "submitted" : "unknown";
|
|
310
|
+
const updated = await reldb.sql.execute(`UPDATE ${table} SET status=?, queue_id=?, error_code=? WHERE id=? AND status='sending'`, [status, sent.success && sent.data.deferred ? sent.data.queueId : null, sent.success ? null : sent.error.code, id]);
|
|
311
|
+
if (!updated.success || updated.data.changes !== 1)
|
|
312
|
+
return err(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed"), updated.success ? void 0 : updated.error);
|
|
313
|
+
if (status === "queued")
|
|
314
|
+
report.queued++;
|
|
315
|
+
else if (status === "submitted")
|
|
316
|
+
report.submitted++;
|
|
317
|
+
else
|
|
318
|
+
report.failed++;
|
|
319
|
+
const outcome = await input.log({ action: "audit.alert.notification", resource: "audit.alert", resourceId: id, category: "system", severity: rule.severity, outcome: accepted ? "success" : "failure", details: { ...details, status, ...sent.success ? {} : { reasonCode: sent.error.code }, ...sent.success && sent.data.deferred ? { queueId: sent.data.queueId } : {} } });
|
|
320
|
+
if (!outcome.success)
|
|
321
|
+
return outcome;
|
|
322
|
+
if (!accepted)
|
|
323
|
+
logger.error("Audit alert notification outcome requires review", { id, ruleId: rule.id, targetId: target.id, errorCode: sent.success ? void 0 : sent.error.code });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const oldest = now - Math.max(...input.config.rules.map((rule) => rule.windowSeconds * 1e3)) * 3;
|
|
328
|
+
const cleaned = await reldb.sql.execute(`DELETE FROM ${table} WHERE window_start < ? AND status IN ('submitted', 'queued')`, [oldest]);
|
|
329
|
+
return cleaned.success ? ok(report) : cleaned;
|
|
330
|
+
}
|
|
331
|
+
const check = (options) => {
|
|
332
|
+
if (!active)
|
|
333
|
+
return Promise.resolve(err(HaiAuditError.NOT_INITIALIZED, auditM("audit_notInitialized")));
|
|
334
|
+
if (options?.signal !== void 0 && !(options.signal instanceof AbortSignal))
|
|
335
|
+
return Promise.resolve(err(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed")));
|
|
336
|
+
const controller = new AbortController();
|
|
337
|
+
const cancel = () => controller.abort();
|
|
338
|
+
options?.signal?.addEventListener("abort", cancel, { once: true });
|
|
339
|
+
if (options?.signal?.aborted)
|
|
340
|
+
cancel();
|
|
341
|
+
controllers.add(controller);
|
|
342
|
+
const operation = withAuditContext({ actor: { id: "system:audit-alerts", username: "scheduler" }, category: "system" }, () => run(controller.signal)).catch((error) => {
|
|
343
|
+
logger.error("Audit alert check failed", { error });
|
|
344
|
+
return err(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed"), error);
|
|
345
|
+
});
|
|
346
|
+
pending.add(operation);
|
|
347
|
+
void operation.finally(() => {
|
|
348
|
+
controllers.delete(controller);
|
|
349
|
+
pending.delete(operation);
|
|
350
|
+
options?.signal?.removeEventListener("abort", cancel);
|
|
351
|
+
});
|
|
352
|
+
return operation;
|
|
353
|
+
};
|
|
354
|
+
let schedule = null;
|
|
355
|
+
if (input.config.enabled && input.config.schedule.enabled) {
|
|
356
|
+
schedule = {
|
|
357
|
+
tasks: [{ id: "hai-audit-alerts", name: auditM("audit_alertTaskName"), idempotent: true, cron: input.config.schedule.cron, timezone: input.config.schedule.timezone }],
|
|
358
|
+
hooks: { onTaskExecute: async ({ context, signal }) => {
|
|
359
|
+
if (context.taskId !== "hai-audit-alerts")
|
|
360
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed"));
|
|
361
|
+
const result = await check({ signal });
|
|
362
|
+
if (!result.success || result.data.failed > 0)
|
|
363
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.ALERTS_FAILED, auditM("audit_alertsFailed"), result.success ? void 0 : result.error);
|
|
364
|
+
return result.data;
|
|
365
|
+
} }
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
check,
|
|
370
|
+
schedule,
|
|
371
|
+
async close() {
|
|
372
|
+
active = false;
|
|
373
|
+
controllers.forEach((controller) => controller.abort());
|
|
374
|
+
await Promise.allSettled([...pending]);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
var logger2 = core.logger.child({ module: "audit", scope: "writer" });
|
|
379
|
+
var actorSchema = z.object({ id: z.string().min(1).max(256), username: z.string().max(256).optional(), displayName: z.string().max(256).optional(), roles: z.array(z.string().max(256)).max(1e3).optional() });
|
|
380
|
+
var detailsSchema = z.record(z.string(), z.unknown());
|
|
381
|
+
var inputSchema = z.object({
|
|
382
|
+
userId: z.string().min(1).max(256).nullable().optional(),
|
|
383
|
+
actorSnapshot: actorSchema.nullable().optional(),
|
|
384
|
+
action: z.string().trim().min(1).max(256),
|
|
385
|
+
resource: z.string().trim().min(1).max(256),
|
|
386
|
+
resourceId: z.string().max(256).nullable().optional(),
|
|
387
|
+
category: z.enum(["system", "business"]).nullable().optional(),
|
|
388
|
+
outcome: z.enum(["success", "failure", "denied"]).nullable().optional(),
|
|
389
|
+
severity: z.enum(["info", "warning", "error", "critical"]).nullable().optional(),
|
|
390
|
+
requestId: z.string().max(256).nullable().optional(),
|
|
391
|
+
traceId: z.string().max(256).nullable().optional(),
|
|
392
|
+
scopeId: z.string().max(256).nullable().optional(),
|
|
393
|
+
ipAddress: z.string().max(256).nullable().optional(),
|
|
394
|
+
userAgent: z.string().max(4096).nullable().optional(),
|
|
395
|
+
details: detailsSchema.nullable().optional()
|
|
396
|
+
});
|
|
397
|
+
var querySchema = z.object({
|
|
398
|
+
requestId: z.string().min(1).max(256).optional(),
|
|
399
|
+
traceId: z.string().min(1).max(256).optional(),
|
|
400
|
+
userId: z.string().min(1).max(256).optional(),
|
|
401
|
+
resource: z.string().min(1).max(256).optional(),
|
|
402
|
+
action: z.string().min(1).max(256).optional(),
|
|
403
|
+
scopeId: z.string().min(1).max(256).optional(),
|
|
404
|
+
category: z.enum(["system", "business"]).optional(),
|
|
405
|
+
outcome: z.enum(["success", "failure", "denied"]).optional(),
|
|
406
|
+
severity: z.enum(["info", "warning", "error", "critical"]).optional(),
|
|
407
|
+
sortBy: z.enum(["createdAt", "action", "resource", "category", "outcome", "severity"]).optional(),
|
|
408
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
409
|
+
startDate: z.date().optional(),
|
|
410
|
+
endDate: z.date().optional()
|
|
411
|
+
});
|
|
412
|
+
function validAuditQuery(options) {
|
|
413
|
+
return querySchema.safeParse(options ?? {}).success;
|
|
414
|
+
}
|
|
415
|
+
function readAuditActor(value) {
|
|
416
|
+
if (value == null)
|
|
417
|
+
return null;
|
|
418
|
+
const parsed = actorSchema.safeParse(typeof value === "string" ? JSON.parse(value) : value);
|
|
419
|
+
if (!parsed.success)
|
|
420
|
+
throw new Error("Invalid audit actor snapshot");
|
|
421
|
+
return parsed.data;
|
|
422
|
+
}
|
|
423
|
+
function createAuditWriter(repository, config) {
|
|
424
|
+
const fields = new Set(config.privacy.fields.map((field) => field.toLowerCase()));
|
|
425
|
+
const businessPrivacy = /email|phone|mobile|idcard|identitynumber|bankaccount|shippingaddress|billingaddress|realname|identifier|recipient|邮箱|手机|电话|身份证|银行卡|地址|真实姓名|客户姓名/i;
|
|
426
|
+
return async (input, tx) => {
|
|
427
|
+
try {
|
|
428
|
+
const parsed = inputSchema.safeParse(input);
|
|
429
|
+
if (!parsed.success) {
|
|
430
|
+
logger2.error("Audit input rejected", { issues: parsed.error.issues.map((issue) => issue.path.join(".")) });
|
|
431
|
+
return err(HaiAuditError.LOG_FAILED, auditM("audit_invalidInput", { params: { field: "event", reason: auditM("audit_eventFieldsInvalid") } }));
|
|
432
|
+
}
|
|
433
|
+
const value = parsed.data;
|
|
434
|
+
const context = getAuditContext();
|
|
435
|
+
const correlation = core.context.get();
|
|
436
|
+
const actor = Object.hasOwn(value, "actorSnapshot") ? value.actorSnapshot : context.actor;
|
|
437
|
+
const userId = value.userId !== void 0 ? value.userId : actor?.id ?? correlation.userId ?? null;
|
|
438
|
+
if (value.actorSnapshot && value.actorSnapshot.id !== userId) {
|
|
439
|
+
logger2.error("Audit actor snapshot rejected", { reason: auditM("audit_identityMismatch") });
|
|
440
|
+
return err(HaiAuditError.LOG_FAILED, auditM("audit_invalidInput", { params: { field: "actorSnapshot", reason: auditM("audit_identityMismatch") } }));
|
|
441
|
+
}
|
|
442
|
+
const snapshot = actor?.id === userId ? readAuditActor(actor) : userId ? await repository.captureActor(userId, tx) : null;
|
|
443
|
+
const outcome = value.outcome ?? "success";
|
|
444
|
+
const jsonDetails = value.details == null ? null : JSON.parse(JSON.stringify(value.details, (key, entry) => {
|
|
445
|
+
const sensitive = core.sanitize.isSensitiveKey(key) || config.privacy.enabled && (businessPrivacy.test(key) || fields.has(key.toLowerCase()));
|
|
446
|
+
return sensitive ? "[REDACTED]" : entry;
|
|
447
|
+
}));
|
|
448
|
+
const normalizedDetails = jsonDetails == null ? null : detailsSchema.parse(jsonDetails);
|
|
449
|
+
const credentialSafe = normalizedDetails == null ? null : core.sanitize.sanitizeSensitiveFields(normalizedDetails, { replacement: "[REDACTED]" });
|
|
450
|
+
const details = !config.privacy.enabled || credentialSafe == null ? credentialSafe : core.sanitize.sanitizeSensitiveFields(credentialSafe, {
|
|
451
|
+
matcher: (key) => businessPrivacy.test(key) || fields.has(key.toLowerCase()),
|
|
452
|
+
replacement: "[REDACTED]"
|
|
453
|
+
});
|
|
454
|
+
const result = await repository.log({
|
|
455
|
+
...value,
|
|
456
|
+
category: value.category ?? context.category ?? "business",
|
|
457
|
+
outcome,
|
|
458
|
+
severity: value.severity ?? (outcome === "denied" ? "warning" : outcome === "failure" ? "error" : "info"),
|
|
459
|
+
userId,
|
|
460
|
+
actorSnapshot: snapshot,
|
|
461
|
+
requestId: value.requestId ?? context.requestId ?? correlation.requestId ?? null,
|
|
462
|
+
traceId: value.traceId ?? context.traceId ?? correlation.traceId ?? correlation.requestId ?? null,
|
|
463
|
+
scopeId: value.scopeId ?? context.scopeId ?? correlation.scopeId ?? null,
|
|
464
|
+
ipAddress: value.ipAddress ?? context.ipAddress ?? null,
|
|
465
|
+
userAgent: value.userAgent ?? context.userAgent ?? null,
|
|
466
|
+
details
|
|
467
|
+
}, tx);
|
|
468
|
+
if (!result.success)
|
|
469
|
+
logger2.error("Audit event persistence failed", { action: value.action, resource: value.resource, requestId: value.requestId ?? context.requestId, error: result.error });
|
|
470
|
+
return result;
|
|
471
|
+
} catch (error) {
|
|
472
|
+
logger2.error("Audit event processing failed", { requestId: getAuditContext().requestId, error });
|
|
473
|
+
return err(HaiAuditError.LOG_FAILED, auditM("audit_logFailed", { params: { error: auditM("audit_eventProcessingFailed") } }), error);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/audit-backup-format.ts
|
|
479
|
+
var backupIdSchema = z.string().regex(/^[a-z0-9][\w-]{0,127}$/i);
|
|
480
|
+
var digestSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
481
|
+
var backupHeaderSchema = z.object({
|
|
482
|
+
type: z.literal("header"),
|
|
483
|
+
format: z.literal("hai-audit"),
|
|
484
|
+
version: z.literal(1),
|
|
485
|
+
id: backupIdSchema,
|
|
486
|
+
createdAt: z.iso.datetime(),
|
|
487
|
+
cutoff: z.iso.datetime(),
|
|
488
|
+
from: z.iso.datetime().nullable(),
|
|
489
|
+
requestHash: digestSchema
|
|
490
|
+
});
|
|
491
|
+
var footerSchema = z.object({ type: z.literal("footer"), recordCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), sha256: digestSchema });
|
|
492
|
+
var entrySchema = z.object({ type: z.literal("entry"), record: z.object({ id: z.string().min(1), action: z.string().min(1), resource: z.string().min(1), createdAt: z.iso.datetime(), actorSnapshot: z.unknown().optional() }).passthrough() });
|
|
493
|
+
function archiveLine(value, maxRecordBytes) {
|
|
494
|
+
const encoded = Buffer.from(`${JSON.stringify(value)}
|
|
495
|
+
`, "utf8");
|
|
496
|
+
if (encoded.byteLength > maxRecordBytes)
|
|
497
|
+
throw new Error("Audit archive record exceeds size limit");
|
|
498
|
+
return encoded;
|
|
499
|
+
}
|
|
500
|
+
async function* lines(source, maxBytes, signal) {
|
|
501
|
+
let fragments = [];
|
|
502
|
+
let length = 0;
|
|
503
|
+
for await (const chunk of source) {
|
|
504
|
+
signal?.throwIfAborted();
|
|
505
|
+
const buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
506
|
+
let offset = 0;
|
|
507
|
+
while (offset < buffer.byteLength) {
|
|
508
|
+
const newline = buffer.indexOf(10, offset);
|
|
509
|
+
const end = newline === -1 ? buffer.byteLength : newline + 1;
|
|
510
|
+
const part = buffer.subarray(offset, end);
|
|
511
|
+
length += part.byteLength;
|
|
512
|
+
if (length > maxBytes)
|
|
513
|
+
throw new Error("Audit archive record exceeds size limit");
|
|
514
|
+
fragments.push(part);
|
|
515
|
+
offset = end;
|
|
516
|
+
if (newline !== -1) {
|
|
517
|
+
yield Buffer.concat(fragments, length);
|
|
518
|
+
fragments = [];
|
|
519
|
+
length = 0;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
signal?.throwIfAborted();
|
|
524
|
+
if (length !== 0)
|
|
525
|
+
throw new Error("Audit archive is truncated");
|
|
526
|
+
}
|
|
527
|
+
async function verifyArchive(source, options) {
|
|
528
|
+
const hash2 = createHash("sha256");
|
|
529
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
530
|
+
let header;
|
|
531
|
+
let footer;
|
|
532
|
+
let count = 0;
|
|
533
|
+
for await (const line of lines(source, options.maxRecordBytes, options.signal)) {
|
|
534
|
+
if (footer)
|
|
535
|
+
throw new Error("Audit archive has trailing records");
|
|
536
|
+
const value = JSON.parse(decoder.decode(line));
|
|
537
|
+
if (!header) {
|
|
538
|
+
header = backupHeaderSchema.parse(value);
|
|
539
|
+
if (options.key !== `${AUDIT_BACKUP_PREFIX}${header.id}.jsonl`)
|
|
540
|
+
throw new Error("Audit archive identity mismatch");
|
|
541
|
+
hash2.update(line);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const end = footerSchema.safeParse(value);
|
|
545
|
+
if (end.success) {
|
|
546
|
+
footer = end.data;
|
|
547
|
+
if (footer.recordCount !== count || footer.sha256 !== hash2.digest("hex"))
|
|
548
|
+
throw new Error("Audit archive checksum or count mismatch");
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const entry = entrySchema.parse(value);
|
|
552
|
+
readAuditActor(entry.record.actorSnapshot);
|
|
553
|
+
if (!Number.isSafeInteger(++count))
|
|
554
|
+
throw new Error("Audit archive record count exceeds safe range");
|
|
555
|
+
hash2.update(line);
|
|
556
|
+
}
|
|
557
|
+
if (!header || !footer)
|
|
558
|
+
throw new Error("Audit archive is incomplete");
|
|
559
|
+
return { info: { id: header.id, key: options.key, recordCount: footer.recordCount, sha256: footer.sha256, createdAt: new Date(header.createdAt), cutoff: new Date(header.cutoff) }, requestHash: header.requestHash };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/audit-backup-functions.ts
|
|
563
|
+
var logger3 = core.logger.child({ module: "audit", scope: "backup" });
|
|
564
|
+
var optionsSchema = z.object({ id: backupIdSchema.optional(), from: z.date().optional(), to: z.date().optional(), signal: z.instanceof(AbortSignal).optional() });
|
|
565
|
+
function createAuditBackup(input) {
|
|
566
|
+
let active = true;
|
|
567
|
+
const controllers = /* @__PURE__ */ new Set();
|
|
568
|
+
const pending = /* @__PURE__ */ new Set();
|
|
569
|
+
function track(operation, event, parent) {
|
|
570
|
+
if (!active)
|
|
571
|
+
return Promise.resolve(err(HaiAuditError.NOT_INITIALIZED, auditM("audit_notInitialized")));
|
|
572
|
+
const controller = new AbortController();
|
|
573
|
+
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
|
574
|
+
controllers.add(controller);
|
|
575
|
+
const promise = Promise.resolve().then(async () => {
|
|
576
|
+
if (!input.config.enabled || !input.storage?.isInitialized)
|
|
577
|
+
return err(HaiAuditError.BACKUP_DISABLED, auditM("audit_backupDisabled"));
|
|
578
|
+
signal.throwIfAborted();
|
|
579
|
+
return operation(signal);
|
|
580
|
+
}).catch((error) => {
|
|
581
|
+
logger3.error("Audit backup operation failed", { error });
|
|
582
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), error);
|
|
583
|
+
}).then(async (result) => {
|
|
584
|
+
const recorded = await input.capture({
|
|
585
|
+
action: event.action,
|
|
586
|
+
resource: "audit",
|
|
587
|
+
resourceId: result.success ? result.data.id : event.resourceId,
|
|
588
|
+
category: "system",
|
|
589
|
+
outcome: result.success ? "success" : result.error.httpStatus === 403 ? "denied" : "failure",
|
|
590
|
+
details: result.success ? { key: result.data.key, recordCount: result.data.recordCount, sha256: result.data.sha256 } : { reasonCode: result.error.code }
|
|
591
|
+
}, { read: event.read });
|
|
592
|
+
if (!recorded.success)
|
|
593
|
+
logger3.error("Audit backup event failed", { action: event.action, error: recorded.error });
|
|
594
|
+
return result;
|
|
595
|
+
}).finally(() => {
|
|
596
|
+
controllers.delete(controller);
|
|
597
|
+
pending.delete(promise);
|
|
598
|
+
});
|
|
599
|
+
pending.add(promise);
|
|
600
|
+
return promise;
|
|
601
|
+
}
|
|
602
|
+
async function read(key, signal) {
|
|
603
|
+
const opened = await input.storage.file.getStream(key, { signal });
|
|
604
|
+
if (!opened.success)
|
|
605
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), opened.error);
|
|
606
|
+
try {
|
|
607
|
+
return ok(await verifyArchive(opened.data, { key, maxRecordBytes: input.config.maxRecordBytes, signal }));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
logger3.error("Audit archive verification failed", { error });
|
|
610
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupVerificationFailed"), error);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function reuse(key, requestHash, signal) {
|
|
614
|
+
const previous = await read(key, signal);
|
|
615
|
+
if (!previous.success)
|
|
616
|
+
return previous;
|
|
617
|
+
return previous.data.requestHash === requestHash ? ok(previous.data.info) : err(HaiAuditError.BACKUP_CONFLICT, auditM("audit_backupConflict"));
|
|
618
|
+
}
|
|
619
|
+
const operations = {
|
|
620
|
+
run(options) {
|
|
621
|
+
const parsed = optionsSchema.safeParse(options ?? {});
|
|
622
|
+
if (!parsed.success)
|
|
623
|
+
return Promise.resolve(err(HaiAuditError.BACKUP_INVALID, auditM("audit_backupInvalid")));
|
|
624
|
+
const values = parsed.data;
|
|
625
|
+
const id = values.id ?? core.id.uuid();
|
|
626
|
+
const from = values.from ? new Date(values.from.getTime()) : void 0;
|
|
627
|
+
const key = `${AUDIT_BACKUP_PREFIX}${id}.jsonl`;
|
|
628
|
+
const database = reldb.config;
|
|
629
|
+
const source = database?.type === "sqlite" ? { type: database.type, database: database.database } : { type: database?.type, host: database?.host, port: database?.port, database: database?.database };
|
|
630
|
+
const requestHash = createHash("sha256").update(JSON.stringify({ source, from: from?.toISOString() ?? null, to: values.to?.toISOString() ?? null })).digest("hex");
|
|
631
|
+
return track(async (signal) => {
|
|
632
|
+
const time = await readAuditTime();
|
|
633
|
+
if (!time.success)
|
|
634
|
+
return time;
|
|
635
|
+
const createdAt = time.data;
|
|
636
|
+
const cutoff = new Date(values.to?.getTime() ?? createdAt.getTime());
|
|
637
|
+
if (cutoff > createdAt || from && from > cutoff)
|
|
638
|
+
return err(HaiAuditError.BACKUP_INVALID, auditM("audit_backupInvalid"));
|
|
639
|
+
const exists = await input.storage.file.exists(key);
|
|
640
|
+
if (!exists.success)
|
|
641
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), exists.error);
|
|
642
|
+
if (exists.data)
|
|
643
|
+
return reuse(key, requestHash, signal);
|
|
644
|
+
let completed;
|
|
645
|
+
async function* content() {
|
|
646
|
+
const hash2 = createHash("sha256");
|
|
647
|
+
const header = archiveLine({ type: "header", format: "hai-audit", version: 1, id, createdAt: createdAt.toISOString(), cutoff: cutoff.toISOString(), from: from?.toISOString() ?? null, requestHash }, input.config.maxRecordBytes);
|
|
648
|
+
hash2.update(header);
|
|
649
|
+
yield header;
|
|
650
|
+
let afterId;
|
|
651
|
+
let count = 0;
|
|
652
|
+
while (true) {
|
|
653
|
+
signal.throwIfAborted();
|
|
654
|
+
const page = await input.repository.archivePage({ afterId, from, cutoff, limit: input.config.batchSize });
|
|
655
|
+
if (!page.success)
|
|
656
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), page.error);
|
|
657
|
+
for (const record of page.data) {
|
|
658
|
+
signal.throwIfAborted();
|
|
659
|
+
const line = archiveLine({ type: "entry", record }, input.config.maxRecordBytes);
|
|
660
|
+
hash2.update(line);
|
|
661
|
+
yield line;
|
|
662
|
+
if (!Number.isSafeInteger(++count))
|
|
663
|
+
throw new Error("Audit archive record count exceeds safe range");
|
|
664
|
+
afterId = record.id;
|
|
665
|
+
}
|
|
666
|
+
if (page.data.length < input.config.batchSize)
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
const sha256 = hash2.digest("hex");
|
|
670
|
+
yield archiveLine({ type: "footer", recordCount: count, sha256 }, input.config.maxRecordBytes);
|
|
671
|
+
completed = { id, key, recordCount: count, sha256, createdAt, cutoff };
|
|
672
|
+
}
|
|
673
|
+
const stream = content();
|
|
674
|
+
let uploaded;
|
|
675
|
+
try {
|
|
676
|
+
uploaded = await input.storage.file.put(key, stream, { ifNoneMatch: "*", contentType: "application/x-ndjson", cacheControl: "private, no-store", signal });
|
|
677
|
+
} finally {
|
|
678
|
+
await stream.return(void 0);
|
|
679
|
+
}
|
|
680
|
+
if (!uploaded.success) {
|
|
681
|
+
const stored = await input.storage.file.exists(key);
|
|
682
|
+
if (stored.success && stored.data)
|
|
683
|
+
return reuse(key, requestHash, signal);
|
|
684
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), uploaded.error);
|
|
685
|
+
}
|
|
686
|
+
if (!completed)
|
|
687
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"));
|
|
688
|
+
return ok(completed);
|
|
689
|
+
}, { action: "audit.backup", resourceId: id }, values.signal);
|
|
690
|
+
},
|
|
691
|
+
verify(key, options) {
|
|
692
|
+
if (typeof key !== "string" || !key.startsWith(AUDIT_BACKUP_PREFIX) || !key.endsWith(".jsonl") || !backupIdSchema.safeParse(key.slice(AUDIT_BACKUP_PREFIX.length, -6)).success)
|
|
693
|
+
return Promise.resolve(err(HaiAuditError.BACKUP_INVALID, auditM("audit_backupInvalid")));
|
|
694
|
+
const parsed = optionsSchema.pick({ signal: true }).safeParse(options ?? {});
|
|
695
|
+
if (!parsed.success)
|
|
696
|
+
return Promise.resolve(err(HaiAuditError.BACKUP_INVALID, auditM("audit_backupInvalid")));
|
|
697
|
+
return track(async (signal) => {
|
|
698
|
+
const result = await read(key, signal);
|
|
699
|
+
return result.success ? ok(result.data.info) : result;
|
|
700
|
+
}, { action: "audit.backup.verify", read: true }, parsed.data.signal);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
let schedule = null;
|
|
704
|
+
if (input.config.enabled && input.config.schedule.enabled) {
|
|
705
|
+
schedule = {
|
|
706
|
+
tasks: [{ id: "hai-audit-backup", name: auditM("audit_backupTaskName"), idempotent: true, cron: input.config.schedule.cron, timezone: input.config.schedule.timezone }],
|
|
707
|
+
hooks: { onTaskExecute: async ({ context, signal }) => {
|
|
708
|
+
if (context.taskId !== "hai-audit-backup" || !context.occurrenceId)
|
|
709
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.BACKUP_INVALID, auditM("audit_backupInvalid"));
|
|
710
|
+
const result = await withAuditContext({ actor: { id: "system:audit-backup", username: "scheduler" }, requestId: context.occurrenceId }, () => operations.run({ id: context.occurrenceId, signal }));
|
|
711
|
+
if (!result.success)
|
|
712
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.BACKUP_FAILED, result.error.message, result.error);
|
|
713
|
+
return result.data;
|
|
714
|
+
} }
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
operations,
|
|
719
|
+
schedule,
|
|
720
|
+
async close() {
|
|
721
|
+
active = false;
|
|
722
|
+
for (const controller of controllers)
|
|
723
|
+
controller.abort();
|
|
724
|
+
await Promise.allSettled([...pending]);
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function toVoid(result) {
|
|
729
|
+
if (!result.success) {
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
return ok(void 0);
|
|
733
|
+
}
|
|
734
|
+
function createHelper(logFn) {
|
|
735
|
+
return {
|
|
736
|
+
async login(userId, ip, ua) {
|
|
737
|
+
const result = await logFn({ userId, category: "system", action: "login", resource: "auth", ipAddress: ip, userAgent: ua });
|
|
738
|
+
return toVoid(result);
|
|
739
|
+
},
|
|
740
|
+
async logout(userId, ip, ua) {
|
|
741
|
+
const result = await logFn({ userId, category: "system", action: "logout", resource: "auth", ipAddress: ip, userAgent: ua });
|
|
742
|
+
return toVoid(result);
|
|
743
|
+
},
|
|
744
|
+
async register(userId, ip, ua) {
|
|
745
|
+
const result = await logFn({ userId, category: "system", action: "register", resource: "auth", resourceId: userId, ipAddress: ip, userAgent: ua });
|
|
746
|
+
return toVoid(result);
|
|
747
|
+
},
|
|
748
|
+
async passwordResetRequest(email, ip, ua) {
|
|
749
|
+
const result = await logFn({
|
|
750
|
+
action: "password_reset_request",
|
|
751
|
+
category: "system",
|
|
752
|
+
resource: "auth",
|
|
753
|
+
details: { email },
|
|
754
|
+
ipAddress: ip,
|
|
755
|
+
userAgent: ua
|
|
756
|
+
});
|
|
757
|
+
return toVoid(result);
|
|
758
|
+
},
|
|
759
|
+
async passwordResetComplete(userId, ip, ua) {
|
|
760
|
+
const result = await logFn({ userId, category: "system", action: "password_reset", resource: "auth", ipAddress: ip, userAgent: ua });
|
|
761
|
+
return toVoid(result);
|
|
762
|
+
},
|
|
763
|
+
async crud(input) {
|
|
764
|
+
const { ip, ua, ...fields } = input;
|
|
765
|
+
const result = await logFn({ ...fields, ipAddress: ip, userAgent: ua });
|
|
766
|
+
return toVoid(result);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
var columns = { category: "VARCHAR(16)", outcome: "VARCHAR(16)", severity: "VARCHAR(16)", request_id: "TEXT", trace_id: "TEXT", scope_id: "TEXT", actor_snapshot: "JSON" };
|
|
771
|
+
var triggers = ["hai_audit_protect_update", "hai_audit_protect_delete", "hai_audit_protect_insert"];
|
|
772
|
+
async function existingTriggers(sql) {
|
|
773
|
+
const result = reldb.config?.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=${reldb.config?.type === "mysql" ? "DATABASE()" : "current_schema()"} AND event_object_table='hai_audit_logs'`);
|
|
774
|
+
return result.success ? ok(result.data.map((row) => row.name)) : result;
|
|
775
|
+
}
|
|
776
|
+
var steps = [{
|
|
777
|
+
version: 1,
|
|
778
|
+
name: "event-context-and-identity",
|
|
779
|
+
async needed(sql) {
|
|
780
|
+
const found = await schemaColumns(reldb, "hai_audit_logs", sql);
|
|
781
|
+
return found.success ? ok(found.data.length > 0 && Object.keys(columns).some((name) => !found.data.includes(name))) : found;
|
|
782
|
+
},
|
|
783
|
+
async apply(sql) {
|
|
784
|
+
const found = await schemaColumns(reldb, "hai_audit_logs", sql);
|
|
785
|
+
if (!found.success)
|
|
786
|
+
return found;
|
|
787
|
+
for (const [name, definition] of Object.entries(columns)) {
|
|
788
|
+
if (found.data.includes(name))
|
|
789
|
+
continue;
|
|
790
|
+
const added = await sql.execute(`ALTER TABLE hai_audit_logs ADD COLUMN ${name} ${definition}`);
|
|
791
|
+
if (!added.success)
|
|
792
|
+
return added;
|
|
793
|
+
}
|
|
794
|
+
return ok(void 0);
|
|
795
|
+
}
|
|
796
|
+
}, {
|
|
797
|
+
version: 2,
|
|
798
|
+
name: "persistent-retention-policy",
|
|
799
|
+
async needed(sql) {
|
|
800
|
+
const found = await schemaColumns(reldb, "hai_audit_policy", sql);
|
|
801
|
+
if (!found.success || found.data.length === 0)
|
|
802
|
+
return found.success ? ok(true) : found;
|
|
803
|
+
const row = await sql.get("SELECT id FROM hai_audit_policy WHERE id=1");
|
|
804
|
+
return row.success ? ok(!row.data) : row;
|
|
805
|
+
},
|
|
806
|
+
async apply(sql) {
|
|
807
|
+
const created = await sql.execute("CREATE TABLE IF NOT EXISTS hai_audit_policy (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL, min_days INTEGER NOT NULL)");
|
|
808
|
+
if (!created.success)
|
|
809
|
+
return created;
|
|
810
|
+
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");
|
|
811
|
+
}
|
|
812
|
+
}, {
|
|
813
|
+
version: 3,
|
|
814
|
+
name: "retention-write-guards",
|
|
815
|
+
async needed(sql) {
|
|
816
|
+
const table2 = await schemaColumns(reldb, "hai_audit_logs", sql);
|
|
817
|
+
if (!table2.success || !table2.data.length)
|
|
818
|
+
return table2.success ? ok(false) : table2;
|
|
819
|
+
const found = await existingTriggers(sql);
|
|
820
|
+
return found.success ? ok(triggers.some((name) => !found.data.includes(name))) : found;
|
|
821
|
+
},
|
|
822
|
+
async apply(sql) {
|
|
823
|
+
const found = await existingTriggers(sql);
|
|
824
|
+
if (!found.success)
|
|
825
|
+
return found;
|
|
826
|
+
const enabled = "COALESCE((SELECT enabled FROM hai_audit_policy WHERE id=1),1)=1";
|
|
827
|
+
const days = "COALESCE((SELECT min_days FROM hai_audit_policy WHERE id=1),90)";
|
|
828
|
+
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))`;
|
|
829
|
+
const existingProtected = `${enabled} AND EXISTS(SELECT 1 FROM hai_audit_logs a WHERE a.id=NEW.id AND a.created_at >= ${cutoff})`;
|
|
830
|
+
if (reldb.config?.type === "postgresql") {
|
|
831
|
+
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`);
|
|
832
|
+
if (!fn.success)
|
|
833
|
+
return fn;
|
|
834
|
+
}
|
|
835
|
+
for (const [index, operation] of ["UPDATE", "DELETE", "INSERT"].entries()) {
|
|
836
|
+
const name = triggers[index];
|
|
837
|
+
if (found.data.includes(name))
|
|
838
|
+
continue;
|
|
839
|
+
const protectedRow = operation === "INSERT" ? existingProtected : `${enabled} AND OLD.created_at >= ${cutoff}`;
|
|
840
|
+
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`;
|
|
841
|
+
const created = reldb.config?.type === "mysql" ? await sql.query(statement) : await sql.execute(statement);
|
|
842
|
+
if (!created.success)
|
|
843
|
+
return created;
|
|
844
|
+
}
|
|
845
|
+
return ok(void 0);
|
|
846
|
+
}
|
|
847
|
+
}, {
|
|
848
|
+
version: 4,
|
|
849
|
+
name: "millisecond-event-time",
|
|
850
|
+
async needed(sql) {
|
|
851
|
+
if (reldb.config?.type !== "mysql")
|
|
852
|
+
return ok(false);
|
|
853
|
+
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"]);
|
|
854
|
+
return column.success ? ok(Boolean(column.data && Number(column.data.fractional_digits ?? 0) < 3)) : column;
|
|
855
|
+
},
|
|
856
|
+
async apply(sql) {
|
|
857
|
+
const changed = await sql.execute("ALTER TABLE hai_audit_logs MODIFY COLUMN created_at DATETIME(3)");
|
|
858
|
+
return changed.success ? ok(void 0) : changed;
|
|
859
|
+
}
|
|
860
|
+
}, {
|
|
861
|
+
version: 5,
|
|
862
|
+
name: "alert-notification-claims",
|
|
863
|
+
async needed(sql) {
|
|
864
|
+
const found = await schemaColumns(reldb, "hai_audit_alert_claims", sql);
|
|
865
|
+
return found.success ? ok(found.data.length === 0) : found;
|
|
866
|
+
},
|
|
867
|
+
async apply(sql) {
|
|
868
|
+
const created = await 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))");
|
|
869
|
+
return created.success ? ok(void 0) : created;
|
|
870
|
+
}
|
|
871
|
+
}, {
|
|
872
|
+
version: 6,
|
|
873
|
+
name: "event-time-index",
|
|
874
|
+
async needed(sql) {
|
|
875
|
+
const table2 = await schemaColumns(reldb, "hai_audit_logs", sql);
|
|
876
|
+
if (!table2.success || !table2.data.length)
|
|
877
|
+
return table2.success ? ok(false) : table2;
|
|
878
|
+
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");
|
|
879
|
+
return found.success ? ok(!found.data) : found;
|
|
880
|
+
},
|
|
881
|
+
async apply(sql) {
|
|
882
|
+
const created = await sql.execute("CREATE INDEX hai_audit_event_time_idx ON hai_audit_logs (created_at)");
|
|
883
|
+
return created.success ? ok(void 0) : created;
|
|
884
|
+
}
|
|
885
|
+
}];
|
|
886
|
+
function migrateAudit(options = {}) {
|
|
887
|
+
return migrateSchema(reldb, "audit", steps, options);
|
|
888
|
+
}
|
|
889
|
+
async function configureAuditRetention(policy) {
|
|
890
|
+
if (!policy)
|
|
891
|
+
return ok(void 0);
|
|
892
|
+
const result = await reldb.sql.execute("UPDATE hai_audit_policy SET enabled=?, min_days=? WHERE id=1", [Number(policy.enabled), policy.minDays]);
|
|
893
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : err(HaiAuditError.CONFIG_ERROR, auditM("audit_retentionPolicyFailed"), result.success ? void 0 : result.error);
|
|
894
|
+
}
|
|
895
|
+
async function auditRetentionDays() {
|
|
896
|
+
const result = await reldb.sql.get("SELECT enabled, min_days FROM hai_audit_policy WHERE id=1");
|
|
897
|
+
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)
|
|
898
|
+
return err(HaiAuditError.CLEANUP_FAILED, auditM("audit_retentionPolicyFailed"));
|
|
899
|
+
return ok(Number(result.data.enabled) === 1 ? Number(result.data.min_days) : 0);
|
|
900
|
+
}
|
|
901
|
+
var logger4 = core.logger.child({ module: "audit", scope: "repository" });
|
|
127
902
|
var AUDIT_TABLE = "hai_audit_logs";
|
|
128
903
|
function normalizeAuditLog(row) {
|
|
129
904
|
return {
|
|
130
905
|
...row,
|
|
906
|
+
actorSnapshot: readAuditActor(row.actorSnapshot),
|
|
131
907
|
userId: row.userId ?? null,
|
|
132
908
|
resourceId: row.resourceId ?? null,
|
|
133
909
|
details: row.details == null ? null : typeof row.details === "string" ? row.details : JSON.stringify(row.details) ?? null,
|
|
@@ -156,6 +932,13 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
156
932
|
{ fieldName: "details", columnName: "details", def: { type: "JSON" }, select: true, create: true, update: false },
|
|
157
933
|
{ fieldName: "ipAddress", columnName: "ip_address", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
158
934
|
{ fieldName: "userAgent", columnName: "user_agent", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
935
|
+
{ fieldName: "category", columnName: "category", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
936
|
+
{ fieldName: "outcome", columnName: "outcome", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
937
|
+
{ fieldName: "severity", columnName: "severity", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
938
|
+
{ fieldName: "requestId", columnName: "request_id", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
939
|
+
{ fieldName: "traceId", columnName: "trace_id", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
940
|
+
{ fieldName: "scopeId", columnName: "scope_id", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
941
|
+
{ fieldName: "actorSnapshot", columnName: "actor_snapshot", def: { type: "JSON" }, select: true, create: true, update: false },
|
|
159
942
|
{ fieldName: "createdAt", columnName: "created_at", def: { type: "TIMESTAMP" }, select: true, create: true, update: false }
|
|
160
943
|
]
|
|
161
944
|
});
|
|
@@ -181,6 +964,37 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
181
964
|
toDateParam(date) {
|
|
182
965
|
return this.isSqlite ? date.getTime() : date;
|
|
183
966
|
}
|
|
967
|
+
/** 身份映射可选;名称读失败仍写入明确缺少名称的身份,并留下可观察诊断。 */
|
|
968
|
+
async captureActor(userId, tx) {
|
|
969
|
+
const quote = this.db.config?.type === "mysql" ? "`" : '"';
|
|
970
|
+
const identifier = (name) => `${quote}${name}${quote}`;
|
|
971
|
+
const { userTable, userIdColumn, userNameColumn } = this.repoConfig;
|
|
972
|
+
const result = await this.sql(tx).get(`SELECT ${identifier(userNameColumn)} AS username FROM ${identifier(userTable)} WHERE ${identifier(userIdColumn)}=?`, [userId]);
|
|
973
|
+
if (!result.success) {
|
|
974
|
+
logger4.warn("Audit actor name unavailable", { userId, error: result.error });
|
|
975
|
+
return { id: userId };
|
|
976
|
+
}
|
|
977
|
+
return { id: userId, ...result.data?.username ? { username: result.data.username } : {} };
|
|
978
|
+
}
|
|
979
|
+
filters(options) {
|
|
980
|
+
const conditions = [];
|
|
981
|
+
const params = [];
|
|
982
|
+
for (const [field, column] of [["userId", "user_id"], ["action", "action"], ["resource", "resource"], ["category", "category"], ["outcome", "outcome"], ["severity", "severity"], ["scopeId", "scope_id"], ["requestId", "request_id"], ["traceId", "trace_id"]]) {
|
|
983
|
+
if (options[field] !== void 0) {
|
|
984
|
+
conditions.push(`a.${column} = ?`);
|
|
985
|
+
params.push(options[field]);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (options.startDate) {
|
|
989
|
+
conditions.push("a.created_at >= ?");
|
|
990
|
+
params.push(this.toDateParam(options.startDate));
|
|
991
|
+
}
|
|
992
|
+
if (options.endDate) {
|
|
993
|
+
conditions.push("a.created_at <= ?");
|
|
994
|
+
params.push(this.toDateParam(options.endDate));
|
|
995
|
+
}
|
|
996
|
+
return { conditions, params };
|
|
997
|
+
}
|
|
184
998
|
/**
|
|
185
999
|
* 记录一条审计日志
|
|
186
1000
|
*
|
|
@@ -194,11 +1008,16 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
194
1008
|
* @param input.userAgent - 客户端 User-Agent(可选)
|
|
195
1009
|
* @returns 成功时返回创建的 AuditLog;失败时返回 LOG_FAILED
|
|
196
1010
|
*/
|
|
197
|
-
async log(input) {
|
|
198
|
-
|
|
1011
|
+
async log(input, tx) {
|
|
1012
|
+
logger4.debug("Recording audit log", { action: input.action, resource: input.resource });
|
|
199
1013
|
const id = core.id.withPrefix("audit_");
|
|
200
|
-
const
|
|
1014
|
+
const time = await readAuditTime(this.sql(tx));
|
|
1015
|
+
if (!time.success)
|
|
1016
|
+
return time;
|
|
1017
|
+
const now = time.data;
|
|
1018
|
+
const context = { category: input.category ?? null, outcome: input.outcome ?? null, severity: input.severity ?? null, requestId: input.requestId ?? null, traceId: input.traceId ?? null, scopeId: input.scopeId ?? null, actorSnapshot: input.actorSnapshot ?? null };
|
|
201
1019
|
const data = {
|
|
1020
|
+
...context,
|
|
202
1021
|
id,
|
|
203
1022
|
userId: input.userId ?? null,
|
|
204
1023
|
action: input.action,
|
|
@@ -210,9 +1029,9 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
210
1029
|
createdAt: now
|
|
211
1030
|
};
|
|
212
1031
|
try {
|
|
213
|
-
const createResult = await this.create(data);
|
|
1032
|
+
const createResult = await this.create(data, tx);
|
|
214
1033
|
if (!createResult.success) {
|
|
215
|
-
|
|
1034
|
+
logger4.error("Failed to record audit log", { action: input.action, error: createResult.error.message });
|
|
216
1035
|
return err(
|
|
217
1036
|
HaiAuditError.LOG_FAILED,
|
|
218
1037
|
auditM("audit_logFailed", { params: { error: createResult.error.message } }),
|
|
@@ -220,6 +1039,7 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
220
1039
|
);
|
|
221
1040
|
}
|
|
222
1041
|
const auditLog = {
|
|
1042
|
+
...context,
|
|
223
1043
|
id,
|
|
224
1044
|
userId: input.userId ?? null,
|
|
225
1045
|
action: input.action,
|
|
@@ -230,10 +1050,10 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
230
1050
|
userAgent: input.userAgent ?? null,
|
|
231
1051
|
createdAt: now
|
|
232
1052
|
};
|
|
233
|
-
|
|
1053
|
+
logger4.info("Audit log recorded", { id, action: input.action, resource: input.resource });
|
|
234
1054
|
return ok(auditLog);
|
|
235
1055
|
} catch (error) {
|
|
236
|
-
|
|
1056
|
+
logger4.error("Failed to record audit log", { action: input.action, error });
|
|
237
1057
|
return err(
|
|
238
1058
|
HaiAuditError.LOG_FAILED,
|
|
239
1059
|
auditM("audit_logFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
|
|
@@ -251,57 +1071,42 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
251
1071
|
const ready = await this.ready();
|
|
252
1072
|
if (!ready.success)
|
|
253
1073
|
return ready;
|
|
254
|
-
|
|
255
|
-
const conditions =
|
|
256
|
-
const params = [];
|
|
1074
|
+
logger4.debug("Querying audit logs", { userId: options.userId, action: options.action, resource: options.resource });
|
|
1075
|
+
const { conditions, params } = this.filters(options);
|
|
257
1076
|
const { userTable, userIdColumn, userNameColumn } = this.repoConfig;
|
|
258
1077
|
const quote = this.db.config?.type === "mysql" ? "`" : '"';
|
|
259
1078
|
const identifier = (name) => `${quote}${name.replaceAll(quote, quote + quote)}${quote}`;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
conditions.push("a.action = ?");
|
|
266
|
-
params.push(options.action);
|
|
267
|
-
}
|
|
268
|
-
if (options.resource) {
|
|
269
|
-
conditions.push("a.resource = ?");
|
|
270
|
-
params.push(options.resource);
|
|
271
|
-
}
|
|
272
|
-
if (options.startDate) {
|
|
273
|
-
conditions.push("a.created_at >= ?");
|
|
274
|
-
params.push(this.toDateParam(options.startDate));
|
|
275
|
-
}
|
|
276
|
-
if (options.endDate) {
|
|
277
|
-
conditions.push("a.created_at <= ?");
|
|
278
|
-
params.push(this.toDateParam(options.endDate));
|
|
279
|
-
}
|
|
1079
|
+
const sortColumns = { createdAt: "created_at", action: "action", resource: "resource", category: "category", outcome: "outcome", severity: "severity" };
|
|
1080
|
+
const sortBy = options.sortBy ?? "createdAt";
|
|
1081
|
+
const direction = options.sortDirection ?? "desc";
|
|
1082
|
+
if (!Object.hasOwn(sortColumns, sortBy) || !["asc", "desc"].includes(direction))
|
|
1083
|
+
return err(HaiAuditError.QUERY_FAILED, auditM("audit_invalidInput", { params: { field: "sort", reason: auditM("audit_sortInvalid") } }));
|
|
280
1084
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
281
1085
|
try {
|
|
282
1086
|
const result = await this.sql().queryPage({
|
|
283
1087
|
sql: `SELECT a.id, a.user_id AS ${identifier("userId")}, a.action, a.resource, a.resource_id AS ${identifier("resourceId")},
|
|
284
1088
|
a.details, a.ip_address AS ${identifier("ipAddress")}, a.user_agent AS ${identifier("userAgent")},
|
|
285
|
-
a.created_at AS ${identifier("createdAt")},
|
|
1089
|
+
a.created_at AS ${identifier("createdAt")}, a.category, a.outcome, a.severity,
|
|
1090
|
+
a.request_id AS ${identifier("requestId")}, a.trace_id AS ${identifier("traceId")}, a.scope_id AS ${identifier("scopeId")}, a.actor_snapshot AS ${identifier("actorSnapshot")}, u.${identifier(userNameColumn)} AS username
|
|
286
1091
|
FROM ${AUDIT_TABLE} a
|
|
287
1092
|
LEFT JOIN ${identifier(userTable)} u ON a.user_id = u.${identifier(userIdColumn)}
|
|
288
1093
|
${whereClause}
|
|
289
|
-
ORDER BY a.
|
|
1094
|
+
ORDER BY a.${sortColumns[sortBy]} ${direction.toUpperCase()}, a.id ${direction.toUpperCase()}`,
|
|
290
1095
|
params,
|
|
291
1096
|
pagination: { page: options.page, pageSize: options.pageSize },
|
|
292
1097
|
overrides: { defaultPageSize: 20 }
|
|
293
1098
|
});
|
|
294
1099
|
if (!result.success) {
|
|
295
|
-
|
|
1100
|
+
logger4.error("Failed to query audit logs", { error: result.error.message });
|
|
296
1101
|
return err(
|
|
297
1102
|
HaiAuditError.QUERY_FAILED,
|
|
298
1103
|
auditM("audit_queryFailed", { params: { error: result.error.message } }),
|
|
299
1104
|
result.error
|
|
300
1105
|
);
|
|
301
1106
|
}
|
|
302
|
-
return ok({ ...result.data, items: result.data.items.map((row) => ({ ...normalizeAuditLog(row), username: row.username ?? null })) });
|
|
1107
|
+
return ok({ ...result.data, items: result.data.items.map((row) => ({ ...normalizeAuditLog(row), username: readAuditActor(row.actorSnapshot)?.username ?? (row.actorSnapshot == null ? row.username ?? null : null) })) });
|
|
303
1108
|
} catch (error) {
|
|
304
|
-
|
|
1109
|
+
logger4.error("Failed to query audit logs", { error });
|
|
305
1110
|
return err(
|
|
306
1111
|
HaiAuditError.QUERY_FAILED,
|
|
307
1112
|
auditM("audit_queryFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
|
|
@@ -309,6 +1114,50 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
309
1114
|
);
|
|
310
1115
|
}
|
|
311
1116
|
}
|
|
1117
|
+
/** 归档使用主键游标有界扫描,不关联当前用户名称或改写历史身份。 */
|
|
1118
|
+
async archivePage(options) {
|
|
1119
|
+
const ready = await this.ready();
|
|
1120
|
+
if (!ready.success)
|
|
1121
|
+
return ready;
|
|
1122
|
+
const quote = this.db.config?.type === "mysql" ? "`" : '"';
|
|
1123
|
+
const fields = [
|
|
1124
|
+
["id", "id"],
|
|
1125
|
+
["user_id", "userId"],
|
|
1126
|
+
["action", "action"],
|
|
1127
|
+
["resource", "resource"],
|
|
1128
|
+
["resource_id", "resourceId"],
|
|
1129
|
+
["details", "details"],
|
|
1130
|
+
["ip_address", "ipAddress"],
|
|
1131
|
+
["user_agent", "userAgent"],
|
|
1132
|
+
["created_at", "createdAt"],
|
|
1133
|
+
["category", "category"],
|
|
1134
|
+
["outcome", "outcome"],
|
|
1135
|
+
["severity", "severity"],
|
|
1136
|
+
["request_id", "requestId"],
|
|
1137
|
+
["trace_id", "traceId"],
|
|
1138
|
+
["scope_id", "scopeId"],
|
|
1139
|
+
["actor_snapshot", "actorSnapshot"]
|
|
1140
|
+
].map(([column, alias]) => `${column} AS ${quote}${alias}${quote}`).join(", ");
|
|
1141
|
+
const conditions = ["created_at <= ?"];
|
|
1142
|
+
const params = [this.toDateParam(options.cutoff)];
|
|
1143
|
+
if (options.from) {
|
|
1144
|
+
conditions.push("created_at >= ?");
|
|
1145
|
+
params.push(this.toDateParam(options.from));
|
|
1146
|
+
}
|
|
1147
|
+
if (options.afterId) {
|
|
1148
|
+
conditions.push("id > ?");
|
|
1149
|
+
params.push(options.afterId);
|
|
1150
|
+
}
|
|
1151
|
+
params.push(options.limit);
|
|
1152
|
+
const result = await this.sql().query(`SELECT ${fields} FROM ${AUDIT_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY id ASC LIMIT ?`, params);
|
|
1153
|
+
if (!result.success)
|
|
1154
|
+
return result;
|
|
1155
|
+
try {
|
|
1156
|
+
return ok(result.data.map(normalizeAuditLog));
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
return err(HaiAuditError.BACKUP_FAILED, auditM("audit_backupFailed"), error);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
312
1161
|
/**
|
|
313
1162
|
* 获取指定用户的最近活动
|
|
314
1163
|
*
|
|
@@ -317,7 +1166,7 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
317
1166
|
* @returns 成功时返回 AuditLog 数组(按时间倒序);失败时返回 QUERY_FAILED
|
|
318
1167
|
*/
|
|
319
1168
|
async getUserRecent(userId, limit = 10) {
|
|
320
|
-
|
|
1169
|
+
logger4.debug("Getting user recent activity", { userId, limit });
|
|
321
1170
|
try {
|
|
322
1171
|
const result = await this.findAll({
|
|
323
1172
|
where: "user_id = ?",
|
|
@@ -351,7 +1200,11 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
351
1200
|
const ready = await this.ready();
|
|
352
1201
|
if (!ready.success)
|
|
353
1202
|
return ready;
|
|
354
|
-
|
|
1203
|
+
const minimum = await auditRetentionDays();
|
|
1204
|
+
if (!minimum.success)
|
|
1205
|
+
return minimum;
|
|
1206
|
+
olderThanDays = Math.max(olderThanDays, minimum.data);
|
|
1207
|
+
logger4.debug("Cleaning up old audit logs", { olderThanDays });
|
|
355
1208
|
const cutoff = /* @__PURE__ */ new Date();
|
|
356
1209
|
cutoff.setDate(cutoff.getDate() - olderThanDays);
|
|
357
1210
|
try {
|
|
@@ -360,7 +1213,7 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
360
1213
|
[this.toDateParam(cutoff)]
|
|
361
1214
|
);
|
|
362
1215
|
if (!result.success) {
|
|
363
|
-
|
|
1216
|
+
logger4.error("Failed to cleanup audit logs", { error: result.error.message });
|
|
364
1217
|
return err(
|
|
365
1218
|
HaiAuditError.CLEANUP_FAILED,
|
|
366
1219
|
auditM("audit_cleanupFailed", { params: { error: result.error.message } }),
|
|
@@ -368,10 +1221,10 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
368
1221
|
);
|
|
369
1222
|
}
|
|
370
1223
|
const deleted = result.data.changes;
|
|
371
|
-
|
|
1224
|
+
logger4.info("Audit logs cleaned up", { olderThanDays, deleted });
|
|
372
1225
|
return ok(deleted);
|
|
373
1226
|
} catch (error) {
|
|
374
|
-
|
|
1227
|
+
logger4.error("Failed to cleanup audit logs", { error });
|
|
375
1228
|
return err(
|
|
376
1229
|
HaiAuditError.CLEANUP_FAILED,
|
|
377
1230
|
auditM("audit_cleanupFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
|
|
@@ -385,24 +1238,25 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
385
1238
|
* @param days - 统计天数,默认 7
|
|
386
1239
|
* @returns 成功时返回 AuditStatItem 数组(按 count 倒序);失败时返回 STATS_FAILED
|
|
387
1240
|
*/
|
|
388
|
-
async getStats(days = 7) {
|
|
1241
|
+
async getStats(days = 7, filters) {
|
|
389
1242
|
const ready = await this.ready();
|
|
390
1243
|
if (!ready.success)
|
|
391
1244
|
return ready;
|
|
392
|
-
|
|
1245
|
+
logger4.debug("Getting audit statistics", { days });
|
|
393
1246
|
const cutoff = /* @__PURE__ */ new Date();
|
|
394
1247
|
cutoff.setDate(cutoff.getDate() - days);
|
|
1248
|
+
const { conditions, params } = this.filters({ ...filters, startDate: cutoff });
|
|
395
1249
|
try {
|
|
396
1250
|
const result = await this.sql().query(
|
|
397
1251
|
`SELECT action, COUNT(*) as count
|
|
398
|
-
FROM ${AUDIT_TABLE}
|
|
399
|
-
WHERE
|
|
1252
|
+
FROM ${AUDIT_TABLE} a
|
|
1253
|
+
WHERE ${conditions.join(" AND ")}
|
|
400
1254
|
GROUP BY action
|
|
401
1255
|
ORDER BY count DESC`,
|
|
402
|
-
|
|
1256
|
+
params
|
|
403
1257
|
);
|
|
404
1258
|
if (!result.success) {
|
|
405
|
-
|
|
1259
|
+
logger4.error("Failed to query audit statistics", { error: result.error.message });
|
|
406
1260
|
return err(
|
|
407
1261
|
HaiAuditError.STATS_FAILED,
|
|
408
1262
|
auditM("audit_statsFailed", { params: { error: result.error.message } }),
|
|
@@ -414,7 +1268,7 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
414
1268
|
return err(HaiAuditError.STATS_FAILED, auditM("audit_invalidStatCount"));
|
|
415
1269
|
return ok(statistics);
|
|
416
1270
|
} catch (error) {
|
|
417
|
-
|
|
1271
|
+
logger4.error("Failed to query audit statistics", { error });
|
|
418
1272
|
return err(
|
|
419
1273
|
HaiAuditError.STATS_FAILED,
|
|
420
1274
|
auditM("audit_statsFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
|
|
@@ -425,25 +1279,16 @@ var AuditLogRepository = class extends BaseReldbCrudRepository {
|
|
|
425
1279
|
};
|
|
426
1280
|
|
|
427
1281
|
// src/audit-main.ts
|
|
428
|
-
var
|
|
429
|
-
var MAX_TEXT_LENGTH = 256;
|
|
430
|
-
var REDACTED_AUDIT_DETAIL_VALUE = "[REDACTED]";
|
|
1282
|
+
var logger5 = core.logger.child({ module: "audit", scope: "main" });
|
|
431
1283
|
function isNonEmptyString(value) {
|
|
432
1284
|
return typeof value === "string" && value.trim().length > 0;
|
|
433
1285
|
}
|
|
434
|
-
function isValidTextLength(value, maxLength = MAX_TEXT_LENGTH) {
|
|
435
|
-
return value.length <= maxLength;
|
|
436
|
-
}
|
|
437
|
-
function sanitizeAuditDetails(details) {
|
|
438
|
-
if (!details) {
|
|
439
|
-
return details ?? null;
|
|
440
|
-
}
|
|
441
|
-
return core.sanitize.sanitizeSensitiveFields(details, {
|
|
442
|
-
matcher: core.sanitize.defaultSensitiveKeyRegex,
|
|
443
|
-
replacement: REDACTED_AUDIT_DETAIL_VALUE
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
1286
|
var currentRepo = null;
|
|
1287
|
+
var currentWriter = null;
|
|
1288
|
+
var capturePolicy = null;
|
|
1289
|
+
var currentBackup = null;
|
|
1290
|
+
var currentAlerts = null;
|
|
1291
|
+
var inFlightWrites = /* @__PURE__ */ new Set();
|
|
447
1292
|
var currentHelper = null;
|
|
448
1293
|
var initInProgress = false;
|
|
449
1294
|
var lifecycleGeneration = 0;
|
|
@@ -452,7 +1297,9 @@ var notInitialized = core.module.createNotInitializedKit(
|
|
|
452
1297
|
() => auditM("audit_notInitialized")
|
|
453
1298
|
);
|
|
454
1299
|
var notInitializedHelper = notInitialized.proxy();
|
|
1300
|
+
var notInitializedBackup = notInitialized.proxy();
|
|
455
1301
|
var audit = {
|
|
1302
|
+
withContext: withAuditContext,
|
|
456
1303
|
/**
|
|
457
1304
|
* 初始化审计模块
|
|
458
1305
|
*
|
|
@@ -473,7 +1320,7 @@ var audit = {
|
|
|
473
1320
|
*/
|
|
474
1321
|
async init(config) {
|
|
475
1322
|
if (initInProgress) {
|
|
476
|
-
|
|
1323
|
+
logger5.warn("Audit init already in progress, skipping concurrent call");
|
|
477
1324
|
return err(
|
|
478
1325
|
HaiAuditError.INIT_IN_PROGRESS,
|
|
479
1326
|
auditM("audit_initInProgress")
|
|
@@ -483,14 +1330,23 @@ var audit = {
|
|
|
483
1330
|
const generation = ++lifecycleGeneration;
|
|
484
1331
|
try {
|
|
485
1332
|
if (currentRepo) {
|
|
486
|
-
|
|
1333
|
+
logger5.warn("Audit module is already initialized, reinitializing");
|
|
487
1334
|
currentRepo = null;
|
|
1335
|
+
currentWriter = null;
|
|
1336
|
+
capturePolicy = null;
|
|
488
1337
|
currentHelper = null;
|
|
1338
|
+
const closingBackup = currentBackup;
|
|
1339
|
+
currentBackup = null;
|
|
1340
|
+
const closingAlerts = currentAlerts;
|
|
1341
|
+
currentAlerts = null;
|
|
1342
|
+
await closingAlerts?.close();
|
|
1343
|
+
await closingBackup?.close();
|
|
1344
|
+
await Promise.allSettled([...inFlightWrites]);
|
|
489
1345
|
}
|
|
490
|
-
|
|
1346
|
+
logger5.info("Initializing audit module");
|
|
491
1347
|
const parseResult = AuditInitConfigSchema.safeParse(config ?? {});
|
|
492
1348
|
if (!parseResult.success) {
|
|
493
|
-
|
|
1349
|
+
logger5.error("Audit config validation failed", { error: parseResult.error.message });
|
|
494
1350
|
return err(
|
|
495
1351
|
HaiAuditError.CONFIG_ERROR,
|
|
496
1352
|
auditM("audit_configError", { params: { error: parseResult.error.message } }),
|
|
@@ -498,15 +1354,24 @@ var audit = {
|
|
|
498
1354
|
);
|
|
499
1355
|
}
|
|
500
1356
|
const parsed = parseResult.data;
|
|
1357
|
+
if (parsed.alerts.enabled && (!config?.reach?.isInitialized || parsed.alerts.rules.some((rule) => rule.targets.some((target) => !config.reach?.config?.providers.some((provider) => provider.name === target.provider)))))
|
|
1358
|
+
return err(HaiAuditError.CONFIG_ERROR, auditM("audit_alertReachRequired"));
|
|
1359
|
+
if (parsed.backup.enabled && !config?.storage?.isInitialized)
|
|
1360
|
+
return err(HaiAuditError.CONFIG_ERROR, auditM("audit_backupStorageRequired"));
|
|
1361
|
+
if (parsed.backup.enabled && config?.storage?.config?.type === "s3" && config.storage.config.publicUrl)
|
|
1362
|
+
return err(HaiAuditError.CONFIG_ERROR, auditM("audit_backupPrivateStorageRequired"));
|
|
501
1363
|
const identifierResult = validateIdentifiers([parsed.userTable, parsed.userIdColumn, parsed.userNameColumn]);
|
|
502
1364
|
if (!identifierResult.success) {
|
|
503
|
-
|
|
1365
|
+
logger5.error("Audit config contains invalid identifiers", { error: identifierResult.error.message });
|
|
504
1366
|
return err(
|
|
505
1367
|
HaiAuditError.CONFIG_ERROR,
|
|
506
1368
|
auditM("audit_configError", { params: { error: identifierResult.error.message } }),
|
|
507
1369
|
identifierResult.error
|
|
508
1370
|
);
|
|
509
1371
|
}
|
|
1372
|
+
const migrated = await migrateAudit();
|
|
1373
|
+
if (!migrated.success)
|
|
1374
|
+
return migrated;
|
|
510
1375
|
const candidate = new AuditLogRepository({
|
|
511
1376
|
userTable: parsed.userTable,
|
|
512
1377
|
userIdColumn: parsed.userIdColumn,
|
|
@@ -516,14 +1381,24 @@ var audit = {
|
|
|
516
1381
|
if (!ready.success) {
|
|
517
1382
|
return err(HaiAuditError.CONFIG_ERROR, auditM("audit_initFailed", { params: { error: ready.error.message } }), ready.error);
|
|
518
1383
|
}
|
|
1384
|
+
const protection = await migrateAudit();
|
|
1385
|
+
if (!protection.success)
|
|
1386
|
+
return protection;
|
|
1387
|
+
const configured = await configureAuditRetention(parsed.retention);
|
|
1388
|
+
if (!configured.success)
|
|
1389
|
+
return configured;
|
|
519
1390
|
if (generation !== lifecycleGeneration)
|
|
520
1391
|
return err(HaiAuditError.NOT_INITIALIZED, auditM("audit_notInitialized"));
|
|
521
1392
|
currentRepo = candidate;
|
|
1393
|
+
currentWriter = createAuditWriter(candidate, parsed);
|
|
1394
|
+
capturePolicy = parsed.capture;
|
|
522
1395
|
currentHelper = createHelper((input) => audit.log(input));
|
|
523
|
-
|
|
1396
|
+
currentBackup = createAuditBackup({ repository: candidate, config: parsed.backup, storage: config?.storage, capture: (input, options) => audit.capture(input, options) });
|
|
1397
|
+
currentAlerts = createAuditAlerts({ config: parsed.alerts, reach: config?.reach, log: currentWriter });
|
|
1398
|
+
logger5.info("Audit module initialized");
|
|
524
1399
|
return ok(void 0);
|
|
525
1400
|
} catch (error) {
|
|
526
|
-
|
|
1401
|
+
logger5.error("Audit module initialization failed", { error });
|
|
527
1402
|
return err(
|
|
528
1403
|
HaiAuditError.CONFIG_ERROR,
|
|
529
1404
|
auditM("audit_initFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
|
|
@@ -543,27 +1418,22 @@ var audit = {
|
|
|
543
1418
|
* @param input - 日志内容(action 和 resource 为必填)
|
|
544
1419
|
* @returns 成功时返回创建的 AuditLog;未初始化时返回 NOT_INITIALIZED
|
|
545
1420
|
*/
|
|
546
|
-
log(input) {
|
|
1421
|
+
log(input, tx) {
|
|
547
1422
|
if (!currentRepo) {
|
|
548
1423
|
return Promise.resolve(notInitialized.result());
|
|
549
1424
|
}
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
HaiAuditError.LOG_FAILED,
|
|
563
|
-
auditM("audit_invalidInput", { params: { field: "action/resource", reason: "exceeds max length" } })
|
|
564
|
-
));
|
|
565
|
-
}
|
|
566
|
-
return currentRepo.log(sanitizedInput);
|
|
1425
|
+
const operation = currentWriter(input, tx);
|
|
1426
|
+
inFlightWrites.add(operation);
|
|
1427
|
+
void operation.then(() => inFlightWrites.delete(operation), () => inFlightWrites.delete(operation));
|
|
1428
|
+
return operation;
|
|
1429
|
+
},
|
|
1430
|
+
async capture(input, options) {
|
|
1431
|
+
if (!currentRepo)
|
|
1432
|
+
return notInitialized.result();
|
|
1433
|
+
if (!capturePolicy?.enabled || options?.read && !capturePolicy.includeReads && (!input.outcome || input.outcome === "success"))
|
|
1434
|
+
return ok(void 0);
|
|
1435
|
+
const written = await audit.log(input, options?.tx);
|
|
1436
|
+
return written.success ? ok(void 0) : written;
|
|
567
1437
|
},
|
|
568
1438
|
/**
|
|
569
1439
|
* 分页查询审计日志列表(含用户名 LEFT JOIN)
|
|
@@ -575,6 +1445,8 @@ var audit = {
|
|
|
575
1445
|
if (!currentRepo) {
|
|
576
1446
|
return Promise.resolve(notInitialized.result());
|
|
577
1447
|
}
|
|
1448
|
+
if (!validAuditQuery(options))
|
|
1449
|
+
return Promise.resolve(err(HaiAuditError.QUERY_FAILED, auditM("audit_invalidInput", { params: { field: "query", reason: auditM("audit_queryInvalid") } })));
|
|
578
1450
|
if (options?.startDate && options?.endDate && options.startDate > options.endDate) {
|
|
579
1451
|
return Promise.resolve(err(
|
|
580
1452
|
HaiAuditError.QUERY_FAILED,
|
|
@@ -632,17 +1504,19 @@ var audit = {
|
|
|
632
1504
|
* @param days - 统计天数,默认 7
|
|
633
1505
|
* @returns 成功时返回 AuditStatItem 数组;未初始化时返回 NOT_INITIALIZED
|
|
634
1506
|
*/
|
|
635
|
-
getStats(days) {
|
|
1507
|
+
getStats(days, filters) {
|
|
636
1508
|
if (!currentRepo) {
|
|
637
1509
|
return Promise.resolve(notInitialized.result());
|
|
638
1510
|
}
|
|
1511
|
+
if (!validAuditQuery(filters))
|
|
1512
|
+
return Promise.resolve(err(HaiAuditError.STATS_FAILED, auditM("audit_invalidInput", { params: { field: "query", reason: auditM("audit_queryInvalid") } })));
|
|
639
1513
|
if (typeof days === "number" && (!Number.isInteger(days) || days < 0)) {
|
|
640
1514
|
return Promise.resolve(err(
|
|
641
1515
|
HaiAuditError.STATS_FAILED,
|
|
642
1516
|
auditM("audit_invalidInput", { params: { field: "days", reason: "must be a non-negative integer" } })
|
|
643
1517
|
));
|
|
644
1518
|
}
|
|
645
|
-
return currentRepo.getStats(days);
|
|
1519
|
+
return currentRepo.getStats(days, filters);
|
|
646
1520
|
},
|
|
647
1521
|
/**
|
|
648
1522
|
* 便捷记录器
|
|
@@ -655,23 +1529,50 @@ var audit = {
|
|
|
655
1529
|
}
|
|
656
1530
|
return currentHelper;
|
|
657
1531
|
},
|
|
1532
|
+
get backup() {
|
|
1533
|
+
return currentBackup?.operations ?? notInitializedBackup;
|
|
1534
|
+
},
|
|
1535
|
+
checkAlerts(options) {
|
|
1536
|
+
return currentAlerts ? currentAlerts.check(options) : Promise.resolve(notInitialized.result());
|
|
1537
|
+
},
|
|
1538
|
+
get schedulerPlan() {
|
|
1539
|
+
const backup = currentBackup?.schedule;
|
|
1540
|
+
const alerts = currentAlerts?.schedule;
|
|
1541
|
+
if (!backup && !alerts)
|
|
1542
|
+
return null;
|
|
1543
|
+
return {
|
|
1544
|
+
tasks: [...backup?.tasks ?? [], ...alerts?.tasks ?? []].map((task) => ({ ...task })),
|
|
1545
|
+
hooks: { onTaskExecute: async (input) => {
|
|
1546
|
+
if (input.context.taskId === "hai-audit-backup" && backup)
|
|
1547
|
+
return backup.hooks.onTaskExecute(input);
|
|
1548
|
+
if (input.context.taskId === "hai-audit-alerts" && alerts)
|
|
1549
|
+
return alerts.hooks.onTaskExecute(input);
|
|
1550
|
+
throw core.error.buildHaiErrorInst(HaiAuditError.CONFIG_ERROR, auditM("audit_unknownTask"));
|
|
1551
|
+
} }
|
|
1552
|
+
};
|
|
1553
|
+
},
|
|
658
1554
|
/**
|
|
659
1555
|
* 关闭审计模块,释放内部状态
|
|
660
1556
|
*/
|
|
661
1557
|
async close() {
|
|
662
1558
|
lifecycleGeneration++;
|
|
663
|
-
|
|
664
|
-
logger2.info("Audit module already closed, skipping");
|
|
665
|
-
return ok(void 0);
|
|
666
|
-
}
|
|
667
|
-
logger2.info("Closing audit module");
|
|
1559
|
+
logger5.info("Closing audit module");
|
|
668
1560
|
currentRepo = null;
|
|
1561
|
+
currentWriter = null;
|
|
1562
|
+
capturePolicy = null;
|
|
669
1563
|
currentHelper = null;
|
|
670
|
-
|
|
1564
|
+
const closingBackup = currentBackup;
|
|
1565
|
+
currentBackup = null;
|
|
1566
|
+
const closingAlerts = currentAlerts;
|
|
1567
|
+
currentAlerts = null;
|
|
1568
|
+
await closingAlerts?.close();
|
|
1569
|
+
await closingBackup?.close();
|
|
1570
|
+
await Promise.allSettled([...inFlightWrites]);
|
|
1571
|
+
logger5.info("Audit module closed");
|
|
671
1572
|
return ok(void 0);
|
|
672
1573
|
}
|
|
673
1574
|
};
|
|
674
1575
|
|
|
675
|
-
export { AuditInitConfigSchema, HaiAuditError, audit };
|
|
1576
|
+
export { AUDIT_BACKUP_PREFIX, AuditInitConfigSchema, HaiAuditError, audit, migrateAudit };
|
|
676
1577
|
//# sourceMappingURL=index.js.map
|
|
677
1578
|
//# sourceMappingURL=index.js.map
|