188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import { encodeBase64 } from "jsr:@std/encoding@1/base64";
|
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
|
import { sqlWrapper } from "./db.ts";
|
|
import { buildMerkleTree, leafHash } from "./audit_merkle.ts";
|
|
import { fetchSpiffeIdentity } from "./spire_ffi.ts";
|
|
import { valkey } from "./valkey.ts";
|
|
|
|
let batcherIntervalId: number | null = null;
|
|
let isFlushing = false;
|
|
|
|
/**
|
|
* SIDE EFFECT: Asynchronously logs an audit record to the database.
|
|
* Does not block the main execution thread. Errors are logged but swallowed
|
|
* to prevent failing the core request due to a logging issue.
|
|
*/
|
|
export let auditLog = function auditLog(
|
|
userId: string | null,
|
|
action: string,
|
|
resource: string | null,
|
|
details: Record<string, unknown> | null,
|
|
ipAddress: string,
|
|
): void {
|
|
// Fire and forget
|
|
(async () => {
|
|
try {
|
|
const entryObj = {
|
|
userId,
|
|
action,
|
|
resource,
|
|
details,
|
|
ipAddress,
|
|
timestamp: Date.now(),
|
|
};
|
|
const entryStr = JSON.stringify(entryObj);
|
|
const entryBytes = new TextEncoder().encode(entryStr);
|
|
const hashBuffer = await leafHash(entryBytes);
|
|
const leafHashHex = encodeHex(hashBuffer);
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO audit_records (user_id, action, resource, details, ip_address, leaf_hash)
|
|
VALUES (${userId}, ${action}, ${resource}, ${
|
|
details ? JSON.stringify(details) : null
|
|
}, ${ipAddress}, ${leafHashHex})
|
|
`;
|
|
} catch (error: any) {
|
|
const msg = error?.code || error?.message || String(error);
|
|
console.error(`[Audit Logger] Failed to insert audit record: ${msg}`);
|
|
}
|
|
})();
|
|
};
|
|
|
|
export async function flush(): Promise<void> {
|
|
if (isFlushing) return;
|
|
isFlushing = true;
|
|
|
|
try {
|
|
const sthResult = await sqlWrapper.sql`
|
|
SELECT tree_size FROM audit_sths ORDER BY tree_size DESC LIMIT 1
|
|
`;
|
|
const lastTreeSize = sthResult.length > 0
|
|
? parseInt(sthResult[0].tree_size, 10)
|
|
: 0;
|
|
|
|
const allRecordsResult = await sqlWrapper.sql`
|
|
SELECT leaf_hash FROM audit_records ORDER BY created_at ASC, id ASC
|
|
`;
|
|
|
|
const currentTreeSize = allRecordsResult.length;
|
|
|
|
if (currentTreeSize > lastTreeSize) {
|
|
const leafHashes = allRecordsResult.map((row: any) => {
|
|
const hex = row.leaf_hash;
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
}
|
|
return bytes;
|
|
});
|
|
|
|
const rootHashBytes = await buildMerkleTree(leafHashes);
|
|
const rootHashHex = encodeHex(rootHashBytes);
|
|
|
|
let svidData;
|
|
try {
|
|
svidData = await fetchSpiffeIdentity();
|
|
} catch (_e) {
|
|
svidData = { x509_svid_key: new Uint8Array() };
|
|
}
|
|
|
|
let signatureBase64 = "";
|
|
if (svidData.x509_svid_key && svidData.x509_svid_key.length > 0) {
|
|
try {
|
|
const keyBuffer = svidData.x509_svid_key.buffer as ArrayBuffer;
|
|
const privateKey = await crypto.subtle.importKey(
|
|
"pkcs8",
|
|
keyBuffer,
|
|
{ name: "ECDSA", namedCurve: "P-256" },
|
|
true,
|
|
["sign"],
|
|
).catch(() =>
|
|
crypto.subtle.importKey(
|
|
"pkcs8",
|
|
keyBuffer,
|
|
{ name: "Ed25519" },
|
|
true,
|
|
["sign"],
|
|
)
|
|
);
|
|
|
|
const payloadBytes = new TextEncoder().encode(
|
|
JSON.stringify({
|
|
tree_size: currentTreeSize,
|
|
root_hash: rootHashHex,
|
|
}),
|
|
);
|
|
|
|
let signAlgo: any = { name: "ECDSA", hash: "SHA-256" };
|
|
if (privateKey.algorithm.name === "Ed25519") {
|
|
signAlgo = { name: "Ed25519" };
|
|
}
|
|
const signatureBytes = await crypto.subtle.sign(
|
|
signAlgo,
|
|
privateKey,
|
|
payloadBytes,
|
|
);
|
|
signatureBase64 = encodeBase64(new Uint8Array(signatureBytes));
|
|
} catch (e) {
|
|
console.warn(
|
|
"[Audit Batcher] Failed to import key or sign, using empty signature.",
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO audit_sths (tree_size, root_hash, signature)
|
|
VALUES (${currentTreeSize}, ${rootHashHex}, ${signatureBase64})
|
|
`;
|
|
|
|
const payload = {
|
|
tree_size: currentTreeSize,
|
|
root_hash: rootHashHex,
|
|
signature: signatureBase64,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
|
|
const payloadStr = JSON.stringify(payload);
|
|
|
|
try {
|
|
if (typeof valkey.set === "function") {
|
|
await valkey.set("auth:audit:latest_sth", payloadStr);
|
|
}
|
|
if (typeof valkey.publish === "function") {
|
|
await valkey.publish("auth:audit:sth", payloadStr);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[Audit Batcher] Valkey broadcast failed", e);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[Audit Batcher] Error during flush:", error);
|
|
} finally {
|
|
isFlushing = false;
|
|
}
|
|
}
|
|
|
|
export function startMicroBatcher(): void {
|
|
if (batcherIntervalId === null) {
|
|
batcherIntervalId = setInterval(flush, 1000) as unknown as number;
|
|
}
|
|
}
|
|
|
|
export function stopMicroBatcher(): void {
|
|
if (batcherIntervalId !== null) {
|
|
clearInterval(batcherIntervalId);
|
|
batcherIntervalId = null;
|
|
}
|
|
}
|
|
|
|
export const auditWrapper = {
|
|
get auditLog() {
|
|
return auditLog;
|
|
},
|
|
set auditLog(val: any) {
|
|
auditLog = val;
|
|
},
|
|
};
|