Skip to content

Audit Logging ​

Every create, update, and delete operation in Grant produces an audit log entry — written in the same database transaction as the data change, guaranteeing consistency.

Schema ​

All entity audit log tables follow a standardized structure:

typescript
export const entityAuditLogs = pgTable('entity_audit_logs', {
  id: uuid('id').primaryKey().defaultRandom(),
  entityId: uuid('entity_id')
    .references(() => entity.id)
    .notNull(),
  action: varchar('action', { length: 50 }).notNull(),
  oldValues: varchar('old_values', { length: 1000 }),
  newValues: varchar('new_values', { length: 1000 }),
  metadata: varchar('metadata', { length: 1000 }),
  performedBy: uuid('performed_by').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  scopeTenant: varchar('scope_tenant', { length: 50 }),
  scopeId: varchar('scope_id', { length: 255 }),
});

Scope fields enable tenant-scoped queries:

  • scopeTenant — Tenant type (e.g., organization, organizationProject). Always set from the authenticated request context, never from user input.
  • scopeId — Tenant identifier (e.g., orgId or orgId:projectId).

Indexed on entityId, action, and scopeTenant for efficient lookups.

Standard Actions ​

ActionWhen
CREATENew entity created
UPDATEEntity modified
DELETEEntity soft-deleted
RESTORESoft-deleted entity restored
BULK_CREATEBatch create
BULK_UPDATEBatch update
BULK_DELETEBatch delete

Repositories can define custom actions for domain-specific operations (e.g., PASSWORD_RESET, PERMISSION_GRANTED).

How It Works ​

Every repository operation writes the audit log inside the same transaction:

typescript
return this.db.transaction(async (tx) => {
  const [entity] = await tx.insert(entities).values(data).returning();
  await tx.insert(entityAuditLogs).values({
    entityId: entity.id,
    action: 'CREATE',
    newValues: JSON.stringify(entity),
    performedBy,
    scopeTenant, // from auth context
    scopeId, // from auth context
  });
  return entity;
});

If the entity write fails, the audit log is rolled back too — and vice versa.

Querying by Tenant ​

Filter audit logs for a specific organization or project:

typescript
// All logs for an organization
const logs = await db
  .select()
  .from(projectAuditLogs)
  .where(
    and(
      eq(projectAuditLogs.scopeTenant, 'organization'),
      eq(projectAuditLogs.scopeId, organizationId)
    )
  )
  .orderBy(desc(projectAuditLogs.createdAt));

// Logs for a specific project scope
const projectLogs = await db
  .select()
  .from(projectAuditLogs)
  .where(
    and(
      eq(projectAuditLogs.scopeTenant, 'organizationProject'),
      eq(projectAuditLogs.scopeId, `${organizationId}:${projectId}`)
    )
  )
  .orderBy(desc(projectAuditLogs.createdAt));

Data Retention ​

Audit logs accumulate over time. Consider archiving or purging entries older than your compliance requirement (e.g., 365 days). The data retention cleanup job can be extended for this purpose — see Job Scheduling.


Related:

Released under the MIT License.