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
14 changes: 14 additions & 0 deletions .changeset/22961-plugin-audit-own-ui-state-no-activity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-audit': patch
---

fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before

Clause-②: no

The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else.

- **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`.
- **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged.
- **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them.
- **Nothing to migrate.** No configuration is needed.
100 changes: 100 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,106 @@ describe('audit writers — enable.activities opt-out gate (#2707)', () => {
});
});

/**
* [#22961] A user's own write to their own per-user UI-state row writes the
* ledger row and no activity row. Every other writer keeps today's rows.
*
* The rows below are the console's "recently visited" write as the real engine
* hands it to this hook: the session subject is the owner, and both `result`
* and the bound `previous` carry the full row with its `user_id`.
*/
describe('audit writers — a user\'s own per-user UI-state write is not mirrored (#22961)', () => {
const SCHEMA = {
sys_audit_log: SINGLE_TENANT.sys_audit_log,
sys_activity: SINGLE_TENANT.sys_activity,
sys_user_preference: ['id', 'user_id', 'key', 'value'],
crm_lead: ['id', 'name'],
};
const OWNER = 'u_alice';
const PREF = { id: 'pref-1', user_id: OWNER, key: 'ui.recent', value: [] as unknown[] };
const rowsOn = (created: CapturedRow[], table: string, object: string) =>
created.filter((c) => c.object === table && c.row.object_name === object);

const OWN_WRITES = [
{ event: 'afterInsert', action: 'create', ctx: { input: { id: PREF.id }, result: PREF } },
{
event: 'afterUpdate',
action: 'update',
ctx: { input: { id: PREF.id }, previous: PREF, result: { ...PREF, value: [{ id: 'acc-1' }] } },
},
{ event: 'afterDelete', action: 'delete', ctx: { input: { id: PREF.id }, previous: PREF, result: { id: PREF.id } } },
] as const;

for (const w of OWN_WRITES) {
it(`the owner's own ${w.action} writes the sys_audit_log row and no sys_activity row`, async () => {
const { engine, fire, created } = makeEngine(SCHEMA);
installAuditWriters(engine as any, 'test.audit');

await fire(w.event, { object: 'sys_user_preference', ...w.ctx, session: { userId: OWNER } });

const ledger = rowsOn(created, 'sys_audit_log', 'sys_user_preference');
expect(ledger).toHaveLength(1);
expect(ledger[0].row.action).toBe(w.action);
expect(ledger[0].row.user_id).toBe(OWNER);
expect(ledger[0].row.record_id).toBe(PREF.id);
expect(created.filter((c) => c.object === 'sys_activity')).toHaveLength(0);
});
}

it('CONTROL: the same user\'s write to an ordinary audited object keeps its activity row', async () => {
const { engine, fire, created } = makeEngine(SCHEMA);
installAuditWriters(engine as any, 'test.audit');

await fire('afterUpdate', {
object: 'crm_lead',
input: { id: 'lead-1' },
previous: { id: 'lead-1', name: 'Acme' },
result: { id: 'lead-1', name: 'Acme Corp' },
session: { userId: OWNER },
});

expect(rowsOn(created, 'sys_audit_log', 'crm_lead')).toHaveLength(1);
const activity = rowsOn(created, 'sys_activity', 'crm_lead');
expect(activity).toHaveLength(1);
expect(activity[0].row.actor_id).toBe(OWNER);
});

const OTHER_WRITERS = [
// An organization admin editing the row from Setup's User Preferences grid.
{ who: 'a write by another user (an admin)', session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' },
// A system or service write: no acting user at all.
{ who: 'a system write with no user', session: { isSystem: true }, previous: PREF, result: PREF, actor: null },
// The owner moving the row to someone else: not purely their own row.
{
who: 'the owner moving the row to another user',
session: { userId: OWNER },
previous: PREF,
result: { ...PREF, user_id: 'u_bob' },
actor: OWNER,
},
] as const;

for (const w of OTHER_WRITERS) {
it(`CONTROL: on a user's preference row, ${w.who} keeps its activity row`, async () => {
const { engine, fire, created } = makeEngine(SCHEMA);
installAuditWriters(engine as any, 'test.audit');

await fire('afterUpdate', {
object: 'sys_user_preference',
input: { id: PREF.id },
previous: w.previous,
result: { ...w.result, value: [{ id: 'acc-2' }] },
session: w.session,
});

expect(rowsOn(created, 'sys_audit_log', 'sys_user_preference')).toHaveLength(1);
const activity = rowsOn(created, 'sys_activity', 'sys_user_preference');
expect(activity).toHaveLength(1);
expect(activity[0].row.actor_id).toBe(w.actor);
});
}
});

describe('audit writers — enable.feeds server-side enforcement (#2707)', () => {
const SCHEMA = {
sys_audit_log: SINGLE_TENANT.sys_audit_log,
Expand Down
72 changes: 70 additions & 2 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export interface AuditWriterOptions {
* Skip rules avoid recursion and noise:
* - Never audit the audit/activity tables themselves.
* - Never audit session/presence/auth tables (high-frequency, low value).
* - A user's own write to their own per-user UI-state row writes the ledger
* row and no activity row (`PER_USER_UI_STATE_OWNER_FIELD`).
* - Read-only operations (`afterFind`) are not audited BY THIS WRITER. Since
* #8992 the `read` action has its own writer in `read-audit.ts`, installed
* separately and only over the objects a deployment opts in: record-detail
Expand Down Expand Up @@ -260,6 +262,67 @@ const SKIP_OBJECTS = new Set<string>([
*/
export const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS];

/**
* [#22961] Per-user UI-state objects: the ledger row stays, and the activity
* mirror is skipped for the owning user's OWN write only.
*
* The rule: what reaches a user's bell is another actor's act or something the
* user needs, never the user's own UI state. A `sys_activity` row reaches the
* bell (its Activity tab lists the newest rows the caller may read). A per-user
* UI-state row is re-saved by the client as a side effect of using the
* interface, and RLS keeps it private to its user, so the one reader of its
* activity row is the user who just caused it. Mirrored, every navigation came
* back as `Updated User Preference "ui.recent"`, and a fresh user's bell held
* nothing else.
*
* - CLOSED and DECLARED: an object is a member only by being listed here,
* mapped to the field that names its owning user. ⛔ Never a name heuristic.
* No spec key means "per-user UI state"; `enable.activities: false` mutes
* every writer, which is wider than this rule.
* - OWN WRITES ONLY: the user the activity row would name (`actor_id`) must be
* the owner on every side of the row the write carries. Another actor's
* write keeps its activity row: an organization admin editing the row from
* Setup's User Preferences grid (`modifyAllRecords` passes the self-only
* RLS), a system write with no user, a write that moves the row to another
* owner.
* - ACTIVITY ONLY: the `sys_audit_log` row is written exactly as before. That
* is why this is not `SKIP_OBJECTS`, which drops the ledger row too and is
* the `AUDIT_EXCLUDED_OBJECTS` list the hook registration and `read-audit.ts`
* share.
*
* Membership as measured: the console keeps all of its per-user UI state
* (`ui.recent`, `ui.favorites`, `ui.flow.palette.recents`) on
* `sys_user_preference`, and no other per-user UI-state object exists. A new
* one joins by a line here.
*/
const PER_USER_UI_STATE_OWNER_FIELD: ReadonlyMap<string, string> = new Map([
['sys_user_preference', 'user_id'],
]);

/**
* Whether this write is a user's own write to their own per-user UI-state row
* ({@link PER_USER_UI_STATE_OWNER_FIELD}): the object is listed, an acting user
* is known, and every side of the row that names an owner names that user.
* A side without the owner field (a delete's result is often just `{ id }`) is
* not evidence either way; no side naming an owner at all answers `false`.
*/
function isOwnUiStateWrite(
object: string,
actorUserId: string | undefined,
before: unknown,
after: unknown,
): boolean {
const ownerField = PER_USER_UI_STATE_OWNER_FIELD.get(object);
if (!ownerField || !actorUserId) return false;
const owners: string[] = [];
for (const side of [before, after]) {
if (!side || typeof side !== 'object') continue;
const owner = (side as Record<string, unknown>)[ownerField];
if (owner !== undefined && owner !== null) owners.push(String(owner));
}
return owners.length > 0 && owners.every((owner) => owner === actorUserId);
}

/** Fields that are noise in diffs (always change, never user-meaningful). */
const NOISE_FIELDS = new Set<string>([
'updated_at',
Expand Down Expand Up @@ -2012,14 +2075,19 @@ export function installAuditWriters(
// lever for activity-row growth (ADR-0057). The compliance audit row is
// NOT gated — sys_audit_log capture stays unconditional.
const activitiesEnabled = getObjectDef(ctx.object)?.enable?.activities !== false;
// [#22961] A user's own write to their own per-user UI-state row is not
// mirrored either (PER_USER_UI_STATE_OWNER_FIELD states the rule). Judged
// on `userId`, the same value `actor_id` would carry. The ledger row is
// untouched.
const mirrorsActivity = activitiesEnabled && !isOwnUiStateWrite(ctx.object, userId, before, after);
// [#22510, #22527] The name and avatar of the user `actor_id` names — the
// SAME `userId`, so the columns cannot disagree about who acted — from one
// memoized `sys_user` read. Read only when an activity row will be
// written. No user ⇒ no name and no avatar: ADR-0118 D1 keeps the system
// actor `null`, and rendering it as "System" (with its icon) is the UI's
// rule, not data this writer persists.
const actor: ActorSnapshot =
activitiesEnabled && userId ? await resolveActorSnapshot(api, userId) : NO_ACTOR_SNAPSHOT;
mirrorsActivity && userId ? await resolveActorSnapshot(api, userId) : NO_ACTOR_SNAPSHOT;

const activityRow: Record<string, any> = {
type: activityType,
Expand Down Expand Up @@ -2055,7 +2123,7 @@ export function installAuditWriters(
activityRow.organization_id = tenantId ?? null;
}

const activityRowToWrite = activitiesEnabled ? activityRow : undefined;
const activityRowToWrite = mirrorsActivity ? activityRow : undefined;
// [#21262] The ledger row is written first; the writer advances this
// before the activity row, so after a throw it names the refused table.
const progress: AuditTrailWriteProgress = { writing: 'sys_audit_log' };
Expand Down
Loading