/* * This file is part of a proprietary work. * * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. * You may not use this file except in compliance with the License. * Unauthorized use, copying, modification, or distribution is strictly prohibited. * * This file is not licensed under the AGPLv3. */ import { accessAuditLog, db, orgs } from "@server/db"; import { getCountryCodeForIp } from "@server/lib/geoip"; import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "@server/lib/cache"; async function getAccessDays(orgId: string): Promise { // check cache first const cached = cache.get(`org_${orgId}_accessDays`); if (cached !== undefined) { return cached; } const [org] = await db .select({ settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction }) .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return 0; } // store the result in cache cache.set( `org_${orgId}_accessDays`, org.settingsLogRetentionDaysAction, 300 ); return org.settingsLogRetentionDaysAction; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const now = Math.floor(Date.now() / 1000); const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; try { await db .delete(accessAuditLog) .where( and( lt(accessAuditLog.timestamp, cutoffTimestamp), eq(accessAuditLog.orgId, orgId) ) ); logger.debug( `Cleaned up access audit logs older than ${retentionDays} days` ); } catch (error) { logger.error("Error cleaning up old action audit logs:", error); } } export async function logAccessAudit(data: { action: boolean; type: string; orgId: string; resourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; userAgent?: string; requestIp?: string; }) { try { const retentionDays = await getAccessDays(data.orgId); if (retentionDays === 0) { // do not log return; } let actorType: string | undefined; let actor: string | undefined; let actorId: string | undefined; const user = data.user; if (user) { actorType = "user"; actor = user.username; actorId = user.userId; } const apiKey = data.apiKey; if (apiKey) { actorType = "apiKey"; actor = apiKey.name || apiKey.apiKeyId; actorId = apiKey.apiKeyId; } // if (!actorType || !actor || !actorId) { // logger.warn("logRequestAudit: Incomplete actor information"); // return; // } const timestamp = Math.floor(Date.now() / 1000); let metadata = null; if (metadata) { metadata = JSON.stringify(metadata); } const clientIp = data.requestIp ? (() => { if ( data.requestIp.startsWith("[") && data.requestIp.includes("]") ) { // if brackets are found, extract the IPv6 address from between the brackets const ipv6Match = data.requestIp.match(/\[(.*?)\]/); if (ipv6Match) { return ipv6Match[1]; } } return data.requestIp; })() : undefined; const countryCode = data.requestIp ? await getCountryCodeFromIp(data.requestIp) : undefined; await db.insert(accessAuditLog).values({ timestamp: timestamp, orgId: data.orgId, actorType, actor, actorId, action: data.action, type: data.type, metadata, resourceId: data.resourceId, userAgent: data.userAgent, ip: clientIp, location: countryCode }); } catch (error) { logger.error(error); } } async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip_access:${ip}`; let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally // Cache for longer since IP geolocation doesn't change frequently cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } return cachedCountryCode; }