fix: use service-role client for processing_history writes (#289)

appendProcessingHistory was accepting a caller-provided SupabaseClient,
but processing_history has no INSERT RLS policy (by design — audit trail
writes should not be user-controllable). Extension handlers pass the
authenticated user client (ctx.supabase), which caused silent RLS
violations caught by try-catch — the audit trail was wired up but empty.

Switch to creating a service-role client internally, matching the
existing event_log pattern (event-log-handler.ts uses
createServiceClientNoCookies). Remove the supabase parameter from both
appendProcessingHistory and appendProcessingHistoryBatch. Update all
5 call sites in invoice-inbox and inbox-smart-match.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-21 09:20:12 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent aec14f0ca7
commit dd920355b3
3 changed files with 18 additions and 16 deletions
@@ -68,7 +68,7 @@ export async function processInboxItemMatch(
// Append DeterministicMatch event — records that the narrowing ran
let deterministicEventId: string
try {
deterministicEventId = await appendProcessingHistory(ctx.supabase, {
deterministicEventId = await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
aggregateType: 'MatchProposal',
@@ -121,7 +121,7 @@ export async function processInboxItemMatch(
// Record the LLM attempt in processing_history
try {
await appendProcessingHistory(ctx.supabase, {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
causationId: deterministicEventId,
+3 -3
View File
@@ -68,7 +68,7 @@ async function uploadAndClassify(
// Audit: DocumentIngested
try {
await appendProcessingHistory(supabase, {
await appendProcessingHistory({
companyId,
correlationId,
aggregateType: 'Document',
@@ -102,7 +102,7 @@ async function uploadAndClassify(
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
try {
await appendProcessingHistory(supabase, {
await appendProcessingHistory({
companyId,
correlationId,
aggregateType: 'Document',
@@ -190,7 +190,7 @@ async function uploadAndClassify(
// Audit: DocumentClassified (only when classification succeeded)
if (!classificationError && classificationResult) {
try {
await appendProcessingHistory(supabase, {
await appendProcessingHistory({
companyId,
correlationId,
aggregateType: 'Document',
+13 -11
View File
@@ -1,9 +1,10 @@
/**
* Processing history (behandlingshistorik) — append helper.
*
* Appends events to the processing_history table within the caller's
* database transaction. Throws on failure so that the table writes
* and the audit trail are atomically consistent.
* Uses a service-role client internally (no INSERT RLS policy on
* processing_history — matching the event_log pattern). Company scoping
* is enforced by companyId in the event payload, not by RLS.
* Throws on failure.
*
* PII BOUNDARY: payload MUST contain pseudonymous IDs only (user UUIDs,
* company UUIDs, counterparty IDs). Never names, emails, personnummer,
@@ -13,11 +14,11 @@
* reference, which is the required behavior per v0.2 §10.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
ProcessingHistoryAggregateType,
ProcessingHistoryActor,
} from '@/types'
import { createServiceClient } from '@/lib/supabase/server'
import { z } from 'zod'
// ── PII validator ───────────────────────────────────────────────
@@ -90,16 +91,15 @@ export interface AppendEventInput {
// ── Append functions ────────────────────────────────────────────
/**
* Append a single event to processing_history within the caller's transaction.
* Append a single event to processing_history.
*
* Uses the provided SupabaseClient (which should be the same client used for
* table writes in the command handler). Throws on failure so that both the
* table writes and the audit trail roll back together.
* Uses a service-role client internally (bypasses RLS) since processing_history
* has no INSERT policy — matching the event_log pattern. Company scoping is
* enforced by the companyId in the event payload, not by RLS.
*
* Returns the generated event_id (pre-generated client-side for causation chaining).
*/
export async function appendProcessingHistory(
supabase: SupabaseClient,
input: AppendEventInput
): Promise<string> {
// Validate payload + actor.label contain no PII
@@ -107,6 +107,7 @@ export async function appendProcessingHistory(
assertActorPiiSafe(input.actor)
const eventId = crypto.randomUUID()
const supabase = createServiceClient()
const { error } = await supabase
.from('processing_history')
@@ -135,13 +136,12 @@ export async function appendProcessingHistory(
}
/**
* Append multiple events atomically within the caller's transaction.
* Append multiple events atomically (single INSERT).
* Used for batch operations (e.g., migration commits, multi-event command handlers).
*
* Returns array of generated event_ids in input order.
*/
export async function appendProcessingHistoryBatch(
supabase: SupabaseClient,
inputs: AppendEventInput[]
): Promise<string[]> {
if (inputs.length === 0) return []
@@ -169,6 +169,8 @@ export async function appendProcessingHistoryBatch(
occurred_at: input.occurredAt.toISOString(),
}))
const supabase = createServiceClient()
const { error } = await supabase
.from('processing_history')
.insert(rows)