Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { installAuditWriters } from './audit-writers.js';

/**
* Regression coverage for #1532 — on single-tenant stacks the
* SchemaRegistry does NOT auto-inject `organization_id` into
* `sys_audit_log` / `sys_activity`, so the audit writer must not emit that
* column. Previously it stamped `organization_id` unconditionally, making
* every audit INSERT fail with "table sys_audit_log has no column named
* organization_id" (swallowed → audit logging silently non-functional).
*/

interface CapturedRow {
object: string;
row: Record<string, any>;
}

/**
* Build a fake ObjectQL engine that records hook registrations and the rows
* written through `api.sudo().object(name).create(row)`.
*
* @param schemas Map of object short-name → declared field set. Mirrors what
* `engine.getSchema(name)` returns after `applySystemFields` has (or has
* not) injected `organization_id`.
*/
function makeEngine(schemas: Record<string, string[]>) {
const hooks = new Map<string, Array<(ctx: any) => any>>();
const created: CapturedRow[] = [];

const sudoApi = {
object(name: string) {
return {
async create(row: Record<string, any>) {
created.push({ object: name, row });
return { id: 'generated-id', ...row };
},
};
},
};
// `writeAudit` calls `ctx.api.sudo()` to get the object accessor above.
const api = { sudo: () => sudoApi };

const engine = {
getSchema(name: string) {
const fields = schemas[name];
if (!fields) return undefined;
return { name, fields: Object.fromEntries(fields.map((f) => [f, { type: 'text' }])) };
},
registerHook(event: string, fn: (ctx: any) => any) {
const list = hooks.get(event) ?? [];
list.push(fn);
hooks.set(event, list);
},
unregisterHooksByPackage() {
/* no-op */
},
logger: { warn() {} },
};

async function fire(event: string, ctx: any) {
for (const fn of hooks.get(event) ?? []) {
await fn({ ...ctx, event, api });
}
}

return { engine, fire, created };
}

const SINGLE_TENANT = {
// No `organization_id` — single-tenant stacks skip the auto-injection.
sys_audit_log: ['id', 'action', 'user_id', 'object_name', 'record_id', 'old_value', 'new_value', 'tenant_id'],
sys_activity: ['id', 'type', 'timestamp', 'summary', 'actor_id', 'object_name', 'record_id', 'record_label', 'metadata'],
};

const MULTI_TENANT = {
sys_audit_log: [...SINGLE_TENANT.sys_audit_log, 'organization_id'],
sys_activity: [...SINGLE_TENANT.sys_activity, 'organization_id'],
};

describe('audit writers — organization_id stamping (#1532)', () => {
it('omits organization_id on single-tenant tables that lack the column', async () => {
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');

await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-1' },
result: { id: 'lead-1', name: 'Acme' },
session: {},
});

const audit = created.find((c) => c.object === 'sys_audit_log');
const activity = created.find((c) => c.object === 'sys_activity');
expect(audit).toBeDefined();
expect(activity).toBeDefined();
// The fix: no undeclared column is emitted, so the INSERT would succeed.
expect('organization_id' in audit!.row).toBe(false);
expect('organization_id' in activity!.row).toBe(false);
// tenant_id is schema-declared and still written.
expect('tenant_id' in audit!.row).toBe(true);
});

it('stamps organization_id on multi-tenant tables when the column exists', async () => {
const { engine, fire, created } = makeEngine(MULTI_TENANT);
installAuditWriters(engine as any, 'test.audit');

await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-1' },
result: { id: 'lead-1', name: 'Acme', organization_id: 'org-9' },
session: { tenantId: 'org-9', userId: 'user-1' },
});

const audit = created.find((c) => c.object === 'sys_audit_log');
const activity = created.find((c) => c.object === 'sys_activity');
expect(audit?.row.organization_id).toBe('org-9');
expect(activity?.row.organization_id).toBe('org-9');
});
});
62 changes: 50 additions & 12 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,37 @@ export function installAuditWriters(
engine.unregisterHooksByPackage(packageId);
}

// Whether a given object's *registered* schema declares a field. The
// SchemaRegistry auto-injects `organization_id` only in multi-tenant mode
// (`applySystemFields({ multiTenant })`), so on single-tenant stacks the
// `sys_audit_log` / `sys_activity` tables have no `organization_id` column.
// Unconditionally stamping it there made every audit INSERT fail with
// "table sys_audit_log has no column named organization_id" (the error was
// swallowed, so audit logging was silently non-functional). Resolve the
// field set lazily from the engine schema and cache it — object schemas are
// static after registration.
const fieldSetCache = new Map<string, Set<string> | null>();
const objectHasField = (objectName: string, field: string): boolean => {
let set = fieldSetCache.get(objectName);
if (set === undefined) {
set = null;
try {
const schema: any =
typeof (engine as any).getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
const fields = schema?.fields;
if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
set = new Set<string>(Object.keys(fields));
} else if (Array.isArray(fields)) {
set = new Set<string>(fields.map((f: any) => f?.name).filter(Boolean));
}
} catch {
/* ignore — best-effort; absence just means we skip the stamp */
}
fieldSetCache.set(objectName, set);
}
return set != null && set.has(field);
};

/**
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
* so we can compute the diff in the afterXxx hook. We attach the snapshot
Expand Down Expand Up @@ -250,17 +281,21 @@ export function installAuditWriters(
record_id: recordId ?? null,
old_value: oldValue ? safeStringify(oldValue) : null,
new_value: newValue ? safeStringify(newValue) : null,
// `tenant_id` is the schema-declared "tenant context" lookup; the
// platform-default `organization_id` column is what RLS gates on
// (`organization_id = current_user.organization_id`). The audit
// writer runs through `api.sudo()` which bypasses the
// SecurityPlugin's auto-stamping of `organization_id`, so we
// stamp both columns explicitly here. Without `organization_id`,
// non-admin members would see 0 rows on Setup dashboards because
// RLS would deny every audit row as wrong-tenant.
organization_id: tenantId ?? null,
// `tenant_id` is the schema-declared "tenant context" lookup.
tenant_id: tenantId ?? null,
};
// The platform-default `organization_id` column is what RLS gates on
// (`organization_id = current_user.organization_id`). The audit writer
// runs through `api.sudo()` which bypasses the SecurityPlugin's
// auto-stamping of `organization_id`, so we stamp it explicitly here —
// without it, non-admin members would see 0 rows on Setup dashboards
// because RLS would deny every audit row as wrong-tenant. But the column
// only exists in multi-tenant deployments (the SchemaRegistry auto-injects
// it conditionally); stamping it on a single-tenant table that lacks the
// column made every audit INSERT fail. Only stamp it when declared.
if (objectHasField('sys_audit_log', 'organization_id')) {
auditRow.organization_id = tenantId ?? null;
}

const label = recordLabel(after ?? before, recordId ?? '');
const summary =
Expand All @@ -280,10 +315,13 @@ export function installAuditWriters(
record_id: recordId ?? null,
record_label: label,
metadata: newValue || oldValue ? safeStringify({ old: oldValue, new: newValue }) : null,
// Same rationale as auditRow: stamp the tenant column so RLS
// matches the recipient's organization on read.
organization_id: tenantId ?? null,
};
// Same rationale as auditRow: stamp the tenant column so RLS matches the
// recipient's organization on read — but only when the (auto-injected)
// column actually exists, so single-tenant activity writes don't fail.
if (objectHasField('sys_activity', 'organization_id')) {
activityRow.organization_id = tenantId ?? null;
}

try {
const sys = api.sudo();
Expand Down