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
51 changes: 50 additions & 1 deletion packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function makeEngine(

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_audit_log: ['id', 'action', 'user_id', 'actor', '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'],
};

Expand Down Expand Up @@ -126,6 +126,55 @@ describe('audit writers — organization_id stamping (#1532)', () => {
});
});

describe('audit writers — actor attribution (ADR-0014 D2, cloud#340)', () => {
it('records a real user id on actor + user_id', 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: { userId: 'user-7' },
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.user_id).toBe('user-7');
expect(audit?.row.actor).toBe('user-7');
});

it('attributes a service-token write (no userId) via session.actor → actor, user_id stays null', async () => {
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');
// The os-790m7q class: a service-token delete with no real user.
await fire('afterDelete', {
object: 'sys_environment',
input: { id: 'os-790m7q' },
__previous: { id: 'os-790m7q', name: 'test' },
result: { id: 'os-790m7q' },
session: { actor: 'svc:cloud-control' },
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.action).toBe('delete');
// user_id (sys_user lookup) stays null — a service principal isn't a user…
expect(audit?.row.user_id).toBeNull();
// …but the action is now ATTRIBUTABLE on actor.
expect(audit?.row.actor).toBe('svc:cloud-control');
});

it('leaves actor null when neither a user nor a service principal is present', async () => {
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');
await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-2' },
result: { id: 'lead-2', name: 'Beta' },
session: {},
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.actor).toBeNull();
expect(audit?.row.user_id).toBeNull();
});
});

describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)', () => {
// crm_opportunity with a tracked select field (Stage) carrying option labels.
const SCHEMA = {
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ export function installAuditWriters(

const sess: any = (ctx as any).session ?? {};
const userId: string | undefined = sess.userId;
// Principal label for attribution. Prefer the real user id; otherwise fall
// back to a service/automation principal the host put on the context
// (`ExecutionContext.actor`, e.g. `svc:<name>`). This is what makes a
// non-user-authenticated write attributable instead of a null actor — the
// os-790m7q env-delete class (ADR-0014 D2). `user_id` stays user-only (it's
// a strict sys_user lookup); the service principal lands on `actor`.
const actorLabel: string | null =
userId ?? (typeof sess.actor === 'string' && sess.actor.trim() ? sess.actor.trim() : null);
// Prefer the active session tenant, but fall back to the audited
// record's own `organization_id`. This matters in two cases:
// 1. Background jobs / unauthenticated sudo paths where the
Expand Down Expand Up @@ -406,6 +414,11 @@ export function installAuditWriters(
if (objectHasField('sys_audit_log', 'organization_id')) {
auditRow.organization_id = tenantId ?? null;
}
// First-class principal label (ADR-0014 D2). Conditionally stamped — same
// rationale as organization_id: older audit tables predate the column.
if (objectHasField('sys_audit_log', 'actor')) {
auditRow.actor = actorLabel;
}

const label = recordLabel(after ?? before, recordId ?? '');
let summary: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,26 @@ export const SysAuditLog = ObjectSchema.create({
),

user_id: Field.lookup('sys_user', {
label: 'User',
required: false,
readonly: true,
searchable: true,
description: 'User who performed the action (null for non-user / service actions — see actor)',
group: 'Event',
}),

// First-class principal label, independent of the sys_user lookup. Records
// WHO acted even when there is no real user row: a user id, a service-token
// principal (`svc:<name>`), or null/'system'. `user_id` stays a strict
// sys_user lookup (a service principal can't be stuffed there), so this is
// the field that makes service-token writes attributable (ADR-0014 D2).
actor: Field.text({
label: 'Actor',
required: false,
readonly: true,
searchable: true,
description: 'User who performed the action (null for system actions)',
maxLength: 255,
description: 'Principal that performed the action: a user id, svc:<name>, or null',
group: 'Event',
}),

Expand Down
11 changes: 11 additions & 0 deletions packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
/** Current user ID (resolved from session) */
userId: z.string().optional(),

/**
* Stable principal label for AUDIT ATTRIBUTION when the operation is not a
* real user — e.g. a service token (`svc:<name>`) or an automation. The audit
* writer records this on `sys_audit_log.actor` so a non-user-authenticated
* write (the class that left `user_id` null and made the os-790m7q env-delete
* unattributable) is still attributable. `userId` takes precedence when set;
* this is the fallback. The runtime/host sets it (e.g. the control plane's
* service-mode auth) — the framework only defines + records the contract.
*/
actor: z.string().optional(),

/**
* Current user's unique email (resolved from session, falling back to a
* `sys_user` lookup). Exposed to RLS as `current_user.email` for seedable,
Expand Down
Loading