Repository: lightdash/lightdash
Pull request #14416: feat: add audit logging for casl checks
Author: login: owlas
Labels: name: released
Created: 2025-04-15T07:30:09Z | Merged: 2025-04-23T15:17:37Z

Logs out permission checks for auditing. This doesn't actually enable the wrapper yet.

- Adds CASL wrapper to enable logging
- Adds winston log levels for auditing
- Adds tests for CASL formatting

Example check from the dashboard (not included in this PR)

```
{
  "action": "view",
  "actor": {
    "email": "demo@lightdash.com",
    "firstName": "David",
    "groupMemberships": [],
    "lastName": "Attenborough",
    "organizationRole": "admin",
    "organizationUuid": "172a2270-000f-42be-9c68-c4752c23ae51",
    "uuid": "b264d83a-9000-426a-85ec-3f9c20f368ce"
  },
  "context": {},
  "id": "fbd1f07a-df3f-4d40-8005-62e48339c34b",
  "level": "audit",
  "message": "view Dashboard 747c7e64-cffb-4e72-b92c-9b035c8e17d5 by b264d83a-9000-426a-85ec-3f9c20f368ce (allowed)",
  "resource": {
    "name": "Jaffle dashboard",
    "organizationUuid": "172a2270-000f-42be-9c68-c4752c23ae51",
    "projectUuid": "3675b69e-8324-4110-bdca-059031aa8da3",
    "type": "Dashboard",
    "uuid": "747c7e64-cffb-4e72-b92c-9b035c8e17d5"
  },
  "ruleConditions": "{\"organizationUuid\":\"172a2270-000f-42be-9c68-c4752c23ae51\"}",
  "sentryTraceId": "f68d4089573a4d59a1cec855114fa8ad",
  "status": "allowed",
  "timestamp": "2025-04-16 12:13:17",
  "trace": "projects/lightdash-cloud-beta/traces/f68d4089573a4d59a1cec855114fa8ad"
}
```


Diff:
diff --git a/packages/backend/src/config/parseConfig.ts b/packages/backend/src/config/parseConfig.ts
index 7b4f43a68dd4a..0709c86dc027e 100644
--- a/packages/backend/src/config/parseConfig.ts
+++ b/packages/backend/src/config/parseConfig.ts
@@ -188,13 +188,13 @@ export const getPemFileContent = (certValue: string | undefined) =>
         decodeUnlessStartsWith: '-----BEGIN ', // -----BEGIN CERTIFICATE | -----BEGIN PRIVATE KEY
     });
 
-type LoggingLevel = 'error' | 'warn' | 'info' | 'http' | 'debug';
+type LoggingLevel = 'error' | 'warn' | 'info' | 'http' | 'debug' | 'audit';
 const assertIsLoggingLevel = (x: string): x is LoggingLevel =>
-    ['error', 'warn', 'info', 'http', 'debug'].includes(x);
+    ['error', 'warn', 'info', 'http', 'debug', 'audit'].includes(x);
 const parseLoggingLevel = (raw: string): LoggingLevel => {
     if (!assertIsLoggingLevel(raw)) {
         throw new ParseError(
-            `Cannot parse environment variable "LIGHTDASH_LOG_LEVEL". Value must be one of "error", "warn", "info", "debug" but LIGHTDASH_LOG_LEVEL=${raw}`,
+            `Cannot parse environment variable "LIGHTDASH_LOG_LEVEL". Value must be one of "error", "warn", "info", "debug", "audit" but LIGHTDASH_LOG_LEVEL=${raw}`,
         );
     }
     return raw;
diff --git a/packages/backend/src/logging/auditLog.ts b/packages/backend/src/logging/auditLog.ts
new file mode 100644
index 0000000000000..0c49d52250d22
--- /dev/null
+++ b/packages/backend/src/logging/auditLog.ts
@@ -0,0 +1,72 @@
+import { v4 as uuidv4 } from 'uuid';
+import { z } from 'zod';
+
+export type AuditStatusType = 'allowed' | 'denied';
+
+export const AuditStatusSchema = z.enum(['allowed', 'denied']);
+
+export const AuditActorSchema = z.object({
+    uuid: z.string(),
+    firstName: z.string().optional(),
+    lastName: z.string().optional(),
+    email: z.string().optional(),
+    organizationUuid: z.string(),
+    organizationRole: z.string(),
+    groupMemberships: z.array(z.string()).optional(),
+});
+
+export type AuditActor = z.infer<typeof AuditActorSchema>;
+
+export const AuditResourceSchema = z.object({
+    type: z.string(),
+    uuid: z.string().optional(),
+    name: z.string().optional(),
+    organizationUuid: z.string(),
+    projectUuid: z.string().optional(),
+});
+
+export type AuditResource = z.infer<typeof AuditResourceSchema>;
+
+export const AuditContextSchema = z.object({
+    ip: z.string().optional(),
+    userAgent: z.string().optional(),
+    requestId: z.string().optional(),
+});
+
+export type AuditContext = z.infer<typeof AuditContextSchema>;
+
+export const AuditLogEventSchema = z.object({
+    id: z.string().default(() => uuidv4()),
+    timestamp: z.string().default(() => new Date().toISOString()),
+    actor: AuditActorSchema,
+    action: z.string(),
+    resource: AuditResourceSchema,
+    context: AuditContextSchema,
+    status: AuditStatusSchema,
+    reason: z.string().optional(),
+    ruleConditions: z.string().optional(),
+});
+
+export type AuditLogEvent = z.infer<typeof AuditLogEventSchema>;
+
+export const validateAuditLogEvent = (event: unknown): AuditLogEvent =>
+    AuditLogEventSchema.parse(event);
+
+export const createAuditLogEvent = (
+    actor: AuditActor,
+    action: string,
+    resource: AuditResource,
+    context: AuditContext,
+    status: AuditStatusType,
+    reason?: string,
+    ruleConditions?: string,
+): AuditLogEvent =>
+    validateAuditLogEvent({
+        actor,
+        action,
+        resource,
+        context,
+        status,
+        reason,
+        ruleConditions,
+    });
diff --git a/packages/backend/src/logging/caslAuditWrapper.test.ts b/packages/backend/src/logging/caslAuditWrapper.test.ts
new file mode 100644
index 0000000000000..055eb677039d5
--- /dev/null
+++ b/packages/backend/src/logging/caslAuditWrapper.test.ts
@@ -0,0 +1,327 @@
+import { defineAbility, subject } from '@casl/ability';
+import { ForcedSubject, type AnyObject } from '@casl/ability/dist/types/types';
+import { CaslSubjectNames, OrganizationMemberRole } from '@lightdash/common';
+import { type AuditLogEvent } from './auditLog';
+import {
+    CaslAuditWrapper,
+    type AuditLogger,
+    type AuditableUser,
+} from './caslAuditWrapper';
+
+// Test subjects
+const createDashboard = (uuid: string, attributes: AnyObject = {}) =>
+    subject('Dashboard', {
+        uuid,
+        organizationUuid: 'test-org-uuid',
+        ...attributes,
+    });
+
+const createSavedChart = (uuid: string, attributes: AnyObject = {}) =>
+    subject('SavedChart', {
+        uuid,
+        organizationUuid: 'test-org-uuid',
+        ...attributes,
+    });
+
+describe('CaslAuditWrapper', () => {
+    // Create a user with only the required fields for AuditableUser
+    const mockUser: AuditableUser = {
+        userUuid: 'test-user-uuid',
+        email: 'test@example.com',
+        firstName: 'Test',
+        lastName: 'User',
+        organizationUuid: 'test-org-uuid',
+        role: OrganizationMemberRole.MEMBER,
+    };
+
+    // Use function to create fresh instances for each test
+    const createMockLogger = (): jest.Mock<void, [AuditLogEvent]> =>
+        jest.fn<void, [AuditLogEvent]>();
+
+    // Tests run with a simple set of mock abilities - this does not test the real abilities/permissions in Lightdash
+    const createTestAbility = () =>
+        defineAbility((can, cannot) => {
+            // Simple rule
+            can('read', 'Dashboard');
+
+            // Rule with conditions
+            can('update', 'Dashboard', { authorId: mockUser.userUuid });
+
+            // Rule with reason
+            can('delete', 'Dashboard', { authorId: mockUser.userUuid }).because(
+                'User is the author',
+            );
+
+            // Forbidden rule with conditions
+            cannot('read', 'Dashboard', { isPrivate: true }).because(
+                'Private dashboards are not readable',
+            );
+
+            // Rule with multiple conditions
+            can('manage', 'SavedChart', {
+                authorId: mockUser.userUuid,
+                status: 'published',
+            });
+        });
+
+    // Create a new wrapper for each test - cast ability to avoid complex type issues
+    const createWrapper = (mockLogger: jest.Mock<void, [AuditLogEvent]>) =>
+        new CaslAuditWrapper(
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            createTestAbility() as any,
+            mockUser,
+            {
+                ip: '127.0.0.1',
+                userAgent: 'test-agent',
+                requestId: 'test-request-id',
+                auditLogger: mockLogger as AuditLogger,
+            },
+        );
+
+    afterEach(() => {
+        jest.clearAllMocks();
+    });
+
+    describe('can method', () => {
+        it('should return the same result as the original ability', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const ability = createTestAbility();
+
+            const publicDashboard = createDashboard('1', {
+                authorId: 'someone-else',
+            });
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+            const privateDashboard = createDashboard('3', { isPrivate: true });
+
+            // Test various scenarios
+            expect(wrapper.can('read', publicDashboard)).toBe(
+                ability.can('read', publicDashboard),
+            );
+            expect(wrapper.can('update', userDashboard)).toBe(
+                ability.can('update', userDashboard),
+            );
+            expect(wrapper.can('update', publicDashboard)).toBe(
+                ability.can('update', publicDashboard),
+            );
+            expect(wrapper.can('read', privateDashboard)).toBe(
+                ability.can('read', privateDashboard),
+            );
+        });
+
+        it('should log the audit event with allowed status when permission is granted', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+
+            // Act
+            wrapper.can('update', userDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.actor.uuid).toBe(mockUser.userUuid);
+            expect(loggedEvent.action).toBe('update');
+            expect(loggedEvent.resource.type).toBe('Dashboard');
+            expect(loggedEvent.resource.uuid).toBe('2');
+            expect(loggedEvent.status).toBe('allowed');
+            expect(loggedEvent.context.ip).toBe('127.0.0.1');
+            expect(loggedEvent.context.userAgent).toBe('test-agent');
+            expect(loggedEvent.context.requestId).toBe('test-request-id');
+        });
+
+        it('should log the audit event with denied status when permission is denied', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const privateDashboard = createDashboard('3', { isPrivate: true });
+
+            // Act
+            wrapper.can('read', privateDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.actor.uuid).toBe(mockUser.userUuid);
+            expect(loggedEvent.action).toBe('read');
+            expect(loggedEvent.resource.type).toBe('Dashboard');
+            expect(loggedEvent.resource.uuid).toBe('3');
+            expect(loggedEvent.status).toBe('denied');
+        });
+
+        it('should include the reason when provided by a rule', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+
+            // Act
+            wrapper.can('delete', userDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.reason).toBe('User is the author');
+        });
+
+        it('should include properly formatted rule conditions', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+
+            // Act
+            wrapper.can('update', userDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.ruleConditions).toBe(
+                JSON.stringify({ authorId: mockUser.userUuid }),
+            );
+        });
+
+        it('should include complex rule conditions correctly', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const userSavedChart = createSavedChart('1', {
+                authorId: mockUser.userUuid,
+                status: 'published',
+            });
+
+            // Act
+            wrapper.can('manage', userSavedChart);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.ruleConditions).toBe(
+                JSON.stringify({
+                    authorId: mockUser.userUuid,
+                    status: 'published',
+                }),
+            );
+        });
+    });
+
+    describe('cannot method', () => {
+        it('should return the same result as the original ability', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const ability = createTestAbility();
+
+            const publicDashboard = createDashboard('1', {
+                authorId: 'someone-else',
+            });
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+            const privateDashboard = createDashboard('3', { isPrivate: true });
+
+            // Test various scenarios
+            expect(wrapper.cannot('read', publicDashboard)).toBe(
+                ability.cannot('read', publicDashboard),
+            );
+            expect(wrapper.cannot('update', userDashboard)).toBe(
+                ability.cannot('update', userDashboard),
+            );
+            expect(wrapper.cannot('update', publicDashboard)).toBe(
+                ability.cannot('update', publicDashboard),
+            );
+            expect(wrapper.cannot('read', privateDashboard)).toBe(
+                ability.cannot('read', privateDashboard),
+            );
+        });
+
+        it('should log the audit event with denied status when permission is denied', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const privateDashboard = createDashboard('3', { isPrivate: true });
+
+            // Act
+            wrapper.cannot('read', privateDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.actor.uuid).toBe(mockUser.userUuid);
+            expect(loggedEvent.action).toBe('read');
+            expect(loggedEvent.resource.type).toBe('Dashboard');
+            expect(loggedEvent.resource.uuid).toBe('3');
+            expect(loggedEvent.status).toBe('denied');
+        });
+
+        it('should log the audit event with allowed status when permission is granted', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const publicDashboard = createDashboard('1');
+
+            // Act
+            wrapper.cannot('read', publicDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.status).toBe('allowed');
+        });
+
+        it('should include the reason from cannot rules', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const privateDashboard = createDashboard('3', { isPrivate: true });
+
+            // Act
+            wrapper.cannot('read', privateDashboard);
+
+            // Assert
+            expect(mockLogger).toHaveBeenCalledTimes(1);
+
+            const loggedEvent = mockLogger.mock.calls[0][0];
+            expect(loggedEvent.reason).toBe(
+                'Private dashboards are not readable',
+            );
+        });
+    });
+
+    describe('rules property', () => {
+        it('should expose the original ability rules', () => {
+            const mockLogger = createMockLogger();
+            const wrapper = createWrapper(mockLogger);
+            const ability = createTestAbility();
+
+            expect(wrapper.rules).toEqual(ability.rules);
+        });
+    });
+
+    describe('default logger', () => {
+        it('should not throw if no logger is provided', () => {
+            // Use type assertion to satisfy the compiler
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            const ability = createTestAbility() as any;
+            const wrapperWithoutLogger = new CaslAuditWrapper(
+                ability,
+                mockUser,
+            );
+            const userDashboard = createDashboard('2', {
+                authorId: mockUser.userUuid,
+            });
+
+            // Act & Assert - should not throw
+            expect(() =>
+                wrapperWithoutLogger.can('update', userDashboard),
+            ).not.toThrow();
+        });
+    });
+});
diff --git a/packages/backend/src/logging/caslAuditWrapper.ts b/packages/backend/src/logging/caslAuditWrapper.ts
new file mode 100644
index 0000000000000..ea5150b986df7
--- /dev/null
+++ b/packages/backend/src/logging/caslAuditWrapper.ts
@@ -0,0 +1,199 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, ForcedSubject } from '@casl/ability/dist/types/types';
+import { CaslSubjectNames, type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+// Todo: can we remove the & { properties } by improving typing of CaslSubjectNames?
+type AuditableCaslSubject = ForcedSubject<CaslSubjectNames> & {
+    organizationUuid: string;
+    uuid: string;
+    name?: string;
+    projectUuid?: string;
+};
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AuditableCaslSubject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    uuid: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (
+    subject: AuditableCaslSubject,
+): AuditResource => ({
+    type: subject.__caslSubjectType__ || 'unknown',
+    uuid: subject.uuid,
+    name: subject.name,
+    organizationUuid: subject.organizationUuid,
+    projectUuid: subject.projectUuid,
+});
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    ip: args.ip,
+    userAgent: args.userAgent,
+    requestId: args.requestId,
+});
+
+// Helper function to extract conditions from a CASL Rule
+const extractRuleConditions = <A extends Abilities, C>(
+    rule: CaslRule<A, C> | null,
+): string | undefined => {
+    if (!rule) return undefined;
+
+    try {
+        // Get conditions directly from Rule object's conditions property
+        const { conditions } = rule;
+
+        if (conditions && typeof conditions === 'object') {
+            return JSON.stringify(conditions);
+        }
+
+        return undefined;
+    } catch (e) {
+        return undefined;
+    }
+};
+
+export class CaslAuditWrapper<T extends Ability> {
+    private wrappedAbility: T;
+
+    private user: AuditableUser;
+
+    private ip?: string;
+
+    private userAgent?: string;
+
+    private requestId?: string;
+
+    private auditLogger: AuditLogger;
+
+    constructor(
+        ability: T,
+        user: AuditableUser,
+        options?: {
+            ip?: string;
+            userAgent?: string;
+            requestId?: string;
+            auditLogger?: AuditLogger;
+        },
+    ) {
+        this.wrappedAbility = ability;
+        this.user = user;
+        this.ip = options?.ip;
+        this.userAgent = options?.userAgent;
+        this.requestId = options?.requestId;
+        this.auditLogger = options?.auditLogger || ((_event) => {});
+    }
+
+    private logAbilityCheck(
+        args: AuditHelperArgs,
+        status: AuditStatusType,
+        reason?: string,
+    ): void {
+        const actor = createActorFromUser(args.user);
+        const resource = createResourceFromSubject(args.subject);
+        const context = createContextFromArgs(args);
+
+        const event = createAuditLogEvent(
+            actor,
+            args.action,
+            resource,
+            context,
+            status,
+            reason,
+            args.ruleConditions,
+        );
+
+        this.auditLogger(event);
+    }
+
+    can(action: string, subject: AuditableCaslSubject): boolean {
+        const result = this.wrappedAbility.can(action, subject);
+
+        // Extract the relevant rule that allowed this permission
+        const rule = this.wrappedAbility.relevantRuleFor(action, subject);
+        const ruleConditions = extractRuleConditions(rule);
+
+        const reason = rule?.reason;
+
+        this.logAbilityCheck(
+            {
+                user: this.user,
+                action,
+                subject,
+                ip: this.ip,
+                userAgent: this.userAgent,
+                requestId: this.requestId,
+                ruleConditions,
+            },
+            result ? 'allowed' : 'denied',
+            reason,
+        );
+        return result;
+    }
+
+    cannot(action: string, subject: AuditableCaslSubject): boolean {
+        const result = this.wrappedAbility.cannot(action, subject);
+
+        const rule = this.wrappedAbility.relevantRuleFor(action, subject);
+        const ruleConditions = extractRuleConditions(rule);
+        const reason = rule?.reason;
+
+        this.logAbilityCheck(
+            {
+                user: this.user,
+                action,
+                subject,
+                ip: this.ip,
+                userAgent: this.userAgent,
+                requestId: this.requestId,
+                ruleConditions,
+            },
+            result ? 'denied' : 'allowed',
+            reason,
+        );
+        return result;
+    }
+
+    // Forward any property access to the wrapped ability
+    get rules() {
+        return this.wrappedAbility.rules;
+    }
+}
diff --git a/packages/backend/src/logging/winston.ts b/packages/backend/src/logging/winston.ts
index a8769b83e25fe..57bec09dc2f28 100644
--- a/packages/backend/src/logging/winston.ts
+++ b/packages/backend/src/logging/winston.ts
@@ -5,13 +5,15 @@ import * as expressWinston from 'express-winston';
 import ExecutionContext from 'node-execution-context';
 import * as winston from 'winston';
 import { lightdashConfig } from '../config/lightdashConfig';
+import { AuditLogEvent } from './auditLog';
 
 const levels = {
     error: 0,
     warn: 1,
     info: 2,
     http: 3,
-    debug: 4,
+    audit: 4,
+    debug: 5,
 };
 
 const colors = {
@@ -19,6 +21,7 @@ const colors = {
     warn: 'yellow',
     info: 'green',
     http: 'magenta',
+    audit: 'cyan',
     debug: 'white',
 };
 winston.addColors(colors);
@@ -130,6 +133,18 @@ export const winstonLogger = winston.createLogger({
     transports,
 });
 
+export const logAuditEvent = (event: AuditLogEvent): void => {
+    winstonLogger.log({
+        level: 'audit',
+        message: `${event.action} ${event.resource.type}${
+            event.resource.uuid ? ` ${event.resource.uuid}` : ''
+        } by ${event.actor.uuid} (${event.status})${
+            event.reason ? ` - ${event.reason}` : ''
+        }`,
+        ...event,
+    });
+};
+
 declare global {
     namespace Express {
         interface User extends SessionUser {}
diff --git a/packages/backend/src/services/DashboardService/DashboardService.ts b/packages/backend/src/services/DashboardService/DashboardService.ts
index 04a96d0463ff8..f7d63019d8cd7 100644
--- a/packages/backend/src/services/DashboardService/DashboardService.ts
+++ b/packages/backend/src/services/DashboardService/DashboardService.ts
@@ -41,6 +41,8 @@ import {
 } from '../../analytics/LightdashAnalytics';
 import { SlackClient } from '../../clients/Slack/SlackClient';
 import { getSchedulerTargetType } from '../../database/entities/scheduler';
+import { CaslAuditWrapper } from '../../logging/caslAuditWrapper';
+import { logAuditEvent } from '../../logging/winston';
 import { AnalyticsModel } from '../../models/AnalyticsModel';
 import type { CatalogModel } from '../../models/CatalogModel/CatalogModel';
 import { getChartFieldUsageChanges } from '../../models/CatalogModel/utils';
@@ -237,7 +239,12 @@ export class DashboardService extends BaseService {
             access: spaceAccess,
         };
 
-        if (user.ability.cannot('view', subject('Dashboard', dashboard))) {
+        // TODO: normally this would be pre-constructed (perhaps in the Service Repository or on the user object when we create the CASL type)
+        const auditedAbility = new CaslAuditWrapper(user.ability, user, {
+            auditLogger: logAuditEvent,
+        });
+
+        if (auditedAbility.cannot('view', subject('Dashboard', dashboard))) {
             throw new ForbiddenError(
                 "You don't have access to the space this dashboard belongs to",
             );
diff --git a/packages/common/src/authorization/types.ts b/packages/common/src/authorization/types.ts
index 67493a372fab0..87f44fc794efd 100644
--- a/packages/common/src/authorization/types.ts
+++ b/packages/common/src/authorization/types.ts
@@ -18,10 +18,7 @@ interface Organization {
     organizationUuid: string;
 }
 
-type Subject =
-    | Project
-    | Organization
-    | OrganizationMemberProfile
+export type CaslSubjectNames =
     | 'Project'
     | 'Organization'
     | 'OrganizationMemberProfile'
@@ -50,7 +47,13 @@ type Subject =
     | 'PersonalAccessToken'
     | 'MetricsTree'
     | 'SpotlightTableConfig'
-    | 'ContentAsCode'
+    | 'ContentAsCode';
+
+export type Subject =
+    | CaslSubjectNames
+    | Project
+    | Organization
+    | OrganizationMemberProfile
     | 'all';
 
 export type PossibleAbilities = [


Reviews:
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T17:49:36Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T17:54:13Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T17:56:07Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T17:59:09Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T18:08:56Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T09:08:26Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T09:09:05Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:00:21Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:02:52Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:16:17Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:36:02Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:36:27Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:37:01Z
user: login: owlas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:37:34Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T11:15:40Z
user: login: ZeRego
body: lgtm 👍 

<img width="1203" alt="Screenshot 2025-04-17 at 13 31 38" src="https://github.com/user-attachments/assets/9e84395c-eb4f-4435-9b59-dd830810d0e1" />

state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-17T12:32:35Z

Review comments:
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    const { __caslSubjectType__, ...rest } = subject;
+
+    // Ensure we have a valid organizationUuid
+    const organizationUuid = (rest.organizationUuid as string) || '';
+
+    return {
+        type: (__caslSubjectType__ as string) || 'unknown',
+        id: (rest.id || rest.uuid || rest.projectUuid || organizationUuid) as
+            | string
+            | undefined,
+        name: (rest.name as string) || undefined,
+        organizationUuid,
+        projectUuid: (rest.projectUuid as string) || undefined,
+    };
+};
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    organizationId: args.user.organizationUuid,
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: ZeRego
body: `args.user.organizationUuid` or `args.subject.organizationUuid` ?
created_at: 2025-04-15T17:49:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: ZeRego
body: Should we type the subject arg as `ForcedSubject<String>` to guarantee it has this property?
created_at: 2025-04-15T17:54:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    const { __caslSubjectType__, ...rest } = subject;
+
+    // Ensure we have a valid organizationUuid
+    const organizationUuid = (rest.organizationUuid as string) || '';
+
+    return {
+        type: (__caslSubjectType__ as string) || 'unknown',
+        id: (rest.id || rest.uuid || rest.projectUuid || organizationUuid) as
+            | string
+            | undefined,
+        name: (rest.name as string) || undefined,
+        organizationUuid,
+        projectUuid: (rest.projectUuid as string) || undefined,
+    };
+};
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    organizationId: args.user.organizationUuid,
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: ZeRego
body: The `actor` already has information about the user org id.
The `resource` already has information about the subject org&project id.
Should we remove project&org id from `context`? 
created_at: 2025-04-15T17:56:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    const { __caslSubjectType__, ...rest } = subject;
+
+    // Ensure we have a valid organizationUuid
+    const organizationUuid = (rest.organizationUuid as string) || '';
+
+    return {
+        type: (__caslSubjectType__ as string) || 'unknown',
+        id: (rest.id || rest.uuid || rest.projectUuid || organizationUuid) as
+            | string
+            | undefined,
+        name: (rest.name as string) || undefined,
+        organizationUuid,
+        projectUuid: (rest.projectUuid as string) || undefined,
+    };
+};
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    organizationId: args.user.organizationUuid,
+    projectId: args.subject.projectUuid as string | undefined,
+    ip: args.ip,
+    userAgent: args.userAgent,
+    requestId: args.requestId,
+});
+
+// Helper function to extract conditions from a CASL Rule
+const extractRuleConditions = <A extends Abilities, C>(
+    rule: CaslRule<A, C> | null,
+): string | undefined => {
+    if (!rule) return undefined;
+
+    try {
+        // Get conditions directly from Rule object's conditions property
+        const { conditions } = rule;
+
+        if (conditions && typeof conditions === 'object') {
+            return JSON.stringify(conditions);
+        }
+
+        return undefined;
+    } catch (e) {
+        return undefined;
+    }
+};
+
+export class CaslAuditWrapper<T extends Ability> {
+    private wrappedAbility: T;
+
+    private user: AuditableUser;
+
+    private ip?: string;
+
+    private userAgent?: string;
+
+    private requestId?: string;
+
+    private auditLogger: AuditLogger;
+
+    constructor(
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: ZeRego
body: Would be nice to see an example of how this would look like in a service. 
At the moment, the user contains the ability so should these be separate arguments?
created_at: 2025-04-15T17:59:08Z
author_association: COLLABORATOR
diff_hunk: @@ -122,6 +125,24 @@ export const winstonLogger = winston.createLogger({
     transports,
 });
 
+export const auditLogger = winston.createLogger({
+    levels,
+    level: 'audit',
+    transports,
+});
+
+export const logAuditEvent = (event: AuditLogEvent): void => {
+    auditLogger.log({
path: packages/backend/src/logging/winston.ts
user: login: ZeRego
body: Can't we use `winstonLogger` here? it should support the `audit` level.
If so we can delete the `auditLogger`
created_at: 2025-04-15T18:08:56Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: owlas
body: Much better thanks! 
created_at: 2025-04-16T09:08:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    const { __caslSubjectType__, ...rest } = subject;
+
+    // Ensure we have a valid organizationUuid
+    const organizationUuid = (rest.organizationUuid as string) || '';
+
+    return {
+        type: (__caslSubjectType__ as string) || 'unknown',
+        id: (rest.id || rest.uuid || rest.projectUuid || organizationUuid) as
+            | string
+            | undefined,
+        name: (rest.name as string) || undefined,
+        organizationUuid,
+        projectUuid: (rest.projectUuid as string) || undefined,
+    };
+};
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    organizationId: args.user.organizationUuid,
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: owlas
body: Yeah I think the context is confusing here, I'm removing it!
created_at: 2025-04-16T09:09:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, type AnyObject } from '@casl/ability/dist/types/types';
+import { type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AnyObject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    id: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (subject: AnyObject): AuditResource => {
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    const { __caslSubjectType__, ...rest } = subject;
+
+    // Ensure we have a valid organizationUuid
+    const organizationUuid = (rest.organizationUuid as string) || '';
+
+    return {
+        type: (__caslSubjectType__ as string) || 'unknown',
+        id: (rest.id || rest.uuid || rest.projectUuid || organizationUuid) as
+            | string
+            | undefined,
+        name: (rest.name as string) || undefined,
+        organizationUuid,
+        projectUuid: (rest.projectUuid as string) || undefined,
+    };
+};
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    organizationId: args.user.organizationUuid,
+    projectId: args.subject.projectUuid as string | undefined,
+    ip: args.ip,
+    userAgent: args.userAgent,
+    requestId: args.requestId,
+});
+
+// Helper function to extract conditions from a CASL Rule
+const extractRuleConditions = <A extends Abilities, C>(
+    rule: CaslRule<A, C> | null,
+): string | undefined => {
+    if (!rule) return undefined;
+
+    try {
+        // Get conditions directly from Rule object's conditions property
+        const { conditions } = rule;
+
+        if (conditions && typeof conditions === 'object') {
+            return JSON.stringify(conditions);
+        }
+
+        return undefined;
+    } catch (e) {
+        return undefined;
+    }
+};
+
+export class CaslAuditWrapper<T extends Ability> {
+    private wrappedAbility: T;
+
+    private user: AuditableUser;
+
+    private ip?: string;
+
+    private userAgent?: string;
+
+    private requestId?: string;
+
+    private auditLogger: AuditLogger;
+
+    constructor(
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: owlas
body: Adding
created_at: 2025-04-16T11:00:21Z
author_association: COLLABORATOR
diff_hunk: @@ -122,6 +125,24 @@ export const winstonLogger = winston.createLogger({
     transports,
 });
 
+export const auditLogger = winston.createLogger({
+    levels,
+    level: 'audit',
+    transports,
+});
+
+export const logAuditEvent = (event: AuditLogEvent): void => {
+    auditLogger.log({
path: packages/backend/src/logging/winston.ts
user: login: owlas
body: Testing this now!
created_at: 2025-04-16T11:02:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import { v4 as uuidv4 } from 'uuid';
+import { z } from 'zod';
+
+export type AuditStatusType = 'allowed' | 'denied';
+
+export const AuditStatusSchema = z.enum(['allowed', 'denied']);
+
+export const AuditActorSchema = z.object({
+    uuid: z.string(),
+    firstName: z.string().optional(),
+    lastName: z.string().optional(),
+    email: z.string().optional(),
+    organizationUuid: z.string(),
+    organizationRole: z.string(),
+    groupMemberships: z.array(z.string()).optional(),
+});
+
+export type AuditActor = z.infer<typeof AuditActorSchema>;
+
+export const AuditResourceSchema = z.object({
+    type: z.string(),
+    uuid: z.string().optional(),
+    name: z.string().optional(),
+    organizationUuid: z.string(),
+    projectUuid: z.string().optional(),
+});
+
+export type AuditResource = z.infer<typeof AuditResourceSchema>;
+
+export const AuditContextSchema = z.object({
path: packages/backend/src/logging/auditLog.ts
user: login: owlas
body: simpler context - we're not really using it now but would be valuable to populate once integrated into middleware
created_at: 2025-04-16T11:16:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import { v4 as uuidv4 } from 'uuid';
+import { z } from 'zod';
+
+export type AuditStatusType = 'allowed' | 'denied';
+
+export const AuditStatusSchema = z.enum(['allowed', 'denied']);
+
+export const AuditActorSchema = z.object({
+    uuid: z.string(),
+    firstName: z.string().optional(),
+    lastName: z.string().optional(),
+    email: z.string().optional(),
+    organizationUuid: z.string(),
+    organizationRole: z.string(),
+    groupMemberships: z.array(z.string()).optional(),
+});
+
+export type AuditActor = z.infer<typeof AuditActorSchema>;
+
+export const AuditResourceSchema = z.object({
+    type: z.string(),
+    uuid: z.string().optional(),
+    name: z.string().optional(),
+    organizationUuid: z.string(),
+    projectUuid: z.string().optional(),
+});
+
+export type AuditResource = z.infer<typeof AuditResourceSchema>;
+
+export const AuditContextSchema = z.object({
+    ip: z.string().optional(),
+    userAgent: z.string().optional(),
+    requestId: z.string().optional(),
+});
+
+export type AuditContext = z.infer<typeof AuditContextSchema>;
+
+export const AuditLogEventSchema = z.object({
+    id: z.string().default(() => uuidv4()),
+    timestamp: z.string().default(() => new Date().toISOString()),
+    actor: AuditActorSchema,
+    action: z.string(),
+    resource: AuditResourceSchema,
+    context: AuditContextSchema,
+    status: AuditStatusSchema,
+    reason: z.string().optional(),
+    ruleConditions: z.string().optional(),
+});
+
+export type AuditLogEvent = z.infer<typeof AuditLogEventSchema>;
+
+export const validateAuditLogEvent = (event: unknown): AuditLogEvent =>
+    AuditLogEventSchema.parse(event);
+
+export const createAuditLogEvent = (
+    actor: AuditActor,
+    action: string,
+    resource: AuditResource,
+    context: AuditContext,
+    status: AuditStatusType,
+    reason?: string,
+    ruleConditions?: string,
path: packages/backend/src/logging/auditLog.ts
user: login: owlas
body: Should this be a string? Or an object? We can change it later
created_at: 2025-04-16T11:36:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,327 @@
+import { defineAbility, subject } from '@casl/ability';
+import { ForcedSubject, type AnyObject } from '@casl/ability/dist/types/types';
+import { CaslSubjectNames, OrganizationMemberRole } from '@lightdash/common';
+import { type AuditLogEvent } from './auditLog';
+import {
+    CaslAuditWrapper,
+    type AuditLogger,
+    type AuditableUser,
+} from './caslAuditWrapper';
+
+// Test subjects
path: packages/backend/src/logging/caslAuditWrapper.test.ts
user: login: owlas
body: Had to update tests to use real subjects. Also subjects must have a uuid field
created_at: 2025-04-16T11:36:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,199 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, ForcedSubject } from '@casl/ability/dist/types/types';
+import { CaslSubjectNames, type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+// Todo: can we remove the & { properties } by improving typing of CaslSubjectNames?
+type AuditableCaslSubject = ForcedSubject<CaslSubjectNames> & {
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: owlas
body: Resource _must_ have a uuid and organization uuid in order to be logged out and be compatible with the CASLAudit wrapper
created_at: 2025-04-16T11:37:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,199 @@
+import { type Ability } from '@casl/ability';
+import type { Rule as CaslRule } from '@casl/ability/dist/types/Rule';
+import { Abilities, ForcedSubject } from '@casl/ability/dist/types/types';
+import { CaslSubjectNames, type SessionUser } from '@lightdash/common';
+import {
+    AuditActor,
+    AuditContext,
+    AuditResource,
+    AuditStatusType,
+    createAuditLogEvent,
+    type AuditLogEvent,
+} from './auditLog';
+
+export type AuditLogger = (event: AuditLogEvent) => void;
+
+export type AuditableUser = Pick<
+    SessionUser,
+    | 'userUuid'
+    | 'email'
+    | 'firstName'
+    | 'lastName'
+    | 'organizationUuid'
+    | 'role'
+>;
+
+// Todo: can we remove the & { properties } by improving typing of CaslSubjectNames?
+type AuditableCaslSubject = ForcedSubject<CaslSubjectNames> & {
+    organizationUuid: string;
+    uuid: string;
+    name?: string;
+    projectUuid?: string;
+};
+
+type AuditHelperArgs = {
+    user: AuditableUser;
+    action: string;
+    subject: AuditableCaslSubject;
+    ip?: string;
+    userAgent?: string;
+    requestId?: string;
+    ruleConditions?: string;
+};
+
+/**
+ * Creates an audit actor from a user
+ */
+const createActorFromUser = (user: AuditableUser): AuditActor => ({
+    uuid: user.userUuid,
+    email: user.email || '',
+    firstName: user.firstName || '',
+    lastName: user.lastName || '',
+    organizationUuid: user.organizationUuid || '',
+    organizationRole: user.role || 'unknown',
+    // TODO: Add group memberships
+    groupMemberships: [],
+});
+
+const createResourceFromSubject = (
+    subject: AuditableCaslSubject,
+): AuditResource => ({
+    type: subject.__caslSubjectType__ || 'unknown',
+    uuid: subject.uuid,
+    name: subject.name,
+    organizationUuid: subject.organizationUuid,
+    projectUuid: subject.projectUuid,
+});
+
+const createContextFromArgs = (args: AuditHelperArgs): AuditContext => ({
+    ip: args.ip,
+    userAgent: args.userAgent,
+    requestId: args.requestId,
+});
+
+// Helper function to extract conditions from a CASL Rule
+const extractRuleConditions = <A extends Abilities, C>(
path: packages/backend/src/logging/caslAuditWrapper.ts
user: login: owlas
body: I'm stringifying but maybe it's not needed
created_at: 2025-04-16T11:37:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import { v4 as uuidv4 } from 'uuid';
+import { z } from 'zod';
+
+export type AuditStatusType = 'allowed' | 'denied';
+
+export const AuditStatusSchema = z.enum(['allowed', 'denied']);
+
+export const AuditActorSchema = z.object({
+    uuid: z.string(),
+    firstName: z.string().optional(),
+    lastName: z.string().optional(),
+    email: z.string().optional(),
+    organizationUuid: z.string(),
+    organizationRole: z.string(),
+    groupMemberships: z.array(z.string()).optional(),
+});
+
+export type AuditActor = z.infer<typeof AuditActorSchema>;
+
+export const AuditResourceSchema = z.object({
+    type: z.string(),
+    uuid: z.string().optional(),
+    name: z.string().optional(),
+    organizationUuid: z.string(),
+    projectUuid: z.string().optional(),
+});
+
+export type AuditResource = z.infer<typeof AuditResourceSchema>;
+
+export const AuditContextSchema = z.object({
+    ip: z.string().optional(),
+    userAgent: z.string().optional(),
+    requestId: z.string().optional(),
+});
+
+export type AuditContext = z.infer<typeof AuditContextSchema>;
+
+export const AuditLogEventSchema = z.object({
+    id: z.string().default(() => uuidv4()),
+    timestamp: z.string().default(() => new Date().toISOString()),
+    actor: AuditActorSchema,
+    action: z.string(),
+    resource: AuditResourceSchema,
+    context: AuditContextSchema,
+    status: AuditStatusSchema,
+    reason: z.string().optional(),
+    ruleConditions: z.string().optional(),
+});
+
+export type AuditLogEvent = z.infer<typeof AuditLogEventSchema>;
+
+export const validateAuditLogEvent = (event: unknown): AuditLogEvent =>
+    AuditLogEventSchema.parse(event);
+
+export const createAuditLogEvent = (
+    actor: AuditActor,
+    action: string,
+    resource: AuditResource,
+    context: AuditContext,
+    status: AuditStatusType,
+    reason?: string,
+    ruleConditions?: string,
path: packages/backend/src/logging/auditLog.ts
user: login: ZeRego
body: I'm inclined to be an object.
created_at: 2025-04-17T11:15:40Z
author_association: COLLABORATOR

PR comments:
user: login: owlas
created_at: 2025-04-15T07:30:26Z
body: * **#14418** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14418?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14417** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14417?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14416** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14416?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14416?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: COLLABORATOR
user: login: owlas
created_at: 2025-04-16T11:15:59Z
body: @ZeRego wondering if the `ruleConditions` should be raw json and not a json stringify
author_association: COLLABORATOR
user: login: owlas
created_at: 2025-04-23T15:16:20Z
body: ### Merge activity

* **Apr 23, 11:16 AM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/lightdash/lightdash/14416).
* **Apr 23, 11:17 AM EDT**: [Graphite](https://app.graphite.dev/github/pr/lightdash/lightdash/14416) rebased this pull request as part of a merge.
* **Apr 23, 11:17 AM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/lightdash/lightdash/14416).

author_association: COLLABORATOR
user: login: lightdash-bot
created_at: 2025-04-23T15:25:56Z
body: :tada: This PR is included in version 0.1596.0 :tada:

The release is available on:
- `0.1596.0`
- [GitHub release](https://github.com/lightdash/lightdash/releases/tag/0.1596.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR