@nebutra/audit 0.1.0 → 0.1.2
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/LICENSE +21 -676
- package/README.md +4 -2
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +288 -0
- package/dist/middleware.d.ts +75 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +197 -0
- package/dist/providers/clickhouse.d.ts +37 -0
- package/dist/providers/clickhouse.d.ts.map +1 -0
- package/dist/providers/clickhouse.js +114 -0
- package/dist/providers/index.d.ts +22 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +84 -0
- package/dist/providers/memory.d.ts +12 -0
- package/dist/providers/memory.d.ts.map +1 -0
- package/dist/providers/memory.js +52 -0
- package/dist/providers/postgres.d.ts +19 -0
- package/dist/providers/postgres.d.ts.map +1 -0
- package/dist/providers/postgres.js +138 -0
- package/dist/providers/types.d.ts +14 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +7 -0
- package/dist/schema.d.ts +354 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +186 -0
- package/package.json +5 -5
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AuditEvent, AuditQueryFilter } from "../schema";
|
|
2
|
+
import type { AuditProvider } from "./types";
|
|
3
|
+
export interface ClickHouseAuditConfig {
|
|
4
|
+
url: string;
|
|
5
|
+
username?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
database?: string;
|
|
8
|
+
table?: string;
|
|
9
|
+
}
|
|
10
|
+
interface ClickHouseClientLike {
|
|
11
|
+
insert(args: {
|
|
12
|
+
table: string;
|
|
13
|
+
values: unknown[];
|
|
14
|
+
format: string;
|
|
15
|
+
}): Promise<unknown>;
|
|
16
|
+
query(args: {
|
|
17
|
+
query: string;
|
|
18
|
+
format: string;
|
|
19
|
+
query_params?: Record<string, unknown>;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
json: <T>() => Promise<{
|
|
22
|
+
data: T[];
|
|
23
|
+
}>;
|
|
24
|
+
}>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare class ClickHouseAuditProvider implements AuditProvider {
|
|
28
|
+
private readonly client;
|
|
29
|
+
readonly type: "clickhouse";
|
|
30
|
+
private readonly table;
|
|
31
|
+
constructor(client: ClickHouseClientLike, config: ClickHouseAuditConfig);
|
|
32
|
+
log(event: AuditEvent): Promise<void>;
|
|
33
|
+
query(filter: AuditQueryFilter): Promise<AuditEvent[]>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=clickhouse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clickhouse.d.ts","sourceRoot":"","sources":["../../src/providers/clickhouse.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,oBAAoB;IAC5B,MAAM,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrF,KAAK,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC;QAC9F,IAAI,EAAE,CAAC,CAAC,OAAO,OAAO,CAAC;YAAE,IAAI,EAAE,CAAC,EAAE,CAAA;SAAE,CAAC,CAAC;KACvC,CAAC,CAAC;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,qBAAa,uBAAwB,YAAW,aAAa;IAIzD,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHzB,QAAQ,CAAC,IAAI,EAAG,YAAY,CAAU;IACtC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAEZ,MAAM,EAAE,oBAAoB,EAC7C,MAAM,EAAE,qBAAqB;IAKzB,GAAG,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BrC,KAAK,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IA+BtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// ClickHouseAuditProvider — high-volume analytics-grade audit storage
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Optional provider for customers running ClickHouse alongside @nebutra/metering.
|
|
5
|
+
// Activates when both `CLICKHOUSE_URL` is set and `AUDIT_USE_CLICKHOUSE=true`.
|
|
6
|
+
//
|
|
7
|
+
// The client is loaded lazily so installations that don't need ClickHouse can
|
|
8
|
+
// skip pulling `@clickhouse/client` into the runtime bundle.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
export class ClickHouseAuditProvider {
|
|
11
|
+
client;
|
|
12
|
+
type = "clickhouse";
|
|
13
|
+
table;
|
|
14
|
+
constructor(client, config) {
|
|
15
|
+
this.client = client;
|
|
16
|
+
this.table = config.table ?? "audit_logs";
|
|
17
|
+
}
|
|
18
|
+
async log(event) {
|
|
19
|
+
await this.client.insert({
|
|
20
|
+
table: this.table,
|
|
21
|
+
format: "JSONEachRow",
|
|
22
|
+
values: [
|
|
23
|
+
{
|
|
24
|
+
id: event.id,
|
|
25
|
+
timestamp: event.timestamp,
|
|
26
|
+
tenant_id: event.tenantId,
|
|
27
|
+
actor_id: event.actor.id,
|
|
28
|
+
actor_type: event.actor.type,
|
|
29
|
+
actor_email: event.actor.email ?? null,
|
|
30
|
+
action: event.action,
|
|
31
|
+
resource_type: event.resource.type,
|
|
32
|
+
resource_id: event.resource.id,
|
|
33
|
+
resource_name: event.resource.name ?? null,
|
|
34
|
+
outcome: event.outcome,
|
|
35
|
+
severity: event.severity,
|
|
36
|
+
ip: event.context?.ip ?? null,
|
|
37
|
+
user_agent: event.context?.userAgent ?? null,
|
|
38
|
+
request_id: event.context?.requestId ?? null,
|
|
39
|
+
session_id: event.context?.sessionId ?? null,
|
|
40
|
+
changes_before: event.changes?.before ?? null,
|
|
41
|
+
changes_after: event.changes?.after ?? null,
|
|
42
|
+
metadata: event.metadata ?? null,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async query(filter) {
|
|
48
|
+
// ClickHouse query support is intentionally limited — it's optimized for
|
|
49
|
+
// ingest, not point lookups. Most query workloads should hit Postgres.
|
|
50
|
+
const where = [];
|
|
51
|
+
const params = {};
|
|
52
|
+
if (filter.tenantId) {
|
|
53
|
+
where.push("tenant_id = {tenantId:String}");
|
|
54
|
+
params.tenantId = filter.tenantId;
|
|
55
|
+
}
|
|
56
|
+
if (filter.actorId) {
|
|
57
|
+
where.push("actor_id = {actorId:String}");
|
|
58
|
+
params.actorId = filter.actorId;
|
|
59
|
+
}
|
|
60
|
+
if (filter.action) {
|
|
61
|
+
where.push("action = {action:String}");
|
|
62
|
+
params.action = filter.action;
|
|
63
|
+
}
|
|
64
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
65
|
+
const limit = filter.limit ?? 100;
|
|
66
|
+
const offset = filter.offset ?? 0;
|
|
67
|
+
const result = await this.client.query({
|
|
68
|
+
query: `SELECT * FROM ${this.table} ${whereClause} ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`,
|
|
69
|
+
format: "JSONEachRow",
|
|
70
|
+
query_params: params,
|
|
71
|
+
});
|
|
72
|
+
const { data } = await result.json();
|
|
73
|
+
return data.map(rowToEvent);
|
|
74
|
+
}
|
|
75
|
+
async close() {
|
|
76
|
+
await this.client.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function rowToEvent(row) {
|
|
80
|
+
const get = (key) => row[key];
|
|
81
|
+
return {
|
|
82
|
+
id: String(row.id),
|
|
83
|
+
timestamp: String(row.timestamp),
|
|
84
|
+
actor: {
|
|
85
|
+
id: String(row.actor_id),
|
|
86
|
+
type: (row.actor_type ?? "system"),
|
|
87
|
+
...(row.actor_email ? { email: String(row.actor_email) } : {}),
|
|
88
|
+
},
|
|
89
|
+
tenantId: String(row.tenant_id),
|
|
90
|
+
action: String(row.action),
|
|
91
|
+
resource: {
|
|
92
|
+
type: String(row.resource_type),
|
|
93
|
+
id: String(row.resource_id),
|
|
94
|
+
...(row.resource_name ? { name: String(row.resource_name) } : {}),
|
|
95
|
+
},
|
|
96
|
+
outcome: (row.outcome ?? "success"),
|
|
97
|
+
severity: (row.severity ?? "info"),
|
|
98
|
+
context: {
|
|
99
|
+
...(row.ip ? { ip: String(row.ip) } : {}),
|
|
100
|
+
...(row.user_agent ? { userAgent: String(row.user_agent) } : {}),
|
|
101
|
+
...(row.request_id ? { requestId: String(row.request_id) } : {}),
|
|
102
|
+
...(row.session_id ? { sessionId: String(row.session_id) } : {}),
|
|
103
|
+
},
|
|
104
|
+
...(get("changes_before") && get("changes_after")
|
|
105
|
+
? {
|
|
106
|
+
changes: {
|
|
107
|
+
before: get("changes_before"),
|
|
108
|
+
after: get("changes_after"),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
...(get("metadata") ? { metadata: get("metadata") } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type PrismaAuditDelegate } from "./postgres";
|
|
2
|
+
import type { AuditProvider, AuditProviderType } from "./types";
|
|
3
|
+
export { ClickHouseAuditProvider } from "./clickhouse";
|
|
4
|
+
export { MemoryAuditProvider } from "./memory";
|
|
5
|
+
export type { PrismaAuditDelegate } from "./postgres";
|
|
6
|
+
export { PostgresAuditProvider } from "./postgres";
|
|
7
|
+
export type { AuditProvider, AuditProviderType } from "./types";
|
|
8
|
+
export interface AuditFactoryConfig {
|
|
9
|
+
/** Force a specific provider type. Useful for tests. */
|
|
10
|
+
provider?: AuditProviderType;
|
|
11
|
+
/** Required for the postgres provider. Provide the Prisma client. */
|
|
12
|
+
prisma?: PrismaAuditDelegate;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the configured audit provider. Cached after the first call so the
|
|
16
|
+
* provider lifecycle matches the process lifecycle.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getAuditProvider(config?: AuditFactoryConfig): Promise<AuditProvider>;
|
|
19
|
+
export declare function createAuditProvider(config?: AuditFactoryConfig): Promise<AuditProvider>;
|
|
20
|
+
/** @internal — exposed for tests so they can reset the singleton. */
|
|
21
|
+
export declare function __resetAuditProviderForTests(): void;
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/providers/index.ts"],"names":[],"mappings":"AAYA,OAAO,EAAyB,KAAK,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEhE,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC/C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACnD,YAAY,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEhE,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,qEAAqE;IACrE,MAAM,CAAC,EAAE,mBAAmB,CAAC;CAC9B;AAgBD;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,kBAAuB,GAAG,OAAO,CAAC,aAAa,CAAC,CAI9F;AAED,wBAAsB,mBAAmB,CAAC,MAAM,GAAE,kBAAuB,GAAG,OAAO,CAAC,aAAa,CAAC,CAkDjG;AAED,qEAAqE;AACrE,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Audit provider factory — auto-detects the right backend from the environment
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Detection precedence (matches @nebutra/queue, @nebutra/search):
|
|
5
|
+
// 1. AUDIT_PROVIDER env var → explicit override
|
|
6
|
+
// 2. CLICKHOUSE_URL + AUDIT_USE_CLICKHOUSE=true → clickhouse
|
|
7
|
+
// 3. DATABASE_URL → postgres
|
|
8
|
+
// 4. (fallback) → memory (dev/test only)
|
|
9
|
+
// =============================================================================
|
|
10
|
+
import { logger } from "@nebutra/logger";
|
|
11
|
+
import { MemoryAuditProvider } from "./memory";
|
|
12
|
+
import { PostgresAuditProvider } from "./postgres";
|
|
13
|
+
export { ClickHouseAuditProvider } from "./clickhouse";
|
|
14
|
+
export { MemoryAuditProvider } from "./memory";
|
|
15
|
+
export { PostgresAuditProvider } from "./postgres";
|
|
16
|
+
function detectProviderType() {
|
|
17
|
+
const explicit = process.env.AUDIT_PROVIDER?.trim();
|
|
18
|
+
if (explicit && ["memory", "postgres", "clickhouse"].includes(explicit)) {
|
|
19
|
+
return explicit;
|
|
20
|
+
}
|
|
21
|
+
if (process.env.CLICKHOUSE_URL && process.env.AUDIT_USE_CLICKHOUSE === "true") {
|
|
22
|
+
return "clickhouse";
|
|
23
|
+
}
|
|
24
|
+
if (process.env.DATABASE_URL)
|
|
25
|
+
return "postgres";
|
|
26
|
+
return "memory";
|
|
27
|
+
}
|
|
28
|
+
let cachedProvider = null;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the configured audit provider. Cached after the first call so the
|
|
31
|
+
* provider lifecycle matches the process lifecycle.
|
|
32
|
+
*/
|
|
33
|
+
export async function getAuditProvider(config = {}) {
|
|
34
|
+
if (cachedProvider)
|
|
35
|
+
return cachedProvider;
|
|
36
|
+
cachedProvider = await createAuditProvider(config);
|
|
37
|
+
return cachedProvider;
|
|
38
|
+
}
|
|
39
|
+
export async function createAuditProvider(config = {}) {
|
|
40
|
+
const type = config.provider ?? detectProviderType();
|
|
41
|
+
switch (type) {
|
|
42
|
+
case "memory":
|
|
43
|
+
if (process.env.NODE_ENV === "production") {
|
|
44
|
+
logger.warn("[audit] Falling back to in-memory provider in production — events will be lost. Configure DATABASE_URL or AUDIT_PROVIDER.");
|
|
45
|
+
}
|
|
46
|
+
return new MemoryAuditProvider();
|
|
47
|
+
case "postgres": {
|
|
48
|
+
let delegate = config.prisma;
|
|
49
|
+
if (!delegate) {
|
|
50
|
+
try {
|
|
51
|
+
// Lazy import — keeps the package usable in test environments
|
|
52
|
+
// that mock @nebutra/db.
|
|
53
|
+
const dbModule = (await import("@nebutra/db"));
|
|
54
|
+
if (typeof dbModule.getSystemDb === "function") {
|
|
55
|
+
delegate = dbModule.getSystemDb();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger.warn("[audit] Could not load @nebutra/db — falling back to memory provider", {
|
|
60
|
+
error: error instanceof Error ? error.message : String(error),
|
|
61
|
+
});
|
|
62
|
+
return new MemoryAuditProvider();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!delegate) {
|
|
66
|
+
logger.warn("[audit] Postgres provider requested but no Prisma client available");
|
|
67
|
+
return new MemoryAuditProvider();
|
|
68
|
+
}
|
|
69
|
+
return new PostgresAuditProvider(delegate);
|
|
70
|
+
}
|
|
71
|
+
case "clickhouse":
|
|
72
|
+
// ClickHouse provider requires explicit construction by the host app
|
|
73
|
+
// because it owns the @clickhouse/client lifecycle. Fall back to the
|
|
74
|
+
// postgres provider (or memory) if no client is supplied.
|
|
75
|
+
logger.warn("[audit] ClickHouse provider must be constructed explicitly via createClickHouseAuditProvider(). Falling back to postgres.");
|
|
76
|
+
return createAuditProvider({ ...config, provider: "postgres" });
|
|
77
|
+
default:
|
|
78
|
+
return new MemoryAuditProvider();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** @internal — exposed for tests so they can reset the singleton. */
|
|
82
|
+
export function __resetAuditProviderForTests() {
|
|
83
|
+
cachedProvider = null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuditEvent, AuditQueryFilter } from "../schema";
|
|
2
|
+
import type { AuditProvider } from "./types";
|
|
3
|
+
export declare class MemoryAuditProvider implements AuditProvider {
|
|
4
|
+
readonly type: "memory";
|
|
5
|
+
private readonly events;
|
|
6
|
+
log(event: AuditEvent): Promise<void>;
|
|
7
|
+
query(filter: AuditQueryFilter): Promise<AuditEvent[]>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
/** @internal — exposed for tests so they can assert append-only behavior. */
|
|
10
|
+
__all(): readonly AuditEvent[];
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/providers/memory.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,qBAAa,mBAAoB,YAAW,aAAa;IACvD,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAErC,GAAG,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAKrC,KAAK,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IA0BtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,6EAA6E;IAC7E,KAAK,IAAI,SAAS,UAAU,EAAE;CAG/B"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// MemoryProvider — in-process audit log buffer
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// For local development, unit tests, and last-resort fallback when no
|
|
5
|
+
// production backend is configured. NEVER use in production: events are lost
|
|
6
|
+
// on process exit and there is no retention enforcement.
|
|
7
|
+
// =============================================================================
|
|
8
|
+
export class MemoryAuditProvider {
|
|
9
|
+
type = "memory";
|
|
10
|
+
events = [];
|
|
11
|
+
async log(event) {
|
|
12
|
+
// Append-only: never replace, never reorder.
|
|
13
|
+
this.events.push(event);
|
|
14
|
+
}
|
|
15
|
+
async query(filter) {
|
|
16
|
+
const limit = filter.limit ?? 100;
|
|
17
|
+
const offset = filter.offset ?? 0;
|
|
18
|
+
const matches = this.events.filter((e) => {
|
|
19
|
+
if (filter.tenantId && e.tenantId !== filter.tenantId)
|
|
20
|
+
return false;
|
|
21
|
+
if (filter.actorId && e.actor.id !== filter.actorId)
|
|
22
|
+
return false;
|
|
23
|
+
if (filter.action && e.action !== filter.action)
|
|
24
|
+
return false;
|
|
25
|
+
if (filter.resourceType && e.resource.type !== filter.resourceType)
|
|
26
|
+
return false;
|
|
27
|
+
if (filter.resourceId && e.resource.id !== filter.resourceId)
|
|
28
|
+
return false;
|
|
29
|
+
if (filter.outcome && e.outcome !== filter.outcome)
|
|
30
|
+
return false;
|
|
31
|
+
if (filter.startDate && new Date(e.timestamp) < filter.startDate)
|
|
32
|
+
return false;
|
|
33
|
+
if (filter.endDate && new Date(e.timestamp) > filter.endDate)
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
// Sort by timestamp descending (most recent first).
|
|
38
|
+
const sorted = [...matches].sort((a, b) => {
|
|
39
|
+
const ta = new Date(a.timestamp).getTime();
|
|
40
|
+
const tb = new Date(b.timestamp).getTime();
|
|
41
|
+
return tb - ta;
|
|
42
|
+
});
|
|
43
|
+
return sorted.slice(offset, offset + limit);
|
|
44
|
+
}
|
|
45
|
+
async close() {
|
|
46
|
+
// No-op for the memory provider. Buffer is GC'd with the instance.
|
|
47
|
+
}
|
|
48
|
+
/** @internal — exposed for tests so they can assert append-only behavior. */
|
|
49
|
+
__all() {
|
|
50
|
+
return this.events;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AuditEvent, AuditQueryFilter } from "../schema";
|
|
2
|
+
import type { AuditProvider } from "./types";
|
|
3
|
+
export interface PrismaAuditDelegate {
|
|
4
|
+
auditLog: {
|
|
5
|
+
create: (args: {
|
|
6
|
+
data: Record<string, unknown>;
|
|
7
|
+
}) => Promise<unknown>;
|
|
8
|
+
findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare class PostgresAuditProvider implements AuditProvider {
|
|
12
|
+
private readonly db;
|
|
13
|
+
readonly type: "postgres";
|
|
14
|
+
constructor(db: PrismaAuditDelegate);
|
|
15
|
+
log(event: AuditEvent): Promise<void>;
|
|
16
|
+
query(filter: AuditQueryFilter): Promise<AuditEvent[]>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=postgres.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/providers/postgres.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAa,UAAU,EAAE,gBAAgB,EAAW,MAAM,WAAW,CAAC;AAClF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAI7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE;QACR,MAAM,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;QACtE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;KACjE,CAAC;CACH;AAwBD,qBAAa,qBAAsB,YAAW,aAAa;IAE7C,OAAO,CAAC,QAAQ,CAAC,EAAE;IAD/B,QAAQ,CAAC,IAAI,EAAG,UAAU,CAAU;gBACP,EAAE,EAAE,mBAAmB;IAE9C,GAAG,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BrC,KAAK,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAyBtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// PostgresAuditProvider — production-default Prisma-backed storage
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Persists audit events into the existing `audit_logs` table defined in
|
|
5
|
+
// `packages/platform/db/prisma/schema.prisma`. Field mapping:
|
|
6
|
+
//
|
|
7
|
+
// AuditEvent (schema.ts) → Prisma AuditLog model
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// id → id
|
|
10
|
+
// timestamp → createdAt
|
|
11
|
+
// actor.id → userId
|
|
12
|
+
// actor.type → actorType
|
|
13
|
+
// tenantId → organizationId
|
|
14
|
+
// action → action
|
|
15
|
+
// resource.type → entityType
|
|
16
|
+
// resource.id → entityId
|
|
17
|
+
// resource.name → metadata.resourceName
|
|
18
|
+
// outcome → outcome
|
|
19
|
+
// severity → metadata.severity
|
|
20
|
+
// context.ip → ipAddress
|
|
21
|
+
// context.userAgent → userAgent
|
|
22
|
+
// context.requestId/sessionId/geo → metadata.context
|
|
23
|
+
// changes.before → oldValue
|
|
24
|
+
// changes.after → newValue
|
|
25
|
+
// metadata → metadata.userMetadata
|
|
26
|
+
//
|
|
27
|
+
// The audit_logs table is APPEND-ONLY — see MIGRATION.md for the SQL that
|
|
28
|
+
// revokes UPDATE/DELETE from the application role.
|
|
29
|
+
// =============================================================================
|
|
30
|
+
function isObject(v) {
|
|
31
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
export class PostgresAuditProvider {
|
|
34
|
+
db;
|
|
35
|
+
type = "postgres";
|
|
36
|
+
constructor(db) {
|
|
37
|
+
this.db = db;
|
|
38
|
+
}
|
|
39
|
+
async log(event) {
|
|
40
|
+
const metadataBlob = {
|
|
41
|
+
severity: event.severity,
|
|
42
|
+
};
|
|
43
|
+
if (event.resource.name)
|
|
44
|
+
metadataBlob.resourceName = event.resource.name;
|
|
45
|
+
if (event.context && Object.keys(event.context).length > 0) {
|
|
46
|
+
metadataBlob.context = event.context;
|
|
47
|
+
}
|
|
48
|
+
if (event.metadata)
|
|
49
|
+
metadataBlob.userMetadata = event.metadata;
|
|
50
|
+
await this.db.auditLog.create({
|
|
51
|
+
data: {
|
|
52
|
+
id: event.id,
|
|
53
|
+
organizationId: event.tenantId,
|
|
54
|
+
userId: event.actor.id,
|
|
55
|
+
actorType: event.actor.type,
|
|
56
|
+
action: event.action,
|
|
57
|
+
outcome: event.outcome,
|
|
58
|
+
entityType: event.resource.type,
|
|
59
|
+
entityId: event.resource.id,
|
|
60
|
+
oldValue: event.changes?.before ?? null,
|
|
61
|
+
newValue: event.changes?.after ?? null,
|
|
62
|
+
ipAddress: event.context?.ip ?? null,
|
|
63
|
+
userAgent: event.context?.userAgent ?? null,
|
|
64
|
+
metadata: metadataBlob,
|
|
65
|
+
createdAt: new Date(event.timestamp),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async query(filter) {
|
|
70
|
+
const where = {};
|
|
71
|
+
if (filter.tenantId)
|
|
72
|
+
where.organizationId = filter.tenantId;
|
|
73
|
+
if (filter.actorId)
|
|
74
|
+
where.userId = filter.actorId;
|
|
75
|
+
if (filter.action)
|
|
76
|
+
where.action = filter.action;
|
|
77
|
+
if (filter.resourceType)
|
|
78
|
+
where.entityType = filter.resourceType;
|
|
79
|
+
if (filter.resourceId)
|
|
80
|
+
where.entityId = filter.resourceId;
|
|
81
|
+
if (filter.outcome)
|
|
82
|
+
where.outcome = filter.outcome;
|
|
83
|
+
if (filter.startDate || filter.endDate) {
|
|
84
|
+
const range = {};
|
|
85
|
+
if (filter.startDate)
|
|
86
|
+
range.gte = filter.startDate;
|
|
87
|
+
if (filter.endDate)
|
|
88
|
+
range.lte = filter.endDate;
|
|
89
|
+
where.createdAt = range;
|
|
90
|
+
}
|
|
91
|
+
const rows = (await this.db.auditLog.findMany({
|
|
92
|
+
where,
|
|
93
|
+
orderBy: { createdAt: "desc" },
|
|
94
|
+
take: filter.limit ?? 100,
|
|
95
|
+
skip: filter.offset ?? 0,
|
|
96
|
+
}));
|
|
97
|
+
return rows.map(rowToEvent);
|
|
98
|
+
}
|
|
99
|
+
async close() {
|
|
100
|
+
// The Prisma client lifecycle is owned by @nebutra/db — do NOT disconnect
|
|
101
|
+
// here. Closing a shared client would break unrelated callers.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function rowToEvent(row) {
|
|
105
|
+
const meta = isObject(row.metadata) ? row.metadata : {};
|
|
106
|
+
const severity = (typeof meta.severity === "string" ? meta.severity : "info");
|
|
107
|
+
const ctx = isObject(meta.context) ? meta.context : {};
|
|
108
|
+
const userMeta = isObject(meta.userMetadata) ? meta.userMetadata : undefined;
|
|
109
|
+
const event = {
|
|
110
|
+
id: row.id,
|
|
111
|
+
timestamp: row.createdAt.toISOString(),
|
|
112
|
+
actor: {
|
|
113
|
+
id: row.userId ?? "unknown",
|
|
114
|
+
type: (row.actorType ?? "system"),
|
|
115
|
+
},
|
|
116
|
+
tenantId: row.organizationId ?? "unknown",
|
|
117
|
+
action: row.action,
|
|
118
|
+
resource: {
|
|
119
|
+
type: row.entityType,
|
|
120
|
+
id: row.entityId ?? "unknown",
|
|
121
|
+
...(typeof meta.resourceName === "string" ? { name: meta.resourceName } : {}),
|
|
122
|
+
},
|
|
123
|
+
outcome: (row.outcome ?? "success"),
|
|
124
|
+
severity,
|
|
125
|
+
context: {
|
|
126
|
+
...(row.ipAddress ? { ip: row.ipAddress } : {}),
|
|
127
|
+
...(row.userAgent ? { userAgent: row.userAgent } : {}),
|
|
128
|
+
...(typeof ctx.requestId === "string" ? { requestId: ctx.requestId } : {}),
|
|
129
|
+
...(typeof ctx.sessionId === "string" ? { sessionId: ctx.sessionId } : {}),
|
|
130
|
+
...(isObject(ctx.geo) ? { geo: ctx.geo } : {}),
|
|
131
|
+
},
|
|
132
|
+
...(isObject(row.oldValue) && isObject(row.newValue)
|
|
133
|
+
? { changes: { before: row.oldValue, after: row.newValue } }
|
|
134
|
+
: {}),
|
|
135
|
+
...(userMeta ? { metadata: userMeta } : {}),
|
|
136
|
+
};
|
|
137
|
+
return event;
|
|
138
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuditEvent, AuditQueryFilter } from "../schema";
|
|
2
|
+
export type AuditProviderType = "memory" | "postgres" | "clickhouse";
|
|
3
|
+
export interface AuditProvider {
|
|
4
|
+
readonly type: AuditProviderType;
|
|
5
|
+
/** Append an event to the immutable audit log. MUST NOT throw on transient
|
|
6
|
+
* failures — log + swallow to keep the caller's path resilient. */
|
|
7
|
+
log(event: AuditEvent): Promise<void>;
|
|
8
|
+
/** Query past events. Tenant-scoped queries are STRONGLY RECOMMENDED — the
|
|
9
|
+
* caller is responsible for passing `tenantId` to honor multi-tenancy. */
|
|
10
|
+
query(filter: AuditQueryFilter): Promise<AuditEvent[]>;
|
|
11
|
+
/** Release any held connections / flush in-flight buffers. */
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/providers/types.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE9D,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,UAAU,GAAG,YAAY,CAAC;AAErE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC;wEACoE;IACpE,GAAG,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC;+EAC2E;IAC3E,KAAK,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACvD,8DAA8D;IAC9D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// AuditProvider interface
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Provider-agnostic abstraction matching the @nebutra/queue and @nebutra/search
|
|
5
|
+
// patterns: customers swap backends via env vars without changing app code.
|
|
6
|
+
// =============================================================================
|
|
7
|
+
export {};
|