Skip to content

feat: Implement Comprehensive Audit Logging System#3721

Open
suguslove10 wants to merge 11 commits intoDokploy:canaryfrom
suguslove10:feat/audit-logging-v2
Open

feat: Implement Comprehensive Audit Logging System#3721
suguslove10 wants to merge 11 commits intoDokploy:canaryfrom
suguslove10:feat/audit-logging-v2

Conversation

@suguslove10
Copy link

@suguslove10 suguslove10 commented Feb 16, 2026

This PR introduces a robust and comprehensive audit logging system to Dokploy, providing full transparency and detailed tracking of user activities across the platform.

Latest Changes

  • Sensitive data redaction: Implemented a redactSensitive utility to recursively redact passwords, secrets, tokens, API keys, and other sensitive fields from activity log metadata.
  • Restored error handling: Re-implemented try/catch blocks in SSH key, Application, Compose, User, and Organization routers to ensure descriptive TRPCError messages and prevent leaking raw database errors.
  • Improved pagination security: Added an upper bound (max 100) to the pageSize parameter in getActivityLogs to prevent potential resource exhaustion.
  • Database migration: Generated a new Drizzle migration for the activity_log table.
  • Code cleanup: Removed debug logs and fixed unreachable code.
  • Synced with latest canary: Current branch is up-to-date with canary.

Key Improvements

  1. Human-Readable Resources: The "Resource" column now displays the actual name of the project or service (e.g., bolt.diy) instead of an internal ID, making logs much easier to interpret at a glance.
  2. Detailed Change Tracking: Enabled detailed metadata recording for Application, Compose, User, and Organization routers. The system now captures specific field updates (e.g., updated commands, environment variables, or git settings).
  3. Advanced Activity Details View: Replaced basic tooltips with an interactive dialog that provides search, filtering, and structured metadata.

Verification

Verified the system locally by generating logs for various actions. The new UI correctly displays resource names and provides deep insights via the metadata dialog.

This implementation significantly enhances the security and accountability of the Dokploy platform.

Screenshot 2026-02-16 at 4 02 41 PM Screenshot 2026-02-16 at 4 02 56 PM

Greptile Summary

This PR introduces a comprehensive audit logging system that tracks user activities across Dokploy's routers — covering applications, compose, databases, servers, notifications, git providers, and more. It adds a new activity_log table, a recordActivity service with sensitive data redaction, a TRPC router with proper authorization, and a React UI with filtering and pagination.

  • Breaking change: The Postgres setup file changes the development published port from 5432 to 5433, which is unrelated to audit logging and will break existing dev environments.
  • Error handling regression: Several new try/catch blocks (in application.update, compose.update, compose.delete, application.delete) wrap TRPCError instances inside new TRPCErrors, causing the client to always receive INTERNAL_SERVER_ERROR instead of the original error code (e.g., BAD_REQUEST).
  • Return type changes: application.delete and compose.delete now return result[0] from raw DB delete instead of the full entity object, changing the API contract for consumers.
  • Missing database indexes: The activity_log table lacks indexes on organizationId and createdAt, which are used in every query. Performance will degrade as the table grows.
  • Cloud deployments not logged: In compose.deploy and compose.redeploy, the IS_CLOUD code path returns early before recordActivity is called.
  • Sensitive field redaction too aggressive: The "key" entry in SENSITIVE_FIELDS matches field names like sshKeyId and customGitSSHKeyId, unnecessarily redacting non-sensitive ID fields.
  • Purge "Clear All" unreliable: When days=0, purgeActivityLogs uses strict less-than on the current timestamp, which may not delete logs created in the same instant.

Confidence Score: 2/5

  • This PR has several functional issues that should be resolved before merging — including error handling regressions, return type changes, missing indexes, and an unrelated breaking change to the Postgres port.
  • Score of 2 reflects: (1) the Postgres port change is an unrelated breaking change, (2) TRPCError wrapping causes incorrect error codes to reach clients, (3) application/compose delete return type changes may break consumers, (4) cloud deployments skip audit logging, and (5) missing indexes will cause performance issues at scale. The core audit logging architecture is sound but the implementation needs fixes before it's safe to merge.
  • Pay close attention to packages/server/src/setup/postgres-setup.ts (unrelated breaking change), apps/dokploy/server/api/routers/application.ts (error handling and return type changes), apps/dokploy/server/api/routers/compose.ts (cloud deploy logging gap and return type), packages/server/src/services/activity-log.ts (redaction and purge logic), and apps/dokploy/drizzle/0144_plain_lady_ursula.sql (missing indexes).

Last reviewed commit: 02554c0

(4/5) You can add custom instructions or style guidelines for the agent here!

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

51 files reviewed, 8 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 262 to 314
try {
const result = await db
.delete(applications)
.where(eq(applications.applicationId, input.applicationId))
.returning();

await recordActivity({
userId: ctx.user.id,
organizationId: ctx.session.activeOrganizationId,
action: "application.delete",
resourceType: "application",
resourceId: application.applicationId,
metadata: { name: application.name },
});

if (!IS_CLOUD) {
const queueJobs = await getJobsByApplicationId(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
if (!IS_CLOUD) {
const queueJobs = await getJobsByApplicationId(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
}

const cleanupOperations = [
async () => await deleteAllMiddlewares(application),
async () => await removeDeployments(application),
async () =>
await removeDirectoryCode(application.appName, application.serverId),
async () =>
await removeMonitoringDirectory(
application.appName,
application.serverId,
),
async () =>
await removeTraefikConfig(application.appName, application.serverId),
async () =>
await removeService(application?.appName, application.serverId),
];

for (const operation of cleanupOperations) {
try {
await operation();
} catch (_) {}
}
const cleanupOperations = [
async () => await deleteAllMiddlewares(application),
async () => await removeDeployments(application),
async () =>
await removeDirectoryCode(
application.appName,
application.serverId,
),
async () =>
await removeMonitoringDirectory(
application.appName,
application.serverId,
),
async () =>
await removeTraefikConfig(
application.appName,
application.serverId,
),
async () =>
await removeService(application?.appName, application.serverId),
];

for (const operation of cleanupOperations) {
try {
await operation();
} catch (_) {}
}

return application;
return result[0];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return type changed from full entity to partial DB result

The original delete handler returned the full application object (fetched via findApplicationById). This change now returns result[0] from the raw db.delete().returning(), which is a different shape — it lacks the joined relations (e.g., environment.project) that the original application object had. If any frontend code depends on the returned object's structure from this mutation, it will break silently. The same issue exists in compose.delete at the corresponding location.

Comment on lines 9 to 19
const SENSITIVE_FIELDS = [
"password",
"currentPassword",
"token",
"secret",
"key",
"env",
"buildArgs",
"secrets",
"apiKey",
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad "key" field in SENSITIVE_FIELDS causes excessive redaction

The field name "key" matches any metadata key containing the substring "key" (case-insensitive), including non-sensitive ID fields like sshKeyId, customGitSSHKeyId, apiKeyId, backupId (no, but sshKey yes). For example, when logging SSH key creation, the resourceId field sshKeyId in the metadata object would show as [REDACTED] instead of the actual ID, making the audit log less useful.

Consider using more specific field names (e.g., "privateKey", "sshKey", "secretKey") or exact-match instead of substring matching to avoid redacting harmless ID fields.

Comment on lines 116 to 136
export const purgeActivityLogs = async (
input: z.infer<typeof apiPurgeActivityLogsSchema>,
) => {
const { organizationId, days } = input;

const date = new Date();
date.setDate(date.getDate() - days);

const where = [lt(activityLogs.createdAt, date)];

if (organizationId) {
where.push(eq(activityLogs.organizationId, organizationId));
}

const result = await db
.delete(activityLogs)
.where(and(...where))
.returning();

return result.length;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

purgeActivityLogs with days=0 may not delete all logs

When days is 0, the computed date is set to the current timestamp. The WHERE clause uses lt(activityLogs.createdAt, date) (strictly less than), which means any logs created at or after the exact instant date was computed will not be deleted. The UI offers a "Clear All" option that passes days=0, but this won't reliably clear all logs — any log created in the same second (or between the timestamp computation and the query execution) will survive.

Consider using lte (less than or equal) when days === 0, or simply omitting the createdAt filter entirely for the "clear all" case.

Comment on lines 464 to 470
try {
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloud deployments skip audit logging

When IS_CLOUD && compose.serverId is true, the function returns true at line 470 before the recordActivity call at line 481 is reached. This means cloud deployments are never logged in the activity system. The same issue exists in the redeploy handler. The recordActivity call should be placed before the early return, or duplicated in both branches.

Comment on lines 307 to 317
await recordActivity({
userId: ctx.user.id,
organizationId: ctx.session.activeOrganizationId,
action: "mariadb.delete",
resourceType: "database",
resourceId: mongo.mariadbId,
metadata: {
name: mongo.name,
appName: mongo.appName,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong variable name: mongo used instead of mariadb

The recordActivity call references mongo.mariadbId, mongo.name, and mongo.appName, but this is the MariaDB delete handler. The variable is named mongo due to copy-paste from the Mongo router. While it happens to work because the local variable is indeed named mongo in the existing code (inherited from a code-generation pattern), this is misleading for audit log readers and introduces confusion. This same issue exists in mysql.ts where mongo.mysqlId is referenced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants