Ctrl K
ComunidadGuías…

docs/audit-logging.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Audit Logging Architecture

This document describes how audit logging works in Lightdash, covering the system design, actor types, event schema, and
how to add audit logging to new services.

---

## Overview

Lightdash uses a CASL-based authorization system where every permission check (`can`/`cannot`) can be automatically
audit-logged. The audit logging layer wraps CASL's Ability class to intercept permission checks and emit structured
events at Winston's `audit` log level.

This enables ITGC compliance by tracking:

- **Machine actions**: SCIM, service accounts
- **Administrative actions**: Role/permission changes, configuration changes
- **User edits**: Dashboard and chart modifications, folder structure changes

---

## Key Components

| Component                            | Location                                                   | Purpose                                                            |
| ------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------ |
| `AuditLogEvent` schema               | `packages/backend/src/logging/auditLog.ts`                 | Event type and validation schema                                   |
| `CaslAuditWrapper`                   | `packages/backend/src/logging/caslAuditWrapper.ts`         | Wraps CASL Ability to intercept `can`/`cannot` calls               |
| `BaseService.createAuditedAbility()` | `packages/backend/src/services/BaseService.ts`             | Helper that creates a CaslAuditWrapper with logging pre-configured |
| `logAuditEvent()`                    | `packages/backend/src/logging/winston.ts`                  | Emits audit events to Winston at the `audit` level                 |
| ESLint rule                          | `packages/backend/eslint-rules/no-direct-ability-check.js` | Prevents direct `.ability.can/cannot` calls in services            |

---

## Actor Types (Discriminated Union)

Audit actors are modeled as a discriminated union on the `type` field:

| Actor Type          | `type` values             | Description                            | Key Fields                                                                      |
| ------------------- | ------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- |
| **User**            | `session`, `pat`, `oauth` | Human users via browser, PAT, or OAuth | `uuid`, `email`, `firstName`, `lastName`, `organizationRole`, `impersonatedBy?` |
| **Service Account** | `service-account`         | Machine-to-machine CI/CD accounts      | `uuid`, `email`, `organizationRole`                                             |
| **Anonymous**       | `anonymous`               | Embedded dashboard viewers (JWT auth)  | `uuid`, `organizationUuid`                                                      |

The `impersonatedBy` field on user actors tracks admin impersonation for compliance. When an admin acts as another user,
both the target user and the impersonating admin are recorded.

---

## Event Schema

Every audit event contains:

```typescript
{
    id: string;              // UUID, auto-generated
    timestamp: string;       // ISO 8601 UTC
    actor: AuditActor;       // Who performed the action (see Actor Types)
    action: string;          // CASL action: 'view', 'create', 'update', 'delete', 'manage'
    resource: {
        type: string;          // CASL subject: 'Dashboard', 'SavedChart', 'Space', etc.
        metadata ? : Record<string, unknown>; // Resource identifiers and display names
        organizationUuid: string;
        projectUuid ? : string;
    }
    ;
    context: {
        ip ? : string;           // Client IP address
        userAgent ? : string;    // Client user agent
        requestId ? : string;    // Correlation ID for request tracing
    }
    ;
    status: 'allowed' | 'denied';  // Permission check result
    reason ? : string;                // CASL rule `.because()` explanation
    ruleConditions ? : string;        // Serialized CASL rule conditions (JSON)
    callStack ? : Array<{      // Which service methods triggered this check
        serviceName: string;
        methodName: string;
        depth: number;
    }>;
}
```

---

## Winston Log Levels

Audit events use a custom Winston log level:

```
error: 0    (highest priority)
warn:  1
info:  2
http:  3
audit: 4    <-- audit events
debug: 5    (lowest priority)
```

Configure audit logging via environment variables:

```bash
# File output for SIEM ingestion
LIGHTDASH_LOG_FILE_PATH="/var/log/lightdash/audit.log"
LIGHTDASH_LOG_FILE_LEVEL="audit"
LIGHTDASH_LOG_FILE_FORMAT="json"

# Console can be set independently
LIGHTDASH_LOG_LEVEL="info"
LIGHTDASH_LOG_FORMAT="json"
LIGHTDASH_LOG_OUTPUTS="console,file"
```

---

## How to Add Audit Logging to a Service

### Step 1: Use `createAuditedAbility()` Instead of Direct Ability Checks

Every service that extends `BaseService` has access to `this.createAuditedAbility()`. This is a drop-in replacement for
accessing `user.ability` directly.

**Before (no audit logging):**

```typescript
import { subject } from '@casl/ability';
import { ForbiddenError } from '@lightdash/common';

class MyService extends BaseService {
  async getResource(user: SessionUser, resourceUuid: string) {
    const resource = await this.model.get(resourceUuid);

    // Direct ability check - NOT audit logged
    if (user.ability.cannot('view', subject('MyResource', resource))) {
      throw new ForbiddenError();
    }

    return resource;
  }
}
```

**After (audit logged):**

```typescript
import { subject } from '@casl/ability';
import { ForbiddenError } from '@lightdash/common';

class MyService extends BaseService {
  async getResource(user: SessionUser, resourceUuid: string) {
    const resource = await this.model.get(resourceUuid);

    // Audited ability - every can/cannot call is logged
    const auditedAbility = this.createAuditedAbility(user);
    if (auditedAbility.cannot('view', subject('MyResource', resource))) {
      throw new ForbiddenError();
    }

    return resource;
  }
}
```

### Step 2: Reuse the Audited Ability Within a Method

If a method makes multiple permission checks, create the audited ability once and reuse it:

```typescript
class MyService extends BaseService {
  async updateResource(user: SessionUser, uuid: string, data: UpdateData) {
    const resource = await this.model.get(uuid);
    const auditedAbility = this.createAuditedAbility(user);

    // Check 1: Can user update in current space?
    if (auditedAbility.cannot('update', subject('MyResource', resource))) {
      throw new ForbiddenError();
    }

    // Check 2: If moving to new space, can user update there?
    if (data.spaceUuid && data.spaceUuid !== resource.spaceUuid) {
      const newSpace = await this.spaceModel.get(data.spaceUuid);
      if (auditedAbility.cannot('update', subject('MyResource', newSpace))) {
        throw new ForbiddenError('No access to the target space');
      }
    }

    return this.model.update(uuid, data);
  }
}
```

### Step 3: Use Bulk Checks for Collections

Use `canBulk()` when checking the same action across a collection. It evaluates each resource once and emits one summary event per matched CASL rule instead of one event per item. Summary events omit rule conditions and enumerate the resources using the `metadata` from each subject under `resource.metadata.resources`.

Every bulk subject must include identifying metadata, such as `{ spaceUuid, spaceName }` or `{ projectUuid, projectName }`, so each decision remains attributable.

```typescript
const subjects = spaces.map((space) =>
  subject('Space', {
    organizationUuid,
    projectUuid,
    metadata: { spaceUuid: space.uuid, spaceName: space.name },
  }),
);
const accessResults = auditedAbility.canBulk('view', subjects);
const accessibleSpaces = spaces.filter((_, index) => accessResults[index]);
```

Bulk summaries use `resource.metadata.resources` instead of one event per resource. Operators that parse audit events for SIEM ingestion should support this array. Plain and pretty log formats serialize the array as JSON so every identifier remains visible; JSON log output retains the structured array.

A summary is split by matched rule, resource type, and organization. Its top-level `projectUuid` is present only when every listed resource belongs to the same project.

### Step 4: Works with Account Type Too

For services that receive the `Account` type (newer pattern), the same method works:

```typescript
class MyService extends BaseService {
    async listRoles(account: Account) {
        const auditedAbility = this.createAuditedAbility(account);
        if (auditedAbility.cannot('view', subject('Organization', { ... }))) {
            throw new ForbiddenError();
        }
        // ...
    }
}
```

### Step 5: Always Use `subject()` for Typed Subjects

Always wrap permission checks with CASL's `subject()` helper. This ensures the audit log captures the correct resource
type name. Without `subject()`, the `__caslSubjectType__` field is missing and the resource type in the audit log will
be `"unknown"`.

```typescript
// Good - audit log will show resource.type = "Dashboard"
auditedAbility.cannot(
  'view',
  subject('Dashboard', { organizationUuid, projectUuid, uuid: dashboardUuid }),
);

// Bad - audit log will show resource.type = "unknown"
auditedAbility.cannot('view', { organizationUuid, projectUuid });
```

### Step 6: Generate UUIDs Before Permission Checks on Create Actions

For create operations, generate the resource UUID _before_ the permission check so the audit log includes the UUID of
the resource that will be created. This makes it possible to correlate the permission check with the resulting resource.

```typescript
async createDashboard(user: SessionUser, projectUuid: string, data: CreateDashboard) {
    const dashboardUuid = uuidv4(); // Generate UUID first
    const auditedAbility = this.createAuditedAbility(user);

    // Audit log will include uuid: "abc-123" even for the create check
    if (auditedAbility.cannot('create', subject('Dashboard', {
        organizationUuid,
        projectUuid,
        uuid: dashboardUuid,
    }))) {
        throw new ForbiddenError();
    }

    // Pass the pre-generated UUID to the model
    return this.dashboardModel.create({ ...data, uuid: dashboardUuid });
}
```

---

## Example: Full Audit Log Output

When a developer with PAT authentication views a dashboard:

```json
{
  "level": "audit",
  "message": "view Dashboard abc-123-uuid by user-456 (allowed)",
  "id": "evt-789",
  "timestamp": "2026-04-01T10:30:00.000Z",
  "actor": {
    "type": "pat",
    "uuid": "user-456",
    "email": "developer@company.com",
    "firstName": "Jane",
    "lastName": "Dev",
    "organizationUuid": "org-001",
    "organizationRole": "developer",
    "groupMemberships": []
  },
  "action": "view",
  "resource": {
    "type": "Dashboard",
    "metadata": {
      "dashboardUuid": "abc-123-uuid"
    },
    "organizationUuid": "org-001",
    "projectUuid": "proj-001"
  },
  "context": {},
  "status": "allowed",
  "ruleConditions": "{\"organizationUuid\":\"org-001\"}",
  "callStack": [
    {
      "serviceName": "DashboardService",
      "methodName": "getByIdOrSlug",
      "depth": 0
    }
  ]
}
```

When a denied access attempt occurs (e.g., viewer trying to delete):

```json
{
  "level": "audit",
  "message": "delete Dashboard abc-123-uuid by user-789 (denied)",
  "id": "evt-012",
  "timestamp": "2026-04-01T10:31:00.000Z",
  "actor": {
    "type": "session",
    "uuid": "user-789",
    "email": "viewer@company.com",
    "firstName": "Bob",
    "lastName": "Viewer",
    "organizationUuid": "org-001",
    "organizationRole": "viewer",
    "groupMemberships": []
  },
  "action": "delete",
  "resource": {
    "type": "Dashboard",
    "metadata": {
      "dashboardUuid": "abc-123-uuid"
    },
    "organizationUuid": "org-001",
    "projectUuid": "proj-001"
  },
  "context": {},
  "status": "denied",
  "reason": "Viewers cannot delete dashboards",
  "callStack": [
    {
      "serviceName": "DashboardService",
      "methodName": "delete",
      "depth": 0
    }
  ]
}
```

---

## ESLint Enforcement

The custom ESLint rule `no-direct-ability-check` (loaded via `--rulesdir eslint-rules`) detects direct `.ability.can()` /
`.ability.cannot()` usage in service files and flags them. It is currently set to **warning** during the migration period,
and will be promoted to **error** once all services are migrated.

The rule catches patterns like:

- `user.ability.can('view', ...)`
- `account.user.ability.cannot('manage', ...)`
- `actor.ability.can('update', ...)`

And suggests using `this.createAuditedAbility()` instead.