@nebutra/audit 0.1.0 → 0.1.1

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.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @nebutra/audit
2
2
 
3
+ Status: **WIP**
4
+
3
5
  > **Status: WIP** — Not yet integrated into any production app. Do not import until this notice is removed.
4
6
 
5
7
  Audit logging for compliance and security.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Audit Logging System for Nebutra Services
3
+ *
4
+ * Records sensitive operations for:
5
+ * - Security compliance (SOC 2, ISO 27001)
6
+ * - Debugging
7
+ * - User activity tracking
8
+ * - Billing verification
9
+ *
10
+ * Exports:
11
+ * - This module: legacy `audit()` API + Prisma storage adapter (stable for
12
+ * internal callers; see `INTEGRATION_NOTES.md` for migration plan).
13
+ * - `@nebutra/audit/schema`: Zod schemas + `defineAction` + `ACTIONS`
14
+ * - `@nebutra/audit/middleware`: `auditLogger(req, ...)`, `withAudit(...)`
15
+ * - `@nebutra/audit/providers`: `getAuditProvider()`, provider classes
16
+ */
17
+ export { type AuditLoggerDefaults, type AuditLoggerLogInput, type AuditRequestContext, auditLogger, type BoundAuditLogger, extractRequestContext, type WithAuditOptions, withAudit, } from "./middleware";
18
+ export { type AuditFactoryConfig, type AuditProvider, type AuditProviderType, ClickHouseAuditProvider, createAuditProvider, getAuditProvider, MemoryAuditProvider, PostgresAuditProvider, } from "./providers";
19
+ export * from "./schema";
20
+ export type AuditAction = "user.login" | "user.logout" | "user.signup" | "user.password_change" | "user.email_change" | "user.delete" | "org.create" | "org.update" | "org.delete" | "org.member_add" | "org.member_remove" | "org.role_change" | "billing.subscription_create" | "billing.subscription_update" | "billing.subscription_cancel" | "billing.payment_success" | "billing.payment_failed" | "api.key_create" | "api.key_revoke" | "data.export" | "data.delete" | "admin.impersonate" | "admin.settings_change" | "custom";
21
+ /**
22
+ * Legacy audit event shape.
23
+ *
24
+ * Prefer the Zod-validated `AuditEvent` from `@nebutra/audit/schema` for new
25
+ * code. This interface is preserved for existing call sites and the legacy
26
+ * `audit()` helper. See `INTEGRATION_NOTES.md` for migration guidance.
27
+ */
28
+ export interface LegacyAuditEvent {
29
+ id?: string;
30
+ action: AuditAction;
31
+ actorId: string;
32
+ actorType: "user" | "system" | "api_key" | "admin";
33
+ tenantId?: string;
34
+ targetType?: string;
35
+ targetId?: string;
36
+ metadata?: Record<string, unknown>;
37
+ ipAddress?: string;
38
+ userAgent?: string;
39
+ timestamp?: Date;
40
+ outcome: "success" | "failure" | "pending";
41
+ reason?: string;
42
+ }
43
+ export interface AuditStorage {
44
+ store: (event: LegacyAuditEvent) => Promise<void>;
45
+ query: (filter: LegacyAuditQueryFilter) => Promise<LegacyAuditEvent[]>;
46
+ }
47
+ interface PrismaAuditLogClient {
48
+ auditLog: {
49
+ create: (data: {
50
+ data: unknown;
51
+ }) => Promise<unknown>;
52
+ findMany: (args: unknown) => Promise<unknown[]>;
53
+ };
54
+ }
55
+ export interface LegacyAuditQueryFilter {
56
+ tenantId?: string;
57
+ actorId?: string;
58
+ action?: AuditAction;
59
+ targetType?: string;
60
+ targetId?: string;
61
+ startDate?: Date;
62
+ endDate?: Date;
63
+ limit?: number;
64
+ offset?: number;
65
+ }
66
+ /** @internal — exposed for tests so they can reset the legacy in-memory buffer. */
67
+ export declare function __resetLegacyMemoryStorage(): void;
68
+ export declare const inMemoryStorage: AuditStorage;
69
+ export declare function createPrismaStorage(prisma: PrismaAuditLogClient): AuditStorage;
70
+ export declare function setAuditStorage(newStorage: AuditStorage): void;
71
+ export declare function audit(event: Omit<LegacyAuditEvent, "id" | "timestamp">): Promise<void>;
72
+ export declare function queryAuditLogs(filter: LegacyAuditQueryFilter): Promise<LegacyAuditEvent[]>;
73
+ export declare function auditUserLogin(userId: string, tenantId: string, success: boolean, ipAddress?: string, userAgent?: string): Promise<void>;
74
+ export declare function auditUserLogout(userId: string, tenantId: string): Promise<void>;
75
+ export declare function auditRoleChange(adminId: string, tenantId: string, targetUserId: string, oldRole: string, newRole: string): Promise<void>;
76
+ export declare function auditBillingEvent(tenantId: string, action: "billing.subscription_create" | "billing.subscription_update" | "billing.subscription_cancel" | "billing.payment_success" | "billing.payment_failed", metadata: Record<string, unknown>): Promise<void>;
77
+ export declare function auditApiKeyCreate(userId: string, tenantId: string, keyId: string, keyName: string): Promise<void>;
78
+ export declare function auditDataExport(userId: string, tenantId: string, exportType: string, metadata?: Record<string, unknown>): Promise<void>;
79
+ export declare function auditMiddleware(): (c: {
80
+ req: {
81
+ header: (name: string) => string | undefined;
82
+ };
83
+ set: (key: string, value: unknown) => void;
84
+ }, next: () => Promise<void>) => Promise<void>;
85
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Audit Logging System for Nebutra Services
3
+ *
4
+ * Records sensitive operations for:
5
+ * - Security compliance (SOC 2, ISO 27001)
6
+ * - Debugging
7
+ * - User activity tracking
8
+ * - Billing verification
9
+ *
10
+ * Exports:
11
+ * - This module: legacy `audit()` API + Prisma storage adapter (stable for
12
+ * internal callers; see `INTEGRATION_NOTES.md` for migration plan).
13
+ * - `@nebutra/audit/schema`: Zod schemas + `defineAction` + `ACTIONS`
14
+ * - `@nebutra/audit/middleware`: `auditLogger(req, ...)`, `withAudit(...)`
15
+ * - `@nebutra/audit/providers`: `getAuditProvider()`, provider classes
16
+ */
17
+ import { getSystemDb } from "@nebutra/db";
18
+ import { logger } from "@nebutra/logger";
19
+ export { auditLogger, extractRequestContext, withAudit, } from "./middleware";
20
+ export { ClickHouseAuditProvider, createAuditProvider, getAuditProvider, MemoryAuditProvider, PostgresAuditProvider, } from "./providers";
21
+ // Re-exports for the new schema/provider/middleware surface.
22
+ export * from "./schema";
23
+ function parseAuditMetadata(metadata) {
24
+ if (!metadata)
25
+ return undefined;
26
+ if (typeof metadata !== "string")
27
+ return metadata;
28
+ try {
29
+ const parsed = JSON.parse(metadata);
30
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
31
+ ? parsed
32
+ : { value: parsed };
33
+ }
34
+ catch {
35
+ return { value: metadata };
36
+ }
37
+ }
38
+ function mapPrismaAuditRow(row) {
39
+ const metadata = parseAuditMetadata(row.metadata);
40
+ return {
41
+ ...(row.id ? { id: row.id } : {}),
42
+ action: row.action,
43
+ actorId: row.userId,
44
+ actorType: row.actorType,
45
+ ...(row.organizationId ? { tenantId: row.organizationId } : {}),
46
+ ...(row.entityType ? { targetType: row.entityType } : {}),
47
+ ...(row.entityId ? { targetId: row.entityId } : {}),
48
+ ...(metadata ? { metadata } : {}),
49
+ ...(row.ipAddress ? { ipAddress: row.ipAddress } : {}),
50
+ ...(row.userAgent ? { userAgent: row.userAgent } : {}),
51
+ ...(row.createdAt ? { timestamp: row.createdAt } : {}),
52
+ outcome: row.outcome,
53
+ ...(row.reason ? { reason: row.reason } : {}),
54
+ };
55
+ }
56
+ // ============================================
57
+ // In-Memory Storage (for development)
58
+ // ============================================
59
+ const memoryStorage = [];
60
+ /** @internal — exposed for tests so they can reset the legacy in-memory buffer. */
61
+ export function __resetLegacyMemoryStorage() {
62
+ memoryStorage.length = 0;
63
+ }
64
+ export const inMemoryStorage = {
65
+ store: async (event) => {
66
+ memoryStorage.push({
67
+ ...event,
68
+ id: event.id || crypto.randomUUID(),
69
+ timestamp: event.timestamp || new Date(),
70
+ });
71
+ },
72
+ query: async (filter) => {
73
+ // Walk newest-to-oldest insertion order so that callers reading `logs[0]`
74
+ // always see the most recently inserted match, even when timestamps tie at
75
+ // millisecond resolution.
76
+ let results = [...memoryStorage].reverse();
77
+ if (filter.tenantId) {
78
+ results = results.filter((e) => e.tenantId === filter.tenantId);
79
+ }
80
+ if (filter.actorId) {
81
+ results = results.filter((e) => e.actorId === filter.actorId);
82
+ }
83
+ if (filter.action) {
84
+ results = results.filter((e) => e.action === filter.action);
85
+ }
86
+ if (filter.targetType) {
87
+ results = results.filter((e) => e.targetType === filter.targetType);
88
+ }
89
+ if (filter.targetId) {
90
+ results = results.filter((e) => e.targetId === filter.targetId);
91
+ }
92
+ if (filter.startDate) {
93
+ results = results.filter((e) => e.timestamp && filter.startDate && e.timestamp >= filter.startDate);
94
+ }
95
+ if (filter.endDate) {
96
+ results = results.filter((e) => e.timestamp && filter.endDate && e.timestamp <= filter.endDate);
97
+ }
98
+ // Sort by timestamp descending; with stable sort, ties retain the
99
+ // insertion-order-reversed sequence above (newest first).
100
+ results.sort((a, b) => {
101
+ const timeA = a.timestamp?.getTime() || 0;
102
+ const timeB = b.timestamp?.getTime() || 0;
103
+ return timeB - timeA;
104
+ });
105
+ // Apply pagination
106
+ const offset = filter.offset || 0;
107
+ const limit = filter.limit || 100;
108
+ return results.slice(offset, offset + limit);
109
+ },
110
+ };
111
+ // ============================================
112
+ // Database Storage (using Prisma)
113
+ // ============================================
114
+ export function createPrismaStorage(prisma) {
115
+ return {
116
+ store: async (event) => {
117
+ // Field mapping: AuditEvent interface → Prisma AuditLog columns
118
+ // actorId → userId (Prisma model uses userId for the actor)
119
+ // tenantId → organizationId (Prisma model uses organizationId)
120
+ // targetType → entityType (Prisma model uses entity* naming)
121
+ // targetId → entityId
122
+ // actorType, outcome, reason → added in migration 20260316000000
123
+ await prisma.auditLog.create({
124
+ data: {
125
+ id: event.id || crypto.randomUUID(),
126
+ action: event.action,
127
+ userId: event.actorId,
128
+ actorType: event.actorType,
129
+ organizationId: event.tenantId,
130
+ entityType: event.targetType ?? "unknown",
131
+ entityId: event.targetId,
132
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null,
133
+ ipAddress: event.ipAddress,
134
+ userAgent: event.userAgent,
135
+ outcome: event.outcome,
136
+ reason: event.reason,
137
+ createdAt: event.timestamp || new Date(),
138
+ },
139
+ });
140
+ },
141
+ query: async (filter) => {
142
+ const where = {};
143
+ if (filter.tenantId)
144
+ where.organizationId = filter.tenantId;
145
+ if (filter.actorId)
146
+ where.userId = filter.actorId;
147
+ if (filter.action)
148
+ where.action = filter.action;
149
+ if (filter.targetType)
150
+ where.entityType = filter.targetType;
151
+ if (filter.targetId)
152
+ where.entityId = filter.targetId;
153
+ if (filter.startDate || filter.endDate) {
154
+ where.createdAt = {};
155
+ if (filter.startDate)
156
+ where.createdAt.gte = filter.startDate;
157
+ if (filter.endDate)
158
+ where.createdAt.lte = filter.endDate;
159
+ }
160
+ const results = await prisma.auditLog.findMany({
161
+ where,
162
+ orderBy: { createdAt: "desc" },
163
+ take: filter.limit || 100,
164
+ skip: filter.offset || 0,
165
+ });
166
+ return results.map(mapPrismaAuditRow);
167
+ },
168
+ };
169
+ }
170
+ // ============================================
171
+ // Audit Logger
172
+ // ============================================
173
+ let storage = inMemoryStorage;
174
+ // Attach Prisma DB storage.
175
+ // AUDIT(no-tenant): audit logs are written for every tenant through a single
176
+ // shared storage adapter. Each AuditEvent carries its own organizationId/
177
+ // tenantId, so cross-tenant writes here are intentional.
178
+ try {
179
+ setAuditStorage(createPrismaStorage(getSystemDb()));
180
+ }
181
+ catch (e) {
182
+ logger.warn("Prisma storage adapter failed to initialize, relying on in-memory audit logs", {
183
+ error: e,
184
+ });
185
+ }
186
+ export function setAuditStorage(newStorage) {
187
+ storage = newStorage;
188
+ }
189
+ export async function audit(event) {
190
+ const fullEvent = {
191
+ ...event,
192
+ id: crypto.randomUUID(),
193
+ timestamp: new Date(),
194
+ };
195
+ try {
196
+ await storage.store(fullEvent);
197
+ }
198
+ catch (error) {
199
+ // Log via structured logger as fallback, but don't throw
200
+ logger.error("Audit log storage failed", error, { event: fullEvent });
201
+ }
202
+ }
203
+ export async function queryAuditLogs(filter) {
204
+ return storage.query(filter);
205
+ }
206
+ // ============================================
207
+ // Convenience Functions
208
+ // ============================================
209
+ export function auditUserLogin(userId, tenantId, success, ipAddress, userAgent) {
210
+ return audit({
211
+ action: "user.login",
212
+ actorId: userId,
213
+ actorType: "user",
214
+ tenantId,
215
+ outcome: success ? "success" : "failure",
216
+ ...(ipAddress && { ipAddress }),
217
+ ...(userAgent && { userAgent }),
218
+ });
219
+ }
220
+ export function auditUserLogout(userId, tenantId) {
221
+ return audit({
222
+ action: "user.logout",
223
+ actorId: userId,
224
+ actorType: "user",
225
+ tenantId,
226
+ outcome: "success",
227
+ });
228
+ }
229
+ export function auditRoleChange(adminId, tenantId, targetUserId, oldRole, newRole) {
230
+ return audit({
231
+ action: "org.role_change",
232
+ actorId: adminId,
233
+ actorType: "admin",
234
+ tenantId,
235
+ targetType: "user",
236
+ targetId: targetUserId,
237
+ outcome: "success",
238
+ metadata: { oldRole, newRole },
239
+ });
240
+ }
241
+ export function auditBillingEvent(tenantId, action, metadata) {
242
+ return audit({
243
+ action,
244
+ actorId: "system",
245
+ actorType: "system",
246
+ tenantId,
247
+ outcome: action.includes("failed") ? "failure" : "success",
248
+ metadata,
249
+ });
250
+ }
251
+ export function auditApiKeyCreate(userId, tenantId, keyId, keyName) {
252
+ return audit({
253
+ action: "api.key_create",
254
+ actorId: userId,
255
+ actorType: "user",
256
+ tenantId,
257
+ targetType: "api_key",
258
+ targetId: keyId,
259
+ outcome: "success",
260
+ metadata: { keyName },
261
+ });
262
+ }
263
+ export function auditDataExport(userId, tenantId, exportType, metadata) {
264
+ return audit({
265
+ action: "data.export",
266
+ actorId: userId,
267
+ actorType: "user",
268
+ tenantId,
269
+ outcome: "success",
270
+ metadata: { exportType, ...metadata },
271
+ });
272
+ }
273
+ // ============================================
274
+ // Middleware for Hono
275
+ // ============================================
276
+ export function auditMiddleware() {
277
+ return async (c, next) => {
278
+ // Store audit context
279
+ const ipAddress = c.req.header("x-forwarded-for") || c.req.header("x-real-ip");
280
+ const userAgent = c.req.header("user-agent");
281
+ c.set("auditContext", {
282
+ tenantId: c.req.header("x-tenant-id"),
283
+ ...(ipAddress && { ipAddress }),
284
+ ...(userAgent && { userAgent }),
285
+ });
286
+ await next();
287
+ };
288
+ }
@@ -0,0 +1,75 @@
1
+ import { type ActorType, type AuditEvent, type Outcome, type Resource } from "./schema";
2
+ interface HeaderLike {
3
+ get(name: string): string | null;
4
+ }
5
+ interface RequestLike {
6
+ headers: HeaderLike;
7
+ }
8
+ export interface AuditRequestContext {
9
+ ip?: string;
10
+ userAgent?: string;
11
+ requestId?: string;
12
+ }
13
+ export declare function extractRequestContext(req: RequestLike | undefined): AuditRequestContext;
14
+ export interface AuditLoggerDefaults {
15
+ actor: {
16
+ id: string;
17
+ type: ActorType;
18
+ email?: string;
19
+ name?: string;
20
+ };
21
+ tenantId: string;
22
+ resource?: Resource;
23
+ }
24
+ export interface AuditLoggerLogInput {
25
+ action: string;
26
+ outcome: Outcome;
27
+ resource?: Resource;
28
+ severity?: AuditEvent["severity"];
29
+ changes?: AuditEvent["changes"];
30
+ metadata?: Record<string, unknown>;
31
+ }
32
+ export interface BoundAuditLogger {
33
+ log(input: AuditLoggerLogInput): Promise<void>;
34
+ }
35
+ /**
36
+ * Build a request-scoped audit logger. The returned object pre-populates
37
+ * actor/tenant/ip/userAgent fields so call sites only need to supply the
38
+ * action-specific data.
39
+ */
40
+ export declare function auditLogger(req: RequestLike | undefined, defaults: AuditLoggerDefaults): BoundAuditLogger;
41
+ export interface WithAuditOptions {
42
+ action: string;
43
+ /** A function that derives actor/tenant/resource from the incoming request. */
44
+ resolveContext: (req: Request) => Promise<AuditLoggerDefaults | null> | AuditLoggerDefaults | null;
45
+ /** A function that derives the resource from the request (after handler success). */
46
+ resolveResource?: (req: Request, response: Response) => Promise<Resource | undefined> | Resource | undefined;
47
+ severity?: AuditEvent["severity"];
48
+ }
49
+ /**
50
+ * Wrap a Next.js Route Handler with automatic audit logging. The handler is
51
+ * invoked exactly once; its outcome is observed and recorded.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * export const POST = withAudit(
56
+ * {
57
+ * action: "api_key.created",
58
+ * resolveContext: async (req) => {
59
+ * const auth = await getAuth(req);
60
+ * if (!auth.userId || !auth.orgId) return null;
61
+ * return {
62
+ * actor: { id: auth.userId, type: "user" },
63
+ * tenantId: auth.orgId,
64
+ * };
65
+ * },
66
+ * },
67
+ * async (req) => {
68
+ * // ... existing handler body
69
+ * },
70
+ * );
71
+ * ```
72
+ */
73
+ export declare function withAudit(options: WithAuditOptions, handler: (req: Request) => Promise<Response>): (req: Request) => Promise<Response>;
74
+ export {};
75
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAqBA,OAAO,EACL,KAAK,SAAS,EACd,KAAK,UAAU,EAEf,KAAK,OAAO,EACZ,KAAK,QAAQ,EACd,MAAM,UAAU,CAAC;AAMlB,UAAU,UAAU;IAClB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,UAAU,WAAW;IACnB,OAAO,EAAE,UAAU,CAAC;CACrB;AAQD,MAAM,WAAW,mBAAmB;IAClC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,WAAW,GAAG,SAAS,GAAG,mBAAmB,CAevF;AAMD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,SAAS,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,QAAQ,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC;IAClC,OAAO,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,WAAW,GAAG,SAAS,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,gBAAgB,CA+BlB;AAMD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,cAAc,EAAE,CACd,GAAG,EAAE,OAAO,KACT,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,GAAG,mBAAmB,GAAG,IAAI,CAAC;IACtE,qFAAqF;IACrF,eAAe,CAAC,EAAE,CAChB,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,QAAQ,KACf,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC1D,QAAQ,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,gBAAgB,EACzB,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAC3C,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAiErC"}
@@ -0,0 +1,197 @@
1
+ // =============================================================================
2
+ // Request-bound audit helpers
3
+ // =============================================================================
4
+ // Two ergonomic entry points for application code:
5
+ //
6
+ // auditLogger(req, defaults)
7
+ // Returns a thin context-bound logger pre-populated with actor, tenant, IP,
8
+ // userAgent, and requestId derived from a Request/Headers. Application
9
+ // code calls `await audit.log({ action, resource, outcome, ... })`.
10
+ //
11
+ // withAudit({ action, resource, ... }, handler)
12
+ // Wraps a Next.js Route Handler — runs the handler and emits a
13
+ // success/failure audit event around it. Handler exceptions are re-thrown
14
+ // after logging so callers' error handling is preserved.
15
+ //
16
+ // Both helpers are provider-agnostic: they go through getAuditProvider() and
17
+ // honor the AUDIT_PROVIDER env var.
18
+ // =============================================================================
19
+ import { logger } from "@nebutra/logger";
20
+ import { getAuditProvider } from "./providers";
21
+ import { AuditEventInputSchema, } from "./schema";
22
+ function readHeader(req, name) {
23
+ if (!req)
24
+ return undefined;
25
+ const value = req.headers.get(name);
26
+ return value ?? undefined;
27
+ }
28
+ export function extractRequestContext(req) {
29
+ if (!req)
30
+ return {};
31
+ const xff = readHeader(req, "x-forwarded-for");
32
+ const ip = xff?.split(",")[0]?.trim() ?? readHeader(req, "x-real-ip");
33
+ const userAgent = readHeader(req, "user-agent");
34
+ const requestId = readHeader(req, "x-request-id") ??
35
+ readHeader(req, "x-vercel-id") ??
36
+ readHeader(req, "x-amzn-trace-id");
37
+ return {
38
+ ...(ip ? { ip } : {}),
39
+ ...(userAgent ? { userAgent } : {}),
40
+ ...(requestId ? { requestId } : {}),
41
+ };
42
+ }
43
+ /**
44
+ * Build a request-scoped audit logger. The returned object pre-populates
45
+ * actor/tenant/ip/userAgent fields so call sites only need to supply the
46
+ * action-specific data.
47
+ */
48
+ export function auditLogger(req, defaults) {
49
+ const context = extractRequestContext(req);
50
+ return {
51
+ async log(input) {
52
+ const resource = input.resource ?? defaults.resource;
53
+ if (!resource) {
54
+ // Defensive: a missing resource means the call site forgot to specify
55
+ // what was acted upon. Log via @nebutra/logger so this never silently
56
+ // breaks audit coverage.
57
+ logger.warn("[audit] auditLogger.log called without resource — event dropped", {
58
+ action: input.action,
59
+ });
60
+ return;
61
+ }
62
+ const eventInput = {
63
+ actor: defaults.actor,
64
+ tenantId: defaults.tenantId,
65
+ action: input.action,
66
+ outcome: input.outcome,
67
+ resource,
68
+ severity: input.severity ?? "info",
69
+ context,
70
+ ...(input.changes ? { changes: input.changes } : {}),
71
+ ...(input.metadata ? { metadata: input.metadata } : {}),
72
+ };
73
+ await emitAuditEvent(eventInput);
74
+ },
75
+ };
76
+ }
77
+ /**
78
+ * Wrap a Next.js Route Handler with automatic audit logging. The handler is
79
+ * invoked exactly once; its outcome is observed and recorded.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * export const POST = withAudit(
84
+ * {
85
+ * action: "api_key.created",
86
+ * resolveContext: async (req) => {
87
+ * const auth = await getAuth(req);
88
+ * if (!auth.userId || !auth.orgId) return null;
89
+ * return {
90
+ * actor: { id: auth.userId, type: "user" },
91
+ * tenantId: auth.orgId,
92
+ * };
93
+ * },
94
+ * },
95
+ * async (req) => {
96
+ * // ... existing handler body
97
+ * },
98
+ * );
99
+ * ```
100
+ */
101
+ export function withAudit(options, handler) {
102
+ return async (req) => {
103
+ const context = extractRequestContext(req);
104
+ let defaults = null;
105
+ try {
106
+ defaults = await options.resolveContext(req);
107
+ }
108
+ catch (error) {
109
+ logger.warn("[audit] withAudit.resolveContext threw — skipping audit", {
110
+ action: options.action,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ });
113
+ }
114
+ let response = null;
115
+ let handlerError = null;
116
+ try {
117
+ response = await handler(req);
118
+ }
119
+ catch (error) {
120
+ handlerError = error;
121
+ }
122
+ if (defaults) {
123
+ // Determine outcome:
124
+ // - handler threw → "failure"
125
+ // - response 4xx (auth/perm) → "denied"
126
+ // - response 5xx → "failure"
127
+ // - else → "success"
128
+ let outcome = "success";
129
+ if (handlerError || !response) {
130
+ outcome = "failure";
131
+ }
132
+ else {
133
+ const status = response.status;
134
+ if (status === 401 || status === 403)
135
+ outcome = "denied";
136
+ else if (status >= 500)
137
+ outcome = "failure";
138
+ }
139
+ let resource = defaults.resource ?? {
140
+ type: "request",
141
+ id: req.url,
142
+ };
143
+ if (options.resolveResource && response && !handlerError) {
144
+ try {
145
+ const resolved = await options.resolveResource(req, response);
146
+ if (resolved)
147
+ resource = resolved;
148
+ }
149
+ catch {
150
+ // Keep the default — resource resolution is best-effort.
151
+ }
152
+ }
153
+ await emitAuditEvent({
154
+ actor: defaults.actor,
155
+ tenantId: defaults.tenantId,
156
+ action: options.action,
157
+ outcome,
158
+ resource,
159
+ severity: options.severity ?? "info",
160
+ context,
161
+ });
162
+ }
163
+ if (handlerError)
164
+ throw handlerError;
165
+ // After re-throwing on error, `response` is guaranteed non-null here.
166
+ return response;
167
+ };
168
+ }
169
+ // -----------------------------------------------------------------------------
170
+ // Internal: validate + dispatch
171
+ // -----------------------------------------------------------------------------
172
+ async function emitAuditEvent(input) {
173
+ try {
174
+ const parsed = AuditEventInputSchema.parse(input);
175
+ const event = {
176
+ id: parsed.id ?? crypto.randomUUID(),
177
+ timestamp: parsed.timestamp ?? new Date().toISOString(),
178
+ actor: parsed.actor,
179
+ tenantId: parsed.tenantId,
180
+ action: parsed.action,
181
+ resource: parsed.resource,
182
+ outcome: parsed.outcome,
183
+ severity: parsed.severity ?? "info",
184
+ context: parsed.context ?? {},
185
+ ...(parsed.changes ? { changes: parsed.changes } : {}),
186
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
187
+ };
188
+ const provider = await getAuditProvider();
189
+ await provider.log(event);
190
+ }
191
+ catch (error) {
192
+ // Audit failures must NEVER break the calling code path — log and swallow.
193
+ logger.error("[audit] Failed to emit audit event", {
194
+ error: error instanceof Error ? error.message : String(error),
195
+ });
196
+ }
197
+ }