close

DEV Community

Cover image for How to Audit Who Did What in Your Multi-Tenant App: Building an Activity Log With Convex and Kinde
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Audit Who Did What in Your Multi-Tenant App: Building an Activity Log With Convex and Kinde

Enterprise customers will not sign on the dotted line until they can answer: "Who did what, and when?" Here is how to build the activity log that closes that gap.

In this article, you will learn:

  • Why audit logs are no longer optional for multi-tenant SaaS — and what it costs you when they are missing
  • How to design an activityLogs schema in Convex that handles high-volume writes without slowing down your queries
  • Why you should denormalize actor fields at write time and what happens when you do not
  • How to build a single auditLog helper that every mutation calls — and why it being transactional is the whole point
  • How to pull actor identity from Kinde's server session and attach it to every log entry
  • How to build a filterable, paginated, real-time activity feed that org members can view
  • How to export activity logs as CSV for compliance teams
  • How to guarantee that no organization ever sees another organization's activity

Let's dive in!

The Audit Log Gap That Costs You Enterprise Deals

There is a specific moment in enterprise procurement that catches most SaaS founders off guard. You have passed the security review. The procurement lead likes the product. Then someone from IT or legal asks: "If an admin deletes something or changes a permission, can we see who did it and when?"

If the answer is no, the deal stalls — sometimes indefinitely. Enterprise customers are not being bureaucratic. They have legal obligations around data access records, SOC 2 and ISO 27001 auditors who ask for evidence of access trails, and internal IT teams that need to investigate incidents when they happen. An audit log is not a backlog item. It is infrastructure that determines whether your app is deployable inside a governed organization.

The other problem is internal. When a team member emails asking why a document disappeared, or when a customer opens a support ticket claiming their settings were changed without their knowledge, you have nothing to show them without an activity log. You either stare at database timestamps trying to reconstruct what happened, or you tell them you cannot find out. Neither is acceptable once you have paying customers.

Building this correctly is not complicated if you do it at the right layer. This article builds a complete, production-ready activity log for a multi-tenant Next.js app using Convex and Kinde. The pattern it establishes — a single helper, called inside every mutation, committed in the same transaction as the action itself — means you will never have a mutation that fires without a corresponding log entry.

Three columns connected by arrows. Left column labeled

Why Convex Is the Right Database for Audit Logs

Audit logs have a write pattern that is unusual compared to most application data. Entries are inserted at high frequency, almost never updated, never deleted within your retention window, and queried in time-range windows filtered by organization, actor, or resource type.

Convex handles this well for three reasons.

Writes are transactional and co-located with your mutations. Because your mutation and your auditLog call live in the same Convex function and commit in the same transaction, you cannot have a state where a document was deleted but no log entry was written. Either both commit or neither does. This property is not free in architectures where you fire a side-effect call to a separate logging service after the mutation returns — a network failure between the two leaves your audit trail permanently inconsistent.

Compound indexes make time-range queries fast. Filtering by organizationId and then ordering by creation time is exactly the access pattern an activity feed needs. Convex's index system keeps that query fast as the table grows into hundreds of thousands of rows.

Reactive queries give you a live feed for free. A feed component that uses useQuery on your log query will update in real time whenever new entries are written — without polling, without manual WebSocket management, without any extra code. Convex tracks the query's data dependencies and pushes updates automatically.

Schema

Add the activityLogs table to your convex/schema.ts.

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  // ... your existing tables

  activityLogs: defineTable({
    organizationId: v.id("organizations"),
    actorId: v.string(),           // Kinde user ID — e.g. "kp_123abc"
    actorEmail: v.string(),        // Denormalized for display — no join required
    actorName: v.string(),         // Same reason: accounts get deleted, names change
    action: v.string(),            // "document.created", "member.role_changed", etc.
    resourceType: v.string(),      // "document", "member", "role", "settings"
    resourceId: v.optional(v.string()),    // ID of the affected resource
    resourceLabel: v.optional(v.string()), // Human-readable name at the time of action
    metadata: v.optional(v.any()),         // Action-specific context: before/after diffs, etc.
    ipAddress: v.optional(v.string()),
  })
    .index("by_organization", ["organizationId"])
    .index("by_org_and_actor", ["organizationId", "actorId"])
    .index("by_org_and_resource_type", ["organizationId", "resourceType"]),
});
Enter fullscreen mode Exit fullscreen mode

Several design decisions here are worth explaining before you move on.

Denormalize actor fields. actorEmail and actorName are stored directly on the log entry — not as foreign keys to a users table. This means you never need a join to render the activity feed, and the log permanently records who performed the action at the time it happened, not who the account belongs to now. User display names change. Accounts get suspended or deleted. People leave companies. The audit trail must be immutable after write, which means capturing the actor's identity at write time.

Use a string for actorId, not a Convex document ID. Kinde user IDs are strings (e.g. kp_abc123def). Storing the Kinde ID directly means no lookup layer and no dependency on your local users table — the ID remains valid and meaningful even if you migrate or restructure your user data.

Store resourceLabel. When a document is deleted, the document is gone. If you only stored the resource ID, the log entry for document.deleted would show a dead reference with no human-readable context. Capture the label at write time, before the deletion.

Three indexes, not one. The primary access pattern is all logs for an org ordered by time descending. The secondary patterns are filtering by actor ("show me everything Alice did this week") and filtering by resource type ("show me all role changes"). Three focused indexes handle all three without full table scans. Per Convex best practices, by_org_and_resource_type is compound — you always scope to an organization first, so a standalone by_resource_type index would be redundant.

metadata is typed loosely by design. A document.shared event needs to capture who it was shared with. A member.role_changed event needs the before and after role values. A settings.updated event needs the field that changed and its old and new values. Rather than adding a column for every possible shape, metadata holds the action-specific payload and you validate it at the call site.

The activityLogs table schema with all fields and their types listed. A dotted arrow connects organizationId to a separate organizations table box, labeled

The auditLog Helper

Create convex/lib/audit.ts. Every mutation in your app will call this one function. Centralizing it here means you change audit log behavior in exactly one place.

// convex/lib/audit.ts
import { MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";

export type AuditAction =
  | "document.created"
  | "document.updated"
  | "document.deleted"
  | "document.shared"
  | "document.unshared"
  | "member.invited"
  | "member.removed"
  | "member.role_changed"
  | "invitation.accepted"
  | "invitation.revoked"
  | "role.created"
  | "role.updated"
  | "role.deleted"
  | "settings.updated"
  | "billing.plan_changed"
  | "org.created";

export type ResourceType =
  | "document"
  | "member"
  | "invitation"
  | "role"
  | "settings"
  | "billing"
  | "org";

interface AuditLogParams {
  ctx: MutationCtx;
  organizationId: Id<"organizations">;
  actorId: string;
  actorEmail: string;
  actorName: string;
  action: AuditAction;
  resourceType: ResourceType;
  resourceId?: string;
  resourceLabel?: string;
  metadata?: Record<string, unknown>;
  ipAddress?: string;
}

export async function auditLog({
  ctx,
  organizationId,
  actorId,
  actorEmail,
  actorName,
  action,
  resourceType,
  resourceId,
  resourceLabel,
  metadata,
  ipAddress,
}: AuditLogParams): Promise<void> {
  await ctx.db.insert("activityLogs", {
    organizationId,
    actorId,
    actorEmail,
    actorName,
    action,
    resourceType,
    resourceId,
    resourceLabel,
    metadata,
    ipAddress,
  });
}
Enter fullscreen mode Exit fullscreen mode

The function signature is explicit by design. AuditAction and ResourceType are union types — TypeScript enforces them at compile time, so a typo like "document.creatd" fails the build rather than silently writing a malformed entry into your audit trail. The function does exactly one thing: insert. Any filtering, validation, or transformation happens at the call site inside the mutation that needs it.

Pulling Actor Identity From Kinde

Before looking at how mutations call auditLog, you need a reliable way to get the current user's Kinde ID, email, and display name inside a Convex mutation.

The standard pattern is a getCurrentUser helper that queries your local users table using the Kinde user ID embedded in the Convex auth token. Your users table gets populated during the post-login callback — either via a Convex mutation called from the Next.js auth handler, or by checking on first load.

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "../_generated/server";

export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
  // ctx.auth.getUserIdentity() reads the JWT Kinde issues after login.
  // The `subject` claim is the Kinde user ID (e.g. "kp_abc123def").
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) return null;

  const kindeId = identity.subject;

  const user = await ctx.db
    .query("users")
    .withIndex("by_kinde_id", (q) => q.eq("kindeId", kindeId))
    .unique();

  return user;
}
Enter fullscreen mode Exit fullscreen mode

Your users table needs kindeId, email, and name fields. When a user first authenticates through Kinde, those three values are written to the table from the token claims. The auditLog helper reads them and writes them directly onto the log entry — no live lookup, no join, immutable from that point forward.

Kinde's @kinde-oss/kinde-auth-nextjs SDK handles token issuance on the Next.js side. Convex picks up the token automatically when you configure it to use Kinde as the auth provider via the Convex dashboard's authentication settings.

Step #1: Call auditLog Inside Your Mutations

The pattern is identical across every mutation: do the database write, then call auditLog before returning. Because Convex mutations are transactional, the primary write and the log entry commit together or not at all.

Document creation

// convex/documents.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getCurrentUser, requirePermission } from "./lib/auth";
import { auditLog } from "./lib/audit";

export const create = mutation({
  args: {
    organizationId: v.id("organizations"),
    title: v.string(),
    content: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { user } = await requirePermission(ctx, args.organizationId, "documents:create");

    const documentId = await ctx.db.insert("documents", {
      organizationId: args.organizationId,
      title: args.title,
      content: args.content ?? "",
      createdBy: user._id,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    await auditLog({
      ctx,
      organizationId: args.organizationId,
      actorId: user.kindeId,
      actorEmail: user.email,
      actorName: user.name,
      action: "document.created",
      resourceType: "document",
      resourceId: documentId,
      resourceLabel: args.title,
    });

    return documentId;
  },
});
Enter fullscreen mode Exit fullscreen mode

Document deletion

Read the title before the delete. Once ctx.db.delete runs, that document is gone. If you try to fetch the title after, you get nothing.

export const remove = mutation({
  args: {
    organizationId: v.id("organizations"),
    documentId: v.id("documents"),
  },
  handler: async (ctx, args) => {
    const { user } = await requirePermission(ctx, args.organizationId, "documents:delete");

    const document = await ctx.db.get(args.documentId);
    if (!document) throw new Error("Document not found");
    if (document.organizationId !== args.organizationId) throw new Error("Document not found");

    // Capture the label BEFORE the delete — after, it no longer exists
    const documentTitle = document.title;

    await ctx.db.delete(args.documentId);

    await auditLog({
      ctx,
      organizationId: args.organizationId,
      actorId: user.kindeId,
      actorEmail: user.email,
      actorName: user.name,
      action: "document.deleted",
      resourceType: "document",
      resourceId: args.documentId,
      resourceLabel: documentTitle,
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

Member role change

This one captures a before/after diff in metadata. When someone reviews the log later, they see exactly what changed without querying another table.

// convex/organizations.ts
export const updateMemberRole = mutation({
  args: {
    organizationId: v.id("organizations"),
    membershipId: v.id("organizationMembers"),
    newRole: v.string(),
  },
  handler: async (ctx, args) => {
    const { user } = await requirePermission(ctx, args.organizationId, "members:manage");

    const membership = await ctx.db.get(args.membershipId);
    if (!membership) throw new Error("Member not found");
    if (membership.organizationId !== args.organizationId) throw new Error("Member not found");

    // Read the previous role before patching
    const previousRole = membership.role;

    await ctx.db.patch(args.membershipId, {
      role: args.newRole,
    });

    // Fetch the affected user's display info for the log label
    const affectedUser = await ctx.db.get(membership.userId);

    await auditLog({
      ctx,
      organizationId: args.organizationId,
      actorId: user.kindeId,
      actorEmail: user.email,
      actorName: user.name,
      action: "member.role_changed",
      resourceType: "member",
      resourceId: membership.userId,
      resourceLabel: affectedUser?.name ?? affectedUser?.email ?? "Unknown user",
      metadata: {
        previousRole,
        newRole: args.newRole,
      },
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

Settings update

// convex/settings.ts
export const update = mutation({
  args: {
    organizationId: v.id("organizations"),
    field: v.string(),
    value: v.any(),
  },
  handler: async (ctx, args) => {
    const { user } = await requirePermission(ctx, args.organizationId, "settings:manage");

    const settings = await ctx.db
      .query("organizationSettings")
      .withIndex("by_organization", (q) =>
        q.eq("organizationId", args.organizationId)
      )
      .unique();

    if (!settings) throw new Error("Settings not found");

    const previousValue = (settings as any)[args.field];

    await ctx.db.patch(settings._id, {
      [args.field]: args.value,
      updatedAt: Date.now(),
    });

    await auditLog({
      ctx,
      organizationId: args.organizationId,
      actorId: user.kindeId,
      actorEmail: user.email,
      actorName: user.name,
      action: "settings.updated",
      resourceType: "settings",
      resourceLabel: args.field,
      metadata: {
        field: args.field,
        previousValue,
        newValue: args.value,
      },
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

The metadata pattern is consistent across all mutations: capture what changed, what it was before, what it is now. The log entry is self-contained — a compliance auditor reading it does not need to cross-reference other tables or reconstruct state from timestamps.

Step #2: Query the Activity Log

Create the query functions in convex/activityLogs.ts. These are what the frontend subscribes to.

// convex/activityLogs.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { requireOrganizationAccess } from "./lib/auth";

// All activity for an org, paginated, most recent first
export const list = query({
  args: {
    organizationId: v.id("organizations"),
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.union(v.string(), v.null()),
    }),
  },
  handler: async (ctx, { organizationId, paginationOpts }) => {
    await requireOrganizationAccess(ctx, organizationId);

    return ctx.db
      .query("activityLogs")
      .withIndex("by_organization", (q) =>
        q.eq("organizationId", organizationId)
      )
      .order("desc")
      .paginate(paginationOpts);
  },
});

// Filtered by actor — "show me everything Alice did"
export const listByActor = query({
  args: {
    organizationId: v.id("organizations"),
    actorId: v.string(),
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.union(v.string(), v.null()),
    }),
  },
  handler: async (ctx, { organizationId, actorId, paginationOpts }) => {
    await requireOrganizationAccess(ctx, organizationId);

    return ctx.db
      .query("activityLogs")
      .withIndex("by_org_and_actor", (q) =>
        q.eq("organizationId", organizationId).eq("actorId", actorId)
      )
      .order("desc")
      .paginate(paginationOpts);
  },
});

// Filtered by resource type — "show me all role changes"
export const listByResourceType = query({
  args: {
    organizationId: v.id("organizations"),
    resourceType: v.string(),
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.union(v.string(), v.null()),
    }),
  },
  handler: async (ctx, { organizationId, resourceType, paginationOpts }) => {
    await requireOrganizationAccess(ctx, organizationId);

    return ctx.db
      .query("activityLogs")
      .withIndex("by_org_and_resource_type", (q) =>
        q.eq("organizationId", organizationId).eq("resourceType", resourceType)
      )
      .order("desc")
      .paginate(paginationOpts);
  },
});

// Used by the CSV export endpoint — fetches all entries within a time window
export const listAllForExport = query({
  args: {
    organizationId: v.id("organizations"),
    since: v.optional(v.number()), // Unix timestamp in ms
  },
  handler: async (ctx, { organizationId, since }) => {
    await requireOrganizationAccess(ctx, organizationId);

    const results = await ctx.db
      .query("activityLogs")
      .withIndex("by_organization", (q) =>
        q.eq("organizationId", organizationId)
      )
      .order("desc")
      .collect();

    if (since) {
      return results.filter((entry) => entry._creationTime >= since);
    }

    return results;
  },
});
Enter fullscreen mode Exit fullscreen mode

Every query starts with requireOrganizationAccess. This is not optional. Audit log data is sensitive — the fact that it is read-only does not make it public. A user with access to Org A must not be able to call activityLogs.list with Org B's ID and receive results. The access check runs before any data is read.

Step #3: Build the Activity Feed UI

Add the page at /org/[slug]/activity. It shows all activity with filter pills for resource type, loads more entries on scroll, and updates in real time as new entries arrive.

// app/org/[slug]/activity/page.tsx
"use client";

import { usePaginatedQuery, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useState } from "react";
import { formatDistanceToNow } from "date-fns";

const ACTION_LABELS: Record<string, string> = {
  "document.created": "created a document",
  "document.updated": "updated a document",
  "document.deleted": "deleted a document",
  "document.shared": "shared a document",
  "document.unshared": "unshared a document",
  "member.invited": "invited a member",
  "member.removed": "removed a member",
  "member.role_changed": "changed a member's role",
  "invitation.accepted": "accepted an invitation",
  "invitation.revoked": "revoked an invitation",
  "role.created": "created a role",
  "role.updated": "updated a role",
  "role.deleted": "deleted a role",
  "settings.updated": "updated settings",
  "billing.plan_changed": "changed the billing plan",
  "org.created": "created the organization",
};

const RESOURCE_TYPE_FILTERS = [
  { value: "", label: "All activity" },
  { value: "document", label: "Documents" },
  { value: "member", label: "Members" },
  { value: "role", label: "Roles" },
  { value: "settings", label: "Settings" },
  { value: "billing", label: "Billing" },
];

export default function ActivityPage({ params }: { params: { slug: string } }) {
  const org = useQuery(api.organizations.getBySlug, { slug: params.slug });
  const [resourceTypeFilter, setResourceTypeFilter] = useState("");

  const { results, status, loadMore } = usePaginatedQuery(
    resourceTypeFilter
      ? api.activityLogs.listByResourceType
      : api.activityLogs.list,
    org
      ? resourceTypeFilter
        ? { organizationId: org._id, resourceType: resourceTypeFilter }
        : { organizationId: org._id }
      : "skip",
    { initialNumItems: 25 }
  );

  if (!org) {
    return <div className="p-8 text-sm text-gray-500">Loading...</div>;
  }

  return (
    <div className="max-w-2xl mx-auto py-8 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-xl font-semibold">Activity</h1>
          <p className="text-sm text-gray-500 mt-0.5">
            A record of every action taken in this organization.
          </p>
        </div>
        <a
          href={`/api/activity/export?orgId=${org._id}`}
          className="text-sm text-gray-500 hover:text-gray-900 underline underline-offset-2"
        >
          Export CSV
        </a>
      </div>

      {/* Filter pills */}
      <div className="flex gap-2 flex-wrap">
        {RESOURCE_TYPE_FILTERS.map((filter) => (
          <button
            key={filter.value}
            onClick={() => setResourceTypeFilter(filter.value)}
            className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
              resourceTypeFilter === filter.value
                ? "bg-black text-white"
                : "bg-gray-100 text-gray-600 hover:bg-gray-200"
            }`}
          >
            {filter.label}
          </button>
        ))}
      </div>

      {/* Log entries */}
      <div className="space-y-1">
        {results.length === 0 && status === "Exhausted" ? (
          <p className="text-sm text-gray-400 py-8 text-center">No activity yet.</p>
        ) : (
          results.map((entry) => (
            <div
              key={entry._id}
              className="flex items-start gap-3 py-3 border-b border-gray-100 last:border-0"
            >
              {/* Actor avatar */}
              <div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center flex-shrink-0 mt-0.5">
                <span className="text-xs font-medium text-gray-600">
                  {entry.actorName?.[0]?.toUpperCase() ?? "?"}
                </span>
              </div>

              <div className="flex-1 min-w-0">
                <p className="text-sm text-gray-900">
                  <span className="font-medium">{entry.actorName}</span>
                  {" "}
                  <span className="text-gray-600">
                    {ACTION_LABELS[entry.action] ?? entry.action}
                  </span>
                  {entry.resourceLabel && (
                    <>
                      {": "}
                      <span className="font-medium text-gray-900">
                        {entry.resourceLabel}
                      </span>
                    </>
                  )}
                </p>

                {/* Show before/after for role changes */}
                {entry.action === "member.role_changed" && entry.metadata && (
                  <p className="text-xs text-gray-400 mt-0.5">
                    {(entry.metadata as any).previousRole}{(entry.metadata as any).newRole}
                  </p>
                )}

                {/* Show which field changed for settings updates */}
                {entry.action === "settings.updated" && entry.metadata && (
                  <p className="text-xs text-gray-400 mt-0.5">
                    Field: {(entry.metadata as any).field}
                  </p>
                )}

                <p className="text-xs text-gray-400 mt-0.5">
                  {formatDistanceToNow(entry._creationTime, { addSuffix: true })}
                  {" · "}
                  {entry.actorEmail}
                </p>
              </div>
            </div>
          ))
        )}
      </div>

      {status === "CanLoadMore" && (
        <div className="flex justify-center pt-2">
          <button
            onClick={() => loadMore(25)}
            className="text-sm text-gray-500 hover:text-gray-900 underline underline-offset-2"
          >
            Load more
          </button>
        </div>
      )}

      {status === "LoadingMore" && (
        <p className="text-center text-sm text-gray-400">Loading...</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The feed is reactive. Convex tracks the query's data dependencies and pushes updates to the component whenever a new log entry is written. An org member watching the activity page will see new entries appear in real time — without a page refresh, without polling, without any extra setup.

The activity feed UI. Top row shows

Step #4: Export as CSV for Compliance

When a compliance team or auditor asks for the activity history, they want a file — not a browser tab. Add a Next.js API route that fetches all logs for an org and returns them as a downloadable CSV.

// app/api/activity/export/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export async function GET(req: NextRequest) {
  const { isAuthenticated } = getKindeServerSession();

  const authenticated = await isAuthenticated();
  if (!authenticated) {
    return new NextResponse("Unauthorized", { status: 401 });
  }

  const orgId = req.nextUrl.searchParams.get("orgId");
  if (!orgId) {
    return new NextResponse("Missing orgId", { status: 400 });
  }

  // Optional: limit to last 90 days with ?since=<timestamp>
  const since = req.nextUrl.searchParams.get("since");

  const logs = await convex.query(api.activityLogs.listAllForExport, {
    organizationId: orgId as Id<"organizations">,
    since: since ? Number(since) : undefined,
  });

  const csvRows = [
    ["Timestamp", "Actor Name", "Actor Email", "Action", "Resource Type", "Resource", "Details"].join(","),
    ...logs.map((entry) =>
      [
        new Date(entry._creationTime).toISOString(),
        `"${entry.actorName.replace(/"/g, '""')}"`,
        entry.actorEmail,
        entry.action,
        entry.resourceType,
        `"${(entry.resourceLabel ?? "").replace(/"/g, '""')}"`,
        `"${entry.metadata ? JSON.stringify(entry.metadata).replace(/"/g, '""') : ""}"`,
      ].join(",")
    ),
  ].join("\n");

  return new NextResponse(csvRows, {
    status: 200,
    headers: {
      "Content-Type": "text/csv",
      "Content-Disposition": `attachment; filename="activity-${orgId}-${Date.now()}.csv"`,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

getKindeServerSession from @kinde-oss/kinde-auth-nextjs/server verifies the user is authenticated before any data is touched. The ConvexHttpClient is the correct way to call Convex from a Next.js API route or server action — outside of a React component, useQuery is not available, so you use the HTTP client directly.

You now have a complete audit log — write path, read path, real-time feed, and CSV export in one clean pipeline.

Step #5: Guarantee Org Isolation

Org isolation is not automatic. You enforce it explicitly at every query boundary with requireOrganizationAccess. Here is what that helper looks like and why the order of checks matters.

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";

export async function requireOrganizationAccess(
  ctx: QueryCtx | MutationCtx,
  organizationId: Id<"organizations">
) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Not authenticated");

  const user = await ctx.db
    .query("users")
    .withIndex("by_kinde_id", (q) => q.eq("kindeId", identity.subject))
    .unique();

  if (!user) throw new Error("User not found");

  const membership = await ctx.db
    .query("organizationMembers")
    .withIndex("by_org_and_user", (q) =>
      q.eq("organizationId", organizationId).eq("userId", user._id)
    )
    .unique();

  // No membership record = no access. This check runs before any log data is read.
  if (!membership) throw new Error("Access denied");

  return { user, membership };
}
Enter fullscreen mode Exit fullscreen mode

When activityLogs.list is called with an organization ID, requireOrganizationAccess checks for a membership record in that specific org before the log query runs. A member of Org A cannot call the query with Org B's ID and receive results — the check throws before any database read happens.

The isolation guarantee has two layers. First, the membership check in the query handler. Second, the organizationId field on every log entry — each query is scoped to one org's index range. Even if the membership check somehow failed, a query hitting by_organization would only return entries for that specific org.

Two side-by-side columns labeled

Testing the System

Do not skip this. An audit log that silently misses entries is worse than no audit log — it creates false confidence. These five tests verify the critical paths.

Test 1: Transactional commit

Call documents.remove with a valid document ID. After it succeeds, open the Convex dashboard and check the activityLogs table — a document.deleted entry should exist with the correct resourceLabel. Now temporarily patch the mutation to throw an error after ctx.db.delete but before auditLog. Call it again. Both the deletion and the log entry should roll back together. If the document gets deleted but no log entry appears, your auditLog call is outside the mutation handler — move it inside.

Test 2: Actor identity per session

Sign in as two different users and each create a document in the same organization. Check activityLogs — the two entries should have different actorId, actorEmail, and actorName values. If both entries show the same actor, getCurrentUser is reading from a stale or shared identity source.

Test 3: Org isolation under direct API call

From the Convex dashboard, call activityLogs.list with the ID of an organization the currently authenticated user does not belong to. You should receive:

Error: Access denied
Enter fullscreen mode Exit fullscreen mode

If the query returns data, requireOrganizationAccess is either missing or not throwing correctly.

Test 4: Resource label captured before deletion

Create a document titled "Q2 Vendor Report". Delete it. Find the document.deleted entry in activityLogs. The resourceLabel field should read "Q2 Vendor Report". If it is null or undefined, the delete is happening before the title is read. Reverse the order.

Test 5: Metadata correctness on role changes

Change a member's role from member to admin. Find the member.role_changed log entry and expand metadata. It should contain { "previousRole": "member", "newRole": "admin" }. If previousRole is null, the membership record is being read after the patch — capture the previous role before calling ctx.db.patch.

Common Mistakes to Avoid

Capturing resource labels after the mutation. Read everything you need from the affected resource before modifying or deleting it. After ctx.db.delete, the document is gone. After ctx.db.patch, the original field values are overwritten in the current context. Read first, write second, log third.

Calling a side-effect logging service after the mutation returns. If you call an external logging service after the Convex mutation completes, the log entry and the action are not transactionally coupled. A network failure or a crash between the mutation completing and the side-effect firing gives you a permanent inconsistency in your audit trail. Keep auditLog inside the Convex mutation.

Skipping requireOrganizationAccess on log queries. Audit log data is sensitive. The fact that it is read-only does not make it safe to skip the access check. Every query that reads activityLogs needs the membership check, no exceptions.

Storing a Convex document ID instead of the Kinde user ID as actorId. Convex document IDs change if you migrate or restructure data. Kinde user IDs are stable identifiers issued by the identity provider. Use the Kinde ID.

Not filtering by organizationId on every log query. A query that reads from activityLogs without an organizationId constraint is a full table scan that will slow down as the table grows and expose cross-org data. Every query goes through an index that starts with organizationId.

What This Enables

An enterprise customer's IT team can answer "who accessed what and when" directly from the activity feed — no support ticket, no engineering involvement.

Your compliance team can export a CSV of all activity within any date range and hand it to an auditor without writing a single SQL query.

When a user claims their settings were changed without their knowledge, you check the log and show them exactly who made the change and when — in under a minute.

Enterprise deals that stalled on the audit log question now have a concrete answer: every action is logged at write time, every entry is scoped to one organization, the data is exportable on demand, and the log is tamper-evident by design.

What Is Next

Retention policies. Add a scheduled Convex function that runs nightly and deletes log entries older than your configured retention window. Store the retention duration on the organization record so you can offer longer retention as a paid feature for enterprise tiers.

Admin-only visibility. Right now any org member who can reach the activity route can view the feed. Add a audit:read permission check inside the query handler — the membership check is already there, it is a small addition — and gate the page to admins and owners only.

Webhook delivery. Enterprise customers with SIEM (security information and event management) tools will ask for activity data pushed to them in real time. Add a Convex action that fires a webhook to a customer-configured URL after each log entry is written. The payload is the log entry itself.

Full-text search. For orgs with high activity volume, time-range and resource-type filters are not always enough. Convex supports full-text search indexes. Adding one on action and resourceLabel lets you support queries like "show all activity mentioning 'Q2 Report'".

Top comments (0)