sonamu 0.9.1 → 0.9.3
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/dist/api/config.d.ts +9 -2
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/sonamu.d.ts +1 -0
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +50 -2
- package/dist/auth/audit-log-ingestor.d.ts +9 -0
- package/dist/auth/audit-log-ingestor.d.ts.map +1 -0
- package/dist/auth/audit-log-ingestor.js +200 -0
- package/dist/auth/audit-log-proxy-types.d.ts +23 -0
- package/dist/auth/audit-log-proxy-types.d.ts.map +1 -0
- package/dist/auth/audit-log-proxy-types.js +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +4 -2
- package/dist/auth/plugins/entity-definitions/audit-log.d.ts +12 -0
- package/dist/auth/plugins/entity-definitions/audit-log.d.ts.map +1 -0
- package/dist/auth/plugins/entity-definitions/audit-log.js +291 -0
- package/dist/auth/plugins/entity-definitions/index.d.ts +1 -0
- package/dist/auth/plugins/entity-definitions/index.d.ts.map +1 -1
- package/dist/auth/plugins/entity-definitions/index.js +5 -3
- package/dist/auth/plugins/entity-definitions/types.d.ts +1 -1
- package/dist/auth/plugins/entity-definitions/types.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
- package/dist/auth/plugins/wrappers/sso.d.ts +1 -1
- package/dist/database/knex.d.ts.map +1 -1
- package/dist/database/knex.js +10 -18
- package/dist/entity/entity-manager.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/storage/buffered-file.d.ts +1 -1
- package/dist/storage/buffered-file.js +2 -2
- package/dist/types/types.d.ts +14 -14
- package/dist/ui-web/assets/index-Dr8pRJC_.css +1 -0
- package/dist/ui-web/assets/{index-C8qhvZbs.js → index-DrTfl0Ts.js} +49 -46
- package/dist/ui-web/index.html +2 -2
- package/package.json +4 -3
- package/src/api/config.ts +9 -2
- package/src/api/sonamu.ts +65 -1
- package/src/auth/audit-log-ingestor.ts +234 -0
- package/src/auth/audit-log-proxy-types.ts +23 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/plugins/entity-definitions/audit-log.ts +171 -0
- package/src/auth/plugins/entity-definitions/index.ts +3 -0
- package/src/auth/plugins/entity-definitions/types.ts +2 -1
- package/src/database/knex.ts +9 -19
- package/src/skills/sonamu/auth.md +17 -0
- package/src/skills/sonamu/create-sonamu.md +8 -0
- package/src/skills/sonamu/scaffolding.md +13 -0
- package/src/storage/buffered-file.ts +1 -1
- package/dist/ui-web/assets/index-DqgrO7Za.css +0 -1
package/dist/ui-web/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>{{projectName}}: Sonamu UI</title>
|
|
8
|
-
<script type="module" crossorigin src="/sonamu-ui/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/sonamu-ui/assets/index-DrTfl0Ts.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-Dr8pRJC_.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sonamu",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Sonamu — TypeScript Fullstack API Framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"@aws-sdk/lib-storage": "^3.971.0",
|
|
86
86
|
"@aws-sdk/s3-request-presigner": "^3.958.0",
|
|
87
87
|
"@better-auth/api-key": "~1.6.0",
|
|
88
|
+
"@better-auth/infra": "~0.1.14",
|
|
88
89
|
"@better-auth/passkey": "~1.6.0",
|
|
89
90
|
"@better-auth/sso": "~1.6.0",
|
|
90
91
|
"@faker-js/faker": "^9.2.0",
|
|
@@ -125,11 +126,11 @@
|
|
|
125
126
|
"qs": "^6.14.1",
|
|
126
127
|
"radashi": "^12.2.0",
|
|
127
128
|
"tsicli": "^1.0.5",
|
|
128
|
-
"vite": "8.0.
|
|
129
|
+
"vite": "8.0.5",
|
|
129
130
|
"vitest": "^4.1.2",
|
|
130
131
|
"@sonamu-kit/hmr-hook": "^0.5.1",
|
|
131
|
-
"@sonamu-kit/hmr-runner": "^0.2.0",
|
|
132
132
|
"@sonamu-kit/tasks": "^0.3.0",
|
|
133
|
+
"@sonamu-kit/hmr-runner": "^0.2.0",
|
|
133
134
|
"@sonamu-kit/ts-loader": "^2.2.0"
|
|
134
135
|
},
|
|
135
136
|
"devDependencies": {
|
package/src/api/config.ts
CHANGED
|
@@ -198,20 +198,27 @@ export type SonamuServerOptions = {
|
|
|
198
198
|
}
|
|
199
199
|
>;
|
|
200
200
|
};
|
|
201
|
+
/**
|
|
202
|
+
* AuditLog 활성화 여부
|
|
203
|
+
* true로 설정하면 Better Auth dash() 플러그인을 자동 주입하고
|
|
204
|
+
* /api/audit-log/events/track 엔드포인트에서 이벤트를 수신하여
|
|
205
|
+
* audit_events 테이블에 적재합니다.
|
|
206
|
+
* 사전에 `sonamu auth generate --plugins audit-log`로 엔티티를 생성해야 합니다.
|
|
207
|
+
*/
|
|
208
|
+
auditLog?: boolean;
|
|
201
209
|
};
|
|
202
210
|
|
|
203
211
|
apiConfig: SonamuFastifyConfig;
|
|
204
212
|
|
|
205
213
|
/**
|
|
206
214
|
* Storage 드라이버 설정.
|
|
207
|
-
*
|
|
215
|
+
* saveToDisk(diskName, key) 호출 시 드라이버를 명시적으로 지정합니다.
|
|
208
216
|
*
|
|
209
217
|
* @example
|
|
210
218
|
* ```typescript
|
|
211
219
|
* import { drivers } from "sonamu/storage";
|
|
212
220
|
*
|
|
213
221
|
* storage: {
|
|
214
|
-
* default: process.env.DRIVE_DISK ?? "fs",
|
|
215
222
|
* drivers: {
|
|
216
223
|
* fs: drivers.fs({ location: "./uploads", urlBuilder: { ... } }),
|
|
217
224
|
* s3: drivers.s3({ bucket: "my-bucket", region: "ap-northeast-2", ... }),
|
package/src/api/sonamu.ts
CHANGED
|
@@ -5,13 +5,15 @@ import { type IncomingMessage, type Server, type ServerResponse } from "http";
|
|
|
5
5
|
import os from "os";
|
|
6
6
|
import path from "path";
|
|
7
7
|
|
|
8
|
-
import { dispose as logtapeDispose } from "@logtape/logtape";
|
|
8
|
+
import { dispose as logtapeDispose, getLogger } from "@logtape/logtape";
|
|
9
9
|
import { type Auth, type BetterAuthOptions } from "better-auth";
|
|
10
10
|
import { type FSWatcher } from "chokidar";
|
|
11
11
|
import { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
12
12
|
import mime, { lookup as mimeLookup } from "mime-types";
|
|
13
13
|
import { type ZodObject } from "zod";
|
|
14
14
|
|
|
15
|
+
import { ingestAuditEvent } from "../auth/audit-log-ingestor";
|
|
16
|
+
import { type AuditLogEvent } from "../auth/audit-log-proxy-types";
|
|
15
17
|
import { BASE_FIELD_MAPPINGS } from "../auth/better-auth-entities";
|
|
16
18
|
import { applyCacheHeaders, CachePresets } from "../cache-control/cache-control";
|
|
17
19
|
import { type CacheControlConfig, type CacheControlRequest } from "../cache-control/types";
|
|
@@ -233,6 +235,15 @@ class SonamuClass {
|
|
|
233
235
|
// 사용자 설정과 기본값을 merge
|
|
234
236
|
const mergedFieldMappings = merge(BASE_FIELD_MAPPINGS, authConfig);
|
|
235
237
|
|
|
238
|
+
// auth.auditLog: true인 경우 dash() 플러그인 자동 주입
|
|
239
|
+
if (authConfig.auditLog) {
|
|
240
|
+
const { dash } = await import("@better-auth/infra");
|
|
241
|
+
const auditLogBasePath = "/api/audit-log";
|
|
242
|
+
const apiUrl = `${authConfig.baseURL}${auditLogBasePath}`;
|
|
243
|
+
const existingPlugins = mergedFieldMappings.plugins ?? [];
|
|
244
|
+
mergedFieldMappings.plugins = [...existingPlugins, dash({ apiUrl })];
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
// better-auth 인스턴스 생성
|
|
237
248
|
const { betterAuth } = await import("better-auth");
|
|
238
249
|
const { sonamuKnexAdapter } = await import("../auth/knex-adapter");
|
|
@@ -314,6 +325,10 @@ class SonamuClass {
|
|
|
314
325
|
await this.registerBetterAuth(server, options.auth);
|
|
315
326
|
}
|
|
316
327
|
|
|
328
|
+
if (options.auth?.auditLog) {
|
|
329
|
+
this.registerAuditLogProxy(server);
|
|
330
|
+
}
|
|
331
|
+
|
|
317
332
|
// API 라우팅 설정
|
|
318
333
|
await this.withFastify(server, options.apiConfig, {
|
|
319
334
|
enableSync: initOptions?.enableSync,
|
|
@@ -1259,6 +1274,20 @@ class SonamuClass {
|
|
|
1259
1274
|
handler: async (request, reply) => {
|
|
1260
1275
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
1261
1276
|
const headers = convertFastifyHeadersToStandard(request.headers);
|
|
1277
|
+
|
|
1278
|
+
// IP 헤더 fallback: 프록시가 표준 IP 헤더를 주입하지 않는 환경에서도
|
|
1279
|
+
// better-auth/infra의 getClientIpFromRequest()가 IP를 인식할 수 있도록
|
|
1280
|
+
// Fastify가 resolve한 request.ip를 x-real-ip로 주입한다.
|
|
1281
|
+
const IP_HEADERS = [
|
|
1282
|
+
"cf-connecting-ip",
|
|
1283
|
+
"x-forwarded-for",
|
|
1284
|
+
"x-real-ip",
|
|
1285
|
+
"x-vercel-forwarded-for",
|
|
1286
|
+
];
|
|
1287
|
+
if (request.ip && !IP_HEADERS.some((h) => headers.has(h))) {
|
|
1288
|
+
headers.set("x-real-ip", request.ip);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1262
1291
|
const req = new Request(url.toString(), {
|
|
1263
1292
|
method: request.method,
|
|
1264
1293
|
headers,
|
|
@@ -1276,6 +1305,38 @@ class SonamuClass {
|
|
|
1276
1305
|
});
|
|
1277
1306
|
}
|
|
1278
1307
|
|
|
1308
|
+
private registerAuditLogProxy(server: FastifyInstance) {
|
|
1309
|
+
const logger = getLogger(["sonamu", "audit-log"]);
|
|
1310
|
+
const basePath = "/api/audit-log";
|
|
1311
|
+
|
|
1312
|
+
server.route<{ Body: AuditLogEvent }>({
|
|
1313
|
+
method: "POST",
|
|
1314
|
+
url: `${basePath}/events/track`,
|
|
1315
|
+
handler: async (request, reply) => {
|
|
1316
|
+
const event = request.body;
|
|
1317
|
+
|
|
1318
|
+
logger.info(
|
|
1319
|
+
"Audit event received: {eventType} {eventKey} {eventDisplayName} from {ipAddress} ({country})",
|
|
1320
|
+
{
|
|
1321
|
+
eventType: event.eventType,
|
|
1322
|
+
eventKey: event.eventKey,
|
|
1323
|
+
eventDisplayName: event.eventDisplayName,
|
|
1324
|
+
ipAddress: event.ipAddress,
|
|
1325
|
+
country: event.country,
|
|
1326
|
+
},
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
await ingestAuditEvent(DB.getDB("w"), event);
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
logger.error("audit event ingest failed: {error}", { error: err });
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
return reply.status(200).send({ ok: true });
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1279
1340
|
private async printStartupSummary() {
|
|
1280
1341
|
const chalk = (await import("chalk")).default;
|
|
1281
1342
|
const env = process.env.NODE_ENV ?? "development";
|
|
@@ -1311,6 +1372,9 @@ class SonamuClass {
|
|
|
1311
1372
|
const basePath = this.config.server.auth.basePath ?? "/api/auth";
|
|
1312
1373
|
dim(`Auth: better-auth at ${basePath}/*`);
|
|
1313
1374
|
}
|
|
1375
|
+
if (this.config.server.auth?.auditLog) {
|
|
1376
|
+
dim(`AuditLog: proxy at /api/audit-log/events/track`);
|
|
1377
|
+
}
|
|
1314
1378
|
if (this.config.api.timezone) {
|
|
1315
1379
|
dim(`Timezone: ${this.config.api.timezone}`);
|
|
1316
1380
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { type Knex } from "knex";
|
|
4
|
+
|
|
5
|
+
import { type AuditLogEvent } from "./audit-log-proxy-types";
|
|
6
|
+
|
|
7
|
+
const AUDIT_EVENT_SOURCE = "better_auth";
|
|
8
|
+
const AUDIT_EVENT_SOURCE_VERSION = "better-auth|@better-auth/infra";
|
|
9
|
+
|
|
10
|
+
const SESSION_EVENT_TYPES = new Set<string>([
|
|
11
|
+
"user_signed_in",
|
|
12
|
+
"user_signed_out",
|
|
13
|
+
"user_sign_in_failed",
|
|
14
|
+
"session_created",
|
|
15
|
+
"session_revoked",
|
|
16
|
+
"all_sessions_revoked",
|
|
17
|
+
"user_impersonated",
|
|
18
|
+
"user_impersonated_stopped",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const ACCOUNT_EVENT_TYPES = new Set<string>([
|
|
22
|
+
"account_linked",
|
|
23
|
+
"account_unlinked",
|
|
24
|
+
"password_changed",
|
|
25
|
+
|
|
26
|
+
// @better-auth/infra@0.1.14 기준 EVENT_TYPES 상수에는 정의되어 있으나 실제 trackEvent() 호출이 없는 미구현 이벤트임
|
|
27
|
+
// 향후 버전에서 emit될 경우를 대비해 account로 임시 등록
|
|
28
|
+
"two_factor_enabled",
|
|
29
|
+
"two_factor_disabled",
|
|
30
|
+
"two_factor_verified",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const VERIFICATION_EVENT_TYPES = new Set<string>([
|
|
34
|
+
"email_verification_sent",
|
|
35
|
+
"password_reset_requested",
|
|
36
|
+
"password_reset_completed",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const SECURITY_EVENT_TYPES = new Set<string>([
|
|
40
|
+
"security_blocked",
|
|
41
|
+
"security_allowed",
|
|
42
|
+
"security_challenged",
|
|
43
|
+
"security_stale_account",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const USER_EVENT_TYPES = new Set<string>([
|
|
47
|
+
"user_created",
|
|
48
|
+
"profile_updated",
|
|
49
|
+
"profile_image_updated",
|
|
50
|
+
"email_verified",
|
|
51
|
+
"email_changed",
|
|
52
|
+
"user_banned",
|
|
53
|
+
"user_unbanned",
|
|
54
|
+
"user_deleted",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function pickString(source: Record<string, unknown>, key: string): string | null {
|
|
58
|
+
const value = source[key];
|
|
59
|
+
return typeof value === "string" ? value : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pickFirstString(source: Record<string, unknown>, keys: readonly string[]): string | null {
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
const value = pickString(source, key);
|
|
65
|
+
if (value !== null) {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseOccurredAt(raw: unknown): Date {
|
|
73
|
+
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
if (typeof raw === "string") {
|
|
77
|
+
const parsed = new Date(raw);
|
|
78
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return new Date();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function classifyCategory(
|
|
86
|
+
eventType: string,
|
|
87
|
+
): "user" | "session" | "account" | "verification" | "organization" | "security" {
|
|
88
|
+
if (eventType.startsWith("organization_")) {
|
|
89
|
+
return "organization";
|
|
90
|
+
}
|
|
91
|
+
if (SESSION_EVENT_TYPES.has(eventType)) {
|
|
92
|
+
return "session";
|
|
93
|
+
}
|
|
94
|
+
if (ACCOUNT_EVENT_TYPES.has(eventType)) {
|
|
95
|
+
return "account";
|
|
96
|
+
}
|
|
97
|
+
if (VERIFICATION_EVENT_TYPES.has(eventType)) {
|
|
98
|
+
return "verification";
|
|
99
|
+
}
|
|
100
|
+
if (SECURITY_EVENT_TYPES.has(eventType)) {
|
|
101
|
+
return "security";
|
|
102
|
+
}
|
|
103
|
+
if (USER_EVENT_TYPES.has(eventType)) {
|
|
104
|
+
return "user";
|
|
105
|
+
}
|
|
106
|
+
return "user";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function computeDedupeKey(parts: {
|
|
110
|
+
source: string;
|
|
111
|
+
event_type: string;
|
|
112
|
+
event_key: string;
|
|
113
|
+
actor_user_id: string | null;
|
|
114
|
+
subject_user_id: string | null;
|
|
115
|
+
organization_id: string | null;
|
|
116
|
+
team_id: string | null;
|
|
117
|
+
session_id: string | null;
|
|
118
|
+
identifier: string | null;
|
|
119
|
+
reason: string | null;
|
|
120
|
+
action: string | null;
|
|
121
|
+
occurred_at: Date;
|
|
122
|
+
}): string {
|
|
123
|
+
const norm = (v: string | null): string => v ?? "";
|
|
124
|
+
const raw = [
|
|
125
|
+
parts.source,
|
|
126
|
+
parts.event_type,
|
|
127
|
+
parts.event_key,
|
|
128
|
+
norm(parts.actor_user_id),
|
|
129
|
+
norm(parts.subject_user_id),
|
|
130
|
+
norm(parts.organization_id),
|
|
131
|
+
norm(parts.team_id),
|
|
132
|
+
norm(parts.session_id),
|
|
133
|
+
norm(parts.identifier),
|
|
134
|
+
norm(parts.reason),
|
|
135
|
+
norm(parts.action),
|
|
136
|
+
parts.occurred_at.toISOString(),
|
|
137
|
+
].join("|");
|
|
138
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Better Auth dash() 이벤트를 audit_events 테이블에 적재합니다.
|
|
143
|
+
* ON CONFLICT (dedupe_key) DO NOTHING으로 중복을 silent 무시합니다.
|
|
144
|
+
* auth.auditLog: true 설정 시 sonamu 내부에서 자동으로 호출됩니다.
|
|
145
|
+
*/
|
|
146
|
+
export async function ingestAuditEvent(db: Knex, event: AuditLogEvent): Promise<void> {
|
|
147
|
+
const eventData = event.eventData;
|
|
148
|
+
const occurred_at = parseOccurredAt(eventData["occurredAt"]);
|
|
149
|
+
|
|
150
|
+
const actor_user_id = pickString(eventData, "triggeredBy");
|
|
151
|
+
const subject_user_id = pickFirstString(eventData, [
|
|
152
|
+
"userId",
|
|
153
|
+
"userid",
|
|
154
|
+
"acceptedById",
|
|
155
|
+
"rejectedById",
|
|
156
|
+
]);
|
|
157
|
+
const organization_id = pickString(eventData, "organizationId");
|
|
158
|
+
const team_id = pickFirstString(eventData, ["teamId", "inviteeTeamId"]);
|
|
159
|
+
const session_id = pickString(eventData, "sessionId");
|
|
160
|
+
const provider_id = pickString(eventData, "providerId");
|
|
161
|
+
const login_method = pickString(eventData, "loginMethod");
|
|
162
|
+
const identifier = pickFirstString(eventData, [
|
|
163
|
+
"identifier",
|
|
164
|
+
"userEmail",
|
|
165
|
+
"memberEmail",
|
|
166
|
+
"inviteeEmail",
|
|
167
|
+
"acceptedByEmail",
|
|
168
|
+
"rejectedByEmail",
|
|
169
|
+
]);
|
|
170
|
+
const visitor_id = pickString(eventData, "visitorId");
|
|
171
|
+
const reason = pickFirstString(eventData, ["reason", "banReason"]);
|
|
172
|
+
const action = pickString(eventData, "action");
|
|
173
|
+
const trigger_context = pickString(eventData, "triggerContext");
|
|
174
|
+
const user_agent = pickString(eventData, "userAgent");
|
|
175
|
+
|
|
176
|
+
const ip_address = event.ipAddress ?? null;
|
|
177
|
+
const city = event.city ?? null;
|
|
178
|
+
const country = event.country ?? null;
|
|
179
|
+
const country_code = event.countryCode ?? null;
|
|
180
|
+
|
|
181
|
+
const category = classifyCategory(event.eventType);
|
|
182
|
+
const dedupe_key = computeDedupeKey({
|
|
183
|
+
source: AUDIT_EVENT_SOURCE,
|
|
184
|
+
event_type: event.eventType,
|
|
185
|
+
event_key: event.eventKey,
|
|
186
|
+
actor_user_id,
|
|
187
|
+
subject_user_id,
|
|
188
|
+
organization_id,
|
|
189
|
+
team_id,
|
|
190
|
+
session_id,
|
|
191
|
+
identifier,
|
|
192
|
+
reason,
|
|
193
|
+
action,
|
|
194
|
+
occurred_at,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ON CONFLICT DO NOTHING: dedupe_key UNIQUE 위반을 silent 무시.
|
|
198
|
+
// try/catch 방식은 PG 커넥션을 aborted 상태로 만드므로 사용하지 않는다.
|
|
199
|
+
const inserted = await db("audit_events")
|
|
200
|
+
.insert({
|
|
201
|
+
source: AUDIT_EVENT_SOURCE,
|
|
202
|
+
source_version: AUDIT_EVENT_SOURCE_VERSION,
|
|
203
|
+
category,
|
|
204
|
+
event_type: event.eventType,
|
|
205
|
+
event_key: event.eventKey,
|
|
206
|
+
dedupe_key,
|
|
207
|
+
actor_user_id,
|
|
208
|
+
subject_user_id,
|
|
209
|
+
organization_id,
|
|
210
|
+
team_id,
|
|
211
|
+
session_id,
|
|
212
|
+
provider_id,
|
|
213
|
+
login_method,
|
|
214
|
+
identifier,
|
|
215
|
+
visitor_id,
|
|
216
|
+
reason,
|
|
217
|
+
action,
|
|
218
|
+
trigger_context,
|
|
219
|
+
ip_address,
|
|
220
|
+
country_code,
|
|
221
|
+
country,
|
|
222
|
+
city,
|
|
223
|
+
user_agent,
|
|
224
|
+
payload_json: eventData,
|
|
225
|
+
occurred_at,
|
|
226
|
+
})
|
|
227
|
+
.onConflict("dedupe_key")
|
|
228
|
+
.ignore()
|
|
229
|
+
.returning("dedupe_key");
|
|
230
|
+
|
|
231
|
+
if (inserted.length === 0) {
|
|
232
|
+
return; // silent dedupe
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth dash() 플러그인이 POST ${apiUrl}/events/track으로 전송하는 이벤트 body 타입
|
|
3
|
+
*/
|
|
4
|
+
export type AuditLogEvent = {
|
|
5
|
+
eventType: string;
|
|
6
|
+
eventData: Record<string, unknown>;
|
|
7
|
+
eventKey: string;
|
|
8
|
+
eventDisplayName?: string;
|
|
9
|
+
ipAddress?: string;
|
|
10
|
+
city?: string;
|
|
11
|
+
country?: string;
|
|
12
|
+
countryCode?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* AuditLog 프록시 옵션
|
|
17
|
+
*/
|
|
18
|
+
export type AuditLogProxyOptions = {
|
|
19
|
+
/** 프록시 base path (기본값: "/api/audit-log") */
|
|
20
|
+
basePath?: string;
|
|
21
|
+
/** 이벤트 수신 시 콜백 (향후 DB 적재 확장 포인트) */
|
|
22
|
+
onEvent?: (event: AuditLogEvent) => void | Promise<void>;
|
|
23
|
+
};
|
package/src/auth/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { GenerateBetterAuthEntitiesOptions } from "./auth-generator";
|
|
2
2
|
export { generateBetterAuthEntities } from "./auth-generator";
|
|
3
|
+
export { ingestAuditEvent } from "./audit-log-ingestor";
|
|
3
4
|
export { BASE_FIELD_MAPPINGS, betterAuthV1 } from "./better-auth-entities";
|
|
4
5
|
export { sonamuKnexAdapter } from "./knex-adapter";
|
|
5
6
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { type BetterAuthEntityDef } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* better-auth AuditLog 플러그인 엔티티 정의
|
|
5
|
+
*
|
|
6
|
+
* auth.auditLog: true 로 활성화되며 audit_events 테이블을 생성합니다.
|
|
7
|
+
* - Better Auth dash() 플러그인이 전송하는 이벤트를 1건씩 적재합니다.
|
|
8
|
+
* - dedupe_key(sha256 hex)로 중복 적재를 방지합니다.
|
|
9
|
+
*
|
|
10
|
+
* 생성 방법: pnpm sonamu auth generate --plugins audit-log
|
|
11
|
+
*/
|
|
12
|
+
export const auditLogEntityDef: BetterAuthEntityDef = {
|
|
13
|
+
id: "audit-log",
|
|
14
|
+
name: "AuditLog",
|
|
15
|
+
entities: [
|
|
16
|
+
{
|
|
17
|
+
id: "AuditEvent",
|
|
18
|
+
table: "audit_events",
|
|
19
|
+
title: "감사이벤트",
|
|
20
|
+
props: [
|
|
21
|
+
{ name: "id", type: "integer", desc: "ID" },
|
|
22
|
+
{ name: "source", type: "string", length: 32, desc: "이벤트 소스" },
|
|
23
|
+
{ name: "source_version", type: "string", length: 96, nullable: true, desc: "소스 버전" },
|
|
24
|
+
{ name: "category", type: "enum", id: "AuditEventCategory", desc: "카테고리" },
|
|
25
|
+
{ name: "event_type", type: "string", length: 64, desc: "이벤트 타입" },
|
|
26
|
+
{ name: "event_key", type: "string", length: 191, desc: "이벤트 키" },
|
|
27
|
+
{ name: "dedupe_key", type: "string", length: 64, desc: "중복 제거 키" },
|
|
28
|
+
{
|
|
29
|
+
name: "actor_user_id",
|
|
30
|
+
type: "string",
|
|
31
|
+
length: 191,
|
|
32
|
+
nullable: true,
|
|
33
|
+
desc: "액터 사용자 ID",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "subject_user_id",
|
|
37
|
+
type: "string",
|
|
38
|
+
length: 191,
|
|
39
|
+
nullable: true,
|
|
40
|
+
desc: "대상 사용자 ID",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "organization_id",
|
|
44
|
+
type: "string",
|
|
45
|
+
length: 191,
|
|
46
|
+
nullable: true,
|
|
47
|
+
desc: "조직 ID",
|
|
48
|
+
},
|
|
49
|
+
{ name: "team_id", type: "string", length: 191, nullable: true, desc: "팀 ID" },
|
|
50
|
+
{ name: "session_id", type: "string", length: 191, nullable: true, desc: "세션 ID" },
|
|
51
|
+
{ name: "provider_id", type: "string", length: 64, nullable: true, desc: "프로바이더 ID" },
|
|
52
|
+
{ name: "login_method", type: "string", length: 64, nullable: true, desc: "로그인 방식" },
|
|
53
|
+
{ name: "identifier", type: "string", length: 255, nullable: true, desc: "식별자" },
|
|
54
|
+
{ name: "visitor_id", type: "string", length: 191, nullable: true, desc: "방문자 ID" },
|
|
55
|
+
{ name: "reason", type: "string", length: 128, nullable: true, desc: "사유" },
|
|
56
|
+
{ name: "action", type: "string", length: 64, nullable: true, desc: "액션" },
|
|
57
|
+
{
|
|
58
|
+
name: "trigger_context",
|
|
59
|
+
type: "string",
|
|
60
|
+
length: 64,
|
|
61
|
+
nullable: true,
|
|
62
|
+
desc: "트리거 컨텍스트",
|
|
63
|
+
},
|
|
64
|
+
{ name: "ip_address", type: "string", length: 45, nullable: true, desc: "IP 주소" },
|
|
65
|
+
{ name: "country_code", type: "string", length: 8, nullable: true, desc: "국가 코드" },
|
|
66
|
+
{ name: "country", type: "string", length: 100, nullable: true, desc: "국가" },
|
|
67
|
+
{ name: "city", type: "string", length: 100, nullable: true, desc: "도시" },
|
|
68
|
+
{ name: "user_agent", type: "string", nullable: true, desc: "User-Agent" },
|
|
69
|
+
{ name: "payload_json", type: "json", id: "AuditEventPayload", desc: "원본 payload" },
|
|
70
|
+
{ name: "occurred_at", type: "date", desc: "발생 시각" },
|
|
71
|
+
{
|
|
72
|
+
name: "ingested_at",
|
|
73
|
+
type: "date",
|
|
74
|
+
dbDefault: "CURRENT_TIMESTAMP",
|
|
75
|
+
desc: "적재 시각",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
indexes: [
|
|
79
|
+
{
|
|
80
|
+
type: "unique",
|
|
81
|
+
name: "audit_events_dedupe_key_unique",
|
|
82
|
+
columns: [{ name: "dedupe_key" }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "index",
|
|
86
|
+
name: "audit_events_occurred_at_index",
|
|
87
|
+
columns: [{ name: "occurred_at" }],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "index",
|
|
91
|
+
name: "audit_events_event_type_occurred_at_index",
|
|
92
|
+
columns: [{ name: "event_type" }, { name: "occurred_at" }],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: "index",
|
|
96
|
+
name: "audit_events_subject_user_id_occurred_at_index",
|
|
97
|
+
columns: [{ name: "subject_user_id" }, { name: "occurred_at" }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "index",
|
|
101
|
+
name: "audit_events_actor_user_id_occurred_at_index",
|
|
102
|
+
columns: [{ name: "actor_user_id" }, { name: "occurred_at" }],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: "index",
|
|
106
|
+
name: "audit_events_organization_id_occurred_at_index",
|
|
107
|
+
columns: [{ name: "organization_id" }, { name: "occurred_at" }],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: "index",
|
|
111
|
+
name: "audit_events_team_id_occurred_at_index",
|
|
112
|
+
columns: [{ name: "team_id" }, { name: "occurred_at" }],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: "index",
|
|
116
|
+
name: "audit_events_session_id_index",
|
|
117
|
+
columns: [{ name: "session_id" }],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "index",
|
|
121
|
+
name: "audit_events_reason_occurred_at_index",
|
|
122
|
+
columns: [{ name: "reason" }, { name: "occurred_at" }],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
subsets: {
|
|
126
|
+
A: [
|
|
127
|
+
"id",
|
|
128
|
+
"source",
|
|
129
|
+
"source_version",
|
|
130
|
+
"category",
|
|
131
|
+
"event_type",
|
|
132
|
+
"event_key",
|
|
133
|
+
"dedupe_key",
|
|
134
|
+
"actor_user_id",
|
|
135
|
+
"subject_user_id",
|
|
136
|
+
"organization_id",
|
|
137
|
+
"team_id",
|
|
138
|
+
"session_id",
|
|
139
|
+
"provider_id",
|
|
140
|
+
"login_method",
|
|
141
|
+
"identifier",
|
|
142
|
+
"visitor_id",
|
|
143
|
+
"reason",
|
|
144
|
+
"action",
|
|
145
|
+
"trigger_context",
|
|
146
|
+
"ip_address",
|
|
147
|
+
"country_code",
|
|
148
|
+
"country",
|
|
149
|
+
"city",
|
|
150
|
+
"user_agent",
|
|
151
|
+
"payload_json",
|
|
152
|
+
"occurred_at",
|
|
153
|
+
"ingested_at",
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
enums: {
|
|
157
|
+
AuditEventOrderBy: { "id-desc": "ID최신순" },
|
|
158
|
+
AuditEventSearchField: { id: "ID" },
|
|
159
|
+
AuditEventCategory: {
|
|
160
|
+
user: "사용자",
|
|
161
|
+
session: "세션",
|
|
162
|
+
account: "계정",
|
|
163
|
+
verification: "인증",
|
|
164
|
+
organization: "조직",
|
|
165
|
+
security: "보안",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
additionalProps: {},
|
|
171
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { adminEntityDef } from "./admin";
|
|
2
2
|
export { anonymousEntityDef } from "./anonymous";
|
|
3
3
|
export { apiKeyEntityDef } from "./api-key";
|
|
4
|
+
export { auditLogEntityDef } from "./audit-log";
|
|
4
5
|
export { jwtEntityDef } from "./jwt";
|
|
5
6
|
export { organizationEntityDef } from "./organization";
|
|
6
7
|
export { passkeyEntityDef } from "./passkey";
|
|
@@ -13,6 +14,7 @@ export { usernameEntityDef } from "./username";
|
|
|
13
14
|
import { adminEntityDef } from "./admin";
|
|
14
15
|
import { anonymousEntityDef } from "./anonymous";
|
|
15
16
|
import { apiKeyEntityDef } from "./api-key";
|
|
17
|
+
import { auditLogEntityDef } from "./audit-log";
|
|
16
18
|
import { jwtEntityDef } from "./jwt";
|
|
17
19
|
import { organizationEntityDef } from "./organization";
|
|
18
20
|
import { passkeyEntityDef } from "./passkey";
|
|
@@ -37,6 +39,7 @@ export const ENTITY_DEFINITIONS: Record<BetterAuthPluginId, BetterAuthEntityDef>
|
|
|
37
39
|
"api-key": apiKeyEntityDef,
|
|
38
40
|
jwt: jwtEntityDef,
|
|
39
41
|
anonymous: anonymousEntityDef,
|
|
42
|
+
"audit-log": auditLogEntityDef,
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
/**
|