diff --git a/messages/en.json b/messages/en.json index dc7409ab44..5d3c240eaa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -61,6 +61,8 @@ "account-password-heading": "Change my password", "account-updated-succ": "Account updated successfully", "action": "Action", + "actor": "Actor", + "actor-user-email": "User email", "action-app-anr": "Android app not responding", "action-app-crash": "App crash", "action-app-crash-native": "Native app crash", @@ -288,6 +290,8 @@ "api-key-create-partial-failure-title": "API key created, but role assignment failed", "api-key-create-partial-failure-warning-hashed": "We could not finish assigning roles, and automatic cleanup failed. This secure key may still exist in a partial state. Copy it now and store it safely because you will not be able to see it again.", "api-key-create-partial-failure-warning-plain": "We could not finish assigning roles, and automatic cleanup failed. This API key may still exist in a partial state. Copy it now and keep it safe in case you need to delete or inspect it manually.", + "api-key-id": "API key ID", + "api-key-name": "API key name", "api-key-not-found": "API key not found", "api-key-policy": "API Key Policy", "api-key-policy-description": "Configure policies for API keys used with this organization.", @@ -441,6 +445,7 @@ "audit-orgs-delete": "Organization Deleted", "audit-orgs-insert": "Organization Created", "audit-orgs-update": "Organization Updated", + "automated": "Automated", "available-channels": "Available channels", "available-in-the-san": "Available in the sandbox app", "available-versions": "Available bundles", @@ -1968,6 +1973,7 @@ "start-your-first-build": "Start your first native build", "statistics": "Statistics", "status": "Status", + "source": "Source", "storage-chart-mode": "Storage chart mode", "storage-hourly": "Hourly", "storage-trend": "Storage Trend", diff --git a/src/components/tables/AuditLogTable.vue b/src/components/tables/AuditLogTable.vue index 04e815a818..40162baf03 100644 --- a/src/components/tables/AuditLogTable.vue +++ b/src/components/tables/AuditLogTable.vue @@ -19,6 +19,8 @@ import { useSupabase } from '~/services/supabase' import { useDialogV2Store } from '~/stores/dialogv2' import { useOrganizationStore } from '~/stores/organization' +type AuditActorType = 'user' | 'apikey' | 'system' | 'unknown' + interface AuditLogRow { id: number created_at: string @@ -30,6 +32,11 @@ interface AuditLogRow { old_record: Record | null new_record: Record | null changed_fields: string[] | null + actor_type: AuditActorType + actor_user_id: string | null + actor_user_email: string | null + actor_apikey_id: number | null + actor_apikey_name: string | null } interface ExtendedAuditLog extends AuditLogRow { @@ -127,8 +134,8 @@ const columns: Ref = ref([ class: 'truncate max-w-8', }, { - label: 'email', - key: 'user_id', + label: 'actor', + key: 'actor', mobile: false, sortable: false, class: 'truncate max-w-8', @@ -230,6 +237,38 @@ function getTableLabel(tableName: string): string { } } +function getActorTypeLabel(actorType: AuditActorType): string { + switch (actorType) { + case 'user': + return t('user') + case 'apikey': + return t('api-key') + case 'system': + return t('automated') + default: + return t('unknown') + } +} + +function getActorUserEmail(item: ExtendedAuditLog): string | null { + return item.actor_user_email || item.user?.email || null +} + +function getActorDisplay(item: ExtendedAuditLog): string { + if (item.actor_type === 'apikey') { + const apiKey = item.actor_apikey_id ? `${t('api-key')} #${item.actor_apikey_id}` : t('api-key') + return item.actor_apikey_name ? `${apiKey} (${item.actor_apikey_name})` : apiKey + } + + if (item.actor_type === 'system') + return t('automated') + + if (item.actor_type === 'user') + return getActorUserEmail(item) || item.actor_user_id || item.user_id || '-' + + return t('unknown') +} + async function openDetails(item: ExtendedAuditLog) { selectedLog.value = item dialogStore.openDialog({ @@ -255,8 +294,8 @@ function displayValueKey(elem: ExtendedAuditLog, col: TableColumn): string { return getTableLabel(elem.table_name) case 'operation': return getOperationLabel(elem.operation) - case 'user_id': - return elem.user?.email || '-' + case 'actor': + return getActorDisplay(elem) case 'changed_fields': return getChangedFieldsDisplay(elem) case 'details': @@ -314,8 +353,9 @@ async function fetchAuditLogs() { const rows = (data ?? []) as ExtendedAuditLog[] for (const item of rows) { - if (item.user_id) { - const member = membersMap.value.get(item.user_id) + const memberId = item.actor_user_id || item.user_id + if (memberId) { + const member = membersMap.value.get(memberId) if (member) { item.user = member } @@ -581,9 +621,23 @@ onUnmounted(() => { -
- {{ t('email') }}: - {{ selectedLog.user.email }} +
+
+ {{ t('source') }}: + {{ getActorTypeLabel(selectedLog.actor_type) }} +
+
+ {{ t('api-key-id') }}: + #{{ selectedLog.actor_apikey_id }} +
+
+ {{ t('api-key-name') }}: + {{ selectedLog.actor_apikey_name }} +
+
+ {{ t('actor-user-email') }}: + {{ getActorUserEmail(selectedLog) }} +
diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index b3ac52e6b1..bb776467c8 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -384,6 +384,11 @@ export type Database = { } audit_logs: { Row: { + actor_apikey_id: number | null + actor_apikey_name: string | null + actor_type: string + actor_user_email: string | null + actor_user_id: string | null changed_fields: string[] | null created_at: string id: number @@ -396,6 +401,11 @@ export type Database = { user_id: string | null } Insert: { + actor_apikey_id?: number | null + actor_apikey_name?: string | null + actor_type?: string + actor_user_email?: string | null + actor_user_id?: string | null changed_fields?: string[] | null created_at?: string id?: number @@ -408,6 +418,11 @@ export type Database = { user_id?: string | null } Update: { + actor_apikey_id?: number | null + actor_apikey_name?: string | null + actor_type?: string + actor_user_email?: string | null + actor_user_id?: string | null changed_fields?: string[] | null created_at?: string id?: number @@ -419,22 +434,7 @@ export type Database = { table_name?: string user_id?: string | null } - Relationships: [ - { - foreignKeyName: "audit_logs_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "orgs" - referencedColumns: ["id"] - }, - { - foreignKeyName: "audit_logs_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "users" - referencedColumns: ["id"] - }, - ] + Relationships: [] } bandwidth_usage: { Row: { @@ -1951,6 +1951,21 @@ export type Database = { }, ] } + org_id_tombstones: { + Row: { + deleted_at: string + org_id: string + } + Insert: { + deleted_at?: string + org_id: string + } + Update: { + deleted_at?: string + org_id?: string + } + Relationships: [] + } orgs: { Row: { created_at: string | null diff --git a/supabase/functions/_backend/public/organization/audit.ts b/supabase/functions/_backend/public/organization/audit.ts index fba9c4c479..2e83e05579 100644 --- a/supabase/functions/_backend/public/organization/audit.ts +++ b/supabase/functions/_backend/public/organization/audit.ts @@ -24,6 +24,11 @@ const auditLogSchema = type({ old_record: 'unknown', new_record: 'unknown', changed_fields: 'string[] | null', + actor_type: '"user" | "apikey" | "system" | "unknown"', + actor_user_id: 'string | null', + actor_user_email: 'string | null', + actor_apikey_id: 'number | null', + actor_apikey_name: 'string | null', }) const auditLogsSchema = auditLogSchema.array() diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index b3ac52e6b1..bb776467c8 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -384,6 +384,11 @@ export type Database = { } audit_logs: { Row: { + actor_apikey_id: number | null + actor_apikey_name: string | null + actor_type: string + actor_user_email: string | null + actor_user_id: string | null changed_fields: string[] | null created_at: string id: number @@ -396,6 +401,11 @@ export type Database = { user_id: string | null } Insert: { + actor_apikey_id?: number | null + actor_apikey_name?: string | null + actor_type?: string + actor_user_email?: string | null + actor_user_id?: string | null changed_fields?: string[] | null created_at?: string id?: number @@ -408,6 +418,11 @@ export type Database = { user_id?: string | null } Update: { + actor_apikey_id?: number | null + actor_apikey_name?: string | null + actor_type?: string + actor_user_email?: string | null + actor_user_id?: string | null changed_fields?: string[] | null created_at?: string id?: number @@ -419,22 +434,7 @@ export type Database = { table_name?: string user_id?: string | null } - Relationships: [ - { - foreignKeyName: "audit_logs_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "orgs" - referencedColumns: ["id"] - }, - { - foreignKeyName: "audit_logs_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "users" - referencedColumns: ["id"] - }, - ] + Relationships: [] } bandwidth_usage: { Row: { @@ -1951,6 +1951,21 @@ export type Database = { }, ] } + org_id_tombstones: { + Row: { + deleted_at: string + org_id: string + } + Insert: { + deleted_at?: string + org_id: string + } + Update: { + deleted_at?: string + org_id?: string + } + Relationships: [] + } orgs: { Row: { created_at: string | null diff --git a/supabase/functions/_backend/utils/webhook.ts b/supabase/functions/_backend/utils/webhook.ts index b85f692abf..729bcd663b 100644 --- a/supabase/functions/_backend/utils/webhook.ts +++ b/supabase/functions/_backend/utils/webhook.ts @@ -19,6 +19,11 @@ export interface WebhookPayload { old_record: any | null new_record: any | null changed_fields: string[] | null + actor_type?: 'user' | 'apikey' | 'system' | 'unknown' + actor_user_id?: string | null + actor_user_email?: string | null + actor_apikey_id?: number | null + actor_apikey_name?: string | null } } @@ -70,6 +75,11 @@ export interface AuditLogData { new_record: any | null changed_fields: string[] | null user_id: string | null + actor_type?: 'user' | 'apikey' | 'system' | 'unknown' + actor_user_id?: string | null + actor_user_email?: string | null + actor_apikey_id?: number | null + actor_apikey_name?: string | null created_at: string } @@ -172,6 +182,11 @@ export function buildWebhookPayload(auditLogData: AuditLogData): WebhookPayload old_record: auditLogData.old_record, new_record: auditLogData.new_record, changed_fields: auditLogData.changed_fields, + actor_type: auditLogData.actor_type ?? (auditLogData.user_id ? 'user' : 'unknown'), + actor_user_id: auditLogData.actor_user_id ?? auditLogData.user_id, + actor_user_email: auditLogData.actor_user_email ?? null, + actor_apikey_id: auditLogData.actor_apikey_id ?? null, + actor_apikey_name: auditLogData.actor_apikey_name ?? null, }, } } diff --git a/supabase/migrations/20260608160711_audit_log_actor_attribution.sql b/supabase/migrations/20260608160711_audit_log_actor_attribution.sql new file mode 100644 index 0000000000..65f205efc1 --- /dev/null +++ b/supabase/migrations/20260608160711_audit_log_actor_attribution.sql @@ -0,0 +1,500 @@ +CREATE TABLE IF NOT EXISTS "public"."org_id_tombstones" ( + "org_id" uuid NOT NULL, + "deleted_at" timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE "public"."org_id_tombstones" OWNER TO "postgres"; + +ALTER TABLE ONLY "public"."org_id_tombstones" + DROP CONSTRAINT IF EXISTS "org_id_tombstones_pkey"; + +ALTER TABLE ONLY "public"."org_id_tombstones" + ADD CONSTRAINT "org_id_tombstones_pkey" PRIMARY KEY ("org_id"); + +COMMENT ON TABLE "public"."org_id_tombstones" IS 'Deleted organization ids that must never be reused while retained audit logs can reference them.'; +COMMENT ON COLUMN "public"."org_id_tombstones"."org_id" IS 'Deleted organization id retained without foreign keys to prevent UUID reuse from exposing retained audit logs.'; + +ALTER TABLE "public"."org_id_tombstones" ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE "public"."org_id_tombstones" FROM PUBLIC; +REVOKE ALL ON TABLE "public"."org_id_tombstones" FROM "anon"; +REVOKE ALL ON TABLE "public"."org_id_tombstones" FROM "authenticated"; +GRANT ALL ON TABLE "public"."org_id_tombstones" TO "service_role"; + +DROP POLICY IF EXISTS "Deny client select on org_id_tombstones" ON "public"."org_id_tombstones"; +CREATE POLICY "Deny client select on org_id_tombstones" +ON "public"."org_id_tombstones" +AS RESTRICTIVE +FOR SELECT +TO "anon", "authenticated" +USING (false); + +DROP POLICY IF EXISTS "Deny client insert on org_id_tombstones" ON "public"."org_id_tombstones"; +CREATE POLICY "Deny client insert on org_id_tombstones" +ON "public"."org_id_tombstones" +AS RESTRICTIVE +FOR INSERT +TO "anon", "authenticated" +WITH CHECK (false); + +DROP POLICY IF EXISTS "Deny client update on org_id_tombstones" ON "public"."org_id_tombstones"; +CREATE POLICY "Deny client update on org_id_tombstones" +ON "public"."org_id_tombstones" +AS RESTRICTIVE +FOR UPDATE +TO "anon", "authenticated" +USING (false) +WITH CHECK (false); + +DROP POLICY IF EXISTS "Deny client delete on org_id_tombstones" ON "public"."org_id_tombstones"; +CREATE POLICY "Deny client delete on org_id_tombstones" +ON "public"."org_id_tombstones" +AS RESTRICTIVE +FOR DELETE +TO "anon", "authenticated" +USING (false); + +INSERT INTO "public"."org_id_tombstones" ("org_id") +SELECT DISTINCT "audit_logs"."org_id" +FROM "public"."audit_logs" AS "audit_logs" +LEFT JOIN "public"."orgs" AS "orgs" ON "orgs"."id" = "audit_logs"."org_id" +WHERE "orgs"."id" IS NULL +ON CONFLICT ("org_id") DO NOTHING; + +ALTER TABLE "public"."audit_logs" + DROP CONSTRAINT IF EXISTS "audit_logs_org_id_fkey"; + +ALTER TABLE "public"."audit_logs" + DROP CONSTRAINT IF EXISTS "audit_logs_user_id_fkey"; + +ALTER TABLE "public"."audit_logs" + ADD COLUMN IF NOT EXISTS "actor_type" text NOT NULL DEFAULT 'system', + ADD COLUMN IF NOT EXISTS "actor_user_id" uuid, + ADD COLUMN IF NOT EXISTS "actor_user_email" text, + ADD COLUMN IF NOT EXISTS "actor_apikey_id" bigint, + ADD COLUMN IF NOT EXISTS "actor_apikey_name" text; + +ALTER TABLE "public"."audit_logs" + DROP CONSTRAINT IF EXISTS "audit_logs_actor_type_check"; + +ALTER TABLE "public"."audit_logs" + ADD CONSTRAINT "audit_logs_actor_type_check" + CHECK ("actor_type" IN ('user', 'apikey', 'system', 'unknown')); + +COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'Legacy actor user id. Kept without a foreign key so audit history survives user deletion.'; +COMMENT ON COLUMN "public"."audit_logs"."org_id" IS 'Organization context for filtering. Kept without a foreign key so audit history survives organization deletion.'; +COMMENT ON COLUMN "public"."audit_logs"."actor_type" IS 'Source of the action: user, apikey, system, or unknown for older rows that cannot be classified.'; +COMMENT ON COLUMN "public"."audit_logs"."actor_user_id" IS 'Snapshot of the user id behind the action. No foreign key by design.'; +COMMENT ON COLUMN "public"."audit_logs"."actor_user_email" IS 'Snapshot of the user email at audit time. No foreign key by design.'; +COMMENT ON COLUMN "public"."audit_logs"."actor_apikey_id" IS 'Snapshot of the API key id used for the action. The API key secret is never stored here.'; +COMMENT ON COLUMN "public"."audit_logs"."actor_apikey_name" IS 'Snapshot of the API key name at audit time.'; + +UPDATE "public"."audit_logs" +SET + "actor_type" = CASE WHEN "user_id" IS NULL THEN 'unknown' ELSE 'user' END, + "actor_user_id" = "user_id" +WHERE "actor_user_id" IS NULL + AND "actor_apikey_id" IS NULL; + +UPDATE "public"."audit_logs" AS "audit_logs" +SET "actor_user_email" = "users"."email" +FROM "public"."users" AS "users" +WHERE "audit_logs"."actor_user_id" = "users"."id" + AND "audit_logs"."actor_user_email" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_audit_logs_actor_type" + ON "public"."audit_logs"("actor_type"); + +CREATE INDEX IF NOT EXISTS "idx_audit_logs_actor_apikey_id" + ON "public"."audit_logs"("actor_apikey_id"); + +CREATE OR REPLACE FUNCTION "public"."prevent_org_id_reuse"() RETURNS "trigger" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW."id" IS DISTINCT FROM OLD."id" THEN + RAISE EXCEPTION 'org_id_update_forbidden' + USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "public"."org_id_tombstones" + WHERE "org_id" = NEW."id" + ) THEN + RAISE EXCEPTION 'org_id_reuse_forbidden' + USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."prevent_org_id_reuse"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."prevent_org_id_reuse"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."prevent_org_id_reuse"() TO "service_role"; + +CREATE OR REPLACE FUNCTION "public"."lock_org_tombstone_guard"() RETURNS "trigger" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Serialize org id lifecycle changes so retained audit logs cannot become + -- visible through a concurrent delete/recreate race on the same org UUID. + LOCK TABLE "public"."org_id_tombstones" IN SHARE ROW EXCLUSIVE MODE; + RETURN NULL; +END; +$$; + +ALTER FUNCTION "public"."lock_org_tombstone_guard"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."lock_org_tombstone_guard"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."lock_org_tombstone_guard"() TO "service_role"; + +CREATE OR REPLACE FUNCTION "public"."tombstone_deleted_org_id"() RETURNS "trigger" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO "public"."org_id_tombstones" ("org_id", "deleted_at") + VALUES (OLD."id", now()) + ON CONFLICT ("org_id") DO NOTHING; + + RETURN OLD; +END; +$$; + +ALTER FUNCTION "public"."tombstone_deleted_org_id"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."tombstone_deleted_org_id"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."tombstone_deleted_org_id"() TO "service_role"; + +DROP TRIGGER IF EXISTS "lock_org_tombstone_guard" ON "public"."orgs"; +CREATE TRIGGER "lock_org_tombstone_guard" + BEFORE INSERT OR DELETE OR UPDATE OF "id" ON "public"."orgs" + FOR EACH STATEMENT EXECUTE FUNCTION "public"."lock_org_tombstone_guard"(); + +DROP TRIGGER IF EXISTS "prevent_org_id_reuse" ON "public"."orgs"; +CREATE TRIGGER "prevent_org_id_reuse" + BEFORE INSERT OR UPDATE OF "id" ON "public"."orgs" + FOR EACH ROW EXECUTE FUNCTION "public"."prevent_org_id_reuse"(); + +DROP TRIGGER IF EXISTS "tombstone_deleted_org_id" ON "public"."orgs"; +CREATE TRIGGER "tombstone_deleted_org_id" + BEFORE DELETE ON "public"."orgs" + FOR EACH ROW EXECUTE FUNCTION "public"."tombstone_deleted_org_id"(); + +CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_old_record JSONB; + v_new_record JSONB; + v_changed_fields TEXT[]; + v_org_id UUID; + v_record_id TEXT; + v_user_id UUID; + v_key TEXT; + v_api_key_text TEXT; + v_api_key public.apikeys%ROWTYPE; + v_actor_type TEXT := 'system'; + v_actor_user_id UUID; + v_actor_user_email TEXT; + v_actor_apikey_id BIGINT; + v_actor_apikey_name TEXT; + v_stats_refresh_fields CONSTANT TEXT[] := ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']; +BEGIN + SELECT public.get_apikey_header() INTO v_api_key_text; + + IF v_api_key_text IS NOT NULL THEN + SELECT * + INTO v_api_key + FROM public.find_apikey_by_value(v_api_key_text) + LIMIT 1; + + IF v_api_key.id IS NOT NULL AND NOT public.is_apikey_expired(v_api_key.expires_at) THEN + v_actor_type := 'apikey'; + v_actor_user_id := v_api_key.user_id; + v_actor_apikey_id := v_api_key.id; + v_actor_apikey_name := v_api_key.name; + END IF; + END IF; + + IF v_actor_type = 'system' THEN + SELECT auth.uid() INTO v_actor_user_id; + + IF v_actor_user_id IS NOT NULL THEN + v_actor_type := 'user'; + END IF; + END IF; + + IF v_actor_user_id IS NOT NULL THEN + SELECT "email" + INTO v_actor_user_email + FROM "public"."users" + WHERE "id" = v_actor_user_id; + END IF; + + v_user_id := v_actor_user_id; + + -- Convert records to JSONB based on operation type + IF TG_OP = 'DELETE' THEN + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := NULL; + ELSIF TG_OP = 'INSERT' THEN + v_old_record := NULL; + v_new_record := pg_catalog.to_jsonb(NEW); + ELSE -- UPDATE + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := pg_catalog.to_jsonb(NEW); + + -- Calculate changed fields by comparing old and new values + FOR v_key IN SELECT pg_catalog.jsonb_object_keys(v_new_record) + LOOP + IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN + v_changed_fields := pg_catalog.array_append(v_changed_fields, v_key); + END IF; + END LOOP; + + -- Dashboard chart refreshes only touch stats refresh state. The apps table + -- also receives updated_at from its update trigger, so keep that out too. + IF TG_TABLE_NAME = ANY(ARRAY['apps', 'orgs']) + AND v_changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) + WHERE changed_field.field_name <> ALL(v_stats_refresh_fields) + ) THEN + RETURN NEW; + END IF; + END IF; + + -- Get org_id and record_id based on table being modified + CASE TG_TABLE_NAME + WHEN 'orgs' THEN + v_org_id := COALESCE(NEW.id, OLD.id); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'apps' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.app_id, OLD.app_id)::TEXT; + WHEN 'channels' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'app_versions' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'org_users' THEN + v_org_id := COALESCE(NEW.org_id, OLD.org_id); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + ELSE + -- Fallback for any other table (shouldn't happen with current triggers) + v_org_id := NULL; + v_record_id := NULL; + END CASE; + + IF v_org_id IS NOT NULL THEN + INSERT INTO "public"."audit_logs" ( + table_name, record_id, operation, user_id, org_id, + old_record, new_record, changed_fields, + actor_type, actor_user_id, actor_user_email, actor_apikey_id, actor_apikey_name + ) VALUES ( + TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, + v_old_record, v_new_record, v_changed_fields, + v_actor_type, v_actor_user_id, v_actor_user_email, v_actor_apikey_id, v_actor_apikey_name + ); + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +ALTER FUNCTION "public"."audit_log_trigger"() OWNER TO "postgres"; + +CREATE OR REPLACE FUNCTION "public"."trigger_webhook_on_audit_log"() RETURNS "trigger" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Queue the audit log event for webhook dispatch + PERFORM pgmq.send( + 'webhook_dispatcher', + jsonb_build_object( + 'function_name', 'webhook_dispatcher', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'audit_log_id', NEW.id, + 'table_name', NEW.table_name, + 'operation', NEW.operation, + 'org_id', NEW.org_id, + 'record_id', NEW.record_id, + 'old_record', NEW.old_record, + 'new_record', NEW.new_record, + 'changed_fields', NEW.changed_fields, + 'user_id', NEW.user_id, + 'actor_type', NEW.actor_type, + 'actor_user_id', NEW.actor_user_id, + 'actor_user_email', NEW.actor_user_email, + 'actor_apikey_id', NEW.actor_apikey_id, + 'actor_apikey_name', NEW.actor_apikey_name, + 'created_at', NEW.created_at + ) + ) + ); + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."trigger_webhook_on_audit_log"() OWNER TO "postgres"; + +CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion"() RETURNS TABLE("deleted_count" integer, "deleted_user_ids" "uuid"[]) +LANGUAGE "plpgsql" SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + account_record RECORD; + org_record RECORD; + deleted_users UUID[] := ARRAY[]::UUID[]; + total_deleted INTEGER := 0; + other_super_admins_count INTEGER; + replacement_owner_id UUID; +BEGIN + -- Loop through all accounts marked for deletion where removal_date has passed + FOR account_record IN + SELECT "account_id", "removal_date", "removed_data" + FROM "public"."to_delete_accounts" + WHERE "removal_date" < NOW() + LOOP + BEGIN + -- Process each org the user belongs to + FOR org_record IN + SELECT DISTINCT "org_id", "user_right" + FROM "public"."org_users" + WHERE "user_id" = account_record.account_id + LOOP + -- Reset replacement_owner_id for each org + replacement_owner_id := NULL; + + -- Check if user is a super_admin in this org + IF org_record.user_right = 'super_admin'::"public"."user_min_right" THEN + -- Count other super_admins in this org (excluding the user being deleted) + SELECT COUNT(*) INTO other_super_admins_count + FROM "public"."org_users" + WHERE "org_id" = org_record.org_id + AND "user_id" != account_record.account_id + AND "user_right" = 'super_admin'::"public"."user_min_right"; + + IF other_super_admins_count = 0 THEN + -- User is the last super_admin: DELETE all org resources + RAISE NOTICE 'User % is last super_admin of org %. Deleting all org resources.', + account_record.account_id, org_record.org_id; + + -- Delete deploy_history for this org + DELETE FROM "public"."deploy_history" WHERE "owner_org" = org_record.org_id; + + -- Delete channel_devices for this org + DELETE FROM "public"."channel_devices" WHERE "owner_org" = org_record.org_id; + + -- Delete channels for this org + DELETE FROM "public"."channels" WHERE "owner_org" = org_record.org_id; + + -- Delete app_versions for this org + DELETE FROM "public"."app_versions" WHERE "owner_org" = org_record.org_id; + + -- Delete apps for this org + DELETE FROM "public"."apps" WHERE "owner_org" = org_record.org_id; + + -- Delete the org itself since user is last super_admin. Audit logs + -- intentionally keep their org_id snapshot without a foreign key. + DELETE FROM "public"."orgs" WHERE "id" = org_record.org_id; + + -- Skip ownership transfer since all resources are deleted + CONTINUE; + END IF; + END IF; + + -- If we reach here, we need to transfer ownership (either non-super_admin or non-last super_admin) + -- Find a super_admin to transfer ownership to + SELECT "user_id" INTO replacement_owner_id + FROM "public"."org_users" + WHERE "org_id" = org_record.org_id + AND "user_id" != account_record.account_id + AND "user_right" = 'super_admin'::"public"."user_min_right" + LIMIT 1; + + IF replacement_owner_id IS NOT NULL THEN + RAISE NOTICE 'Transferring ownership from user % to user % in org %', + account_record.account_id, replacement_owner_id, org_record.org_id; + + -- Transfer app ownership + UPDATE "public"."apps" + SET "user_id" = replacement_owner_id, "updated_at" = NOW() + WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer app_versions ownership + UPDATE "public"."app_versions" + SET "user_id" = replacement_owner_id, "updated_at" = NOW() + WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer channels ownership + UPDATE "public"."channels" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer deploy_history ownership + UPDATE "public"."deploy_history" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer org ownership if user created it + UPDATE "public"."orgs" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "id" = org_record.org_id AND "created_by" = account_record.account_id; + ELSE + RAISE WARNING 'No super_admin found to transfer ownership in org % for user %', + org_record.org_id, account_record.account_id; + END IF; + END LOOP; + + -- Delete from public.users table + DELETE FROM "public"."users" WHERE "id" = account_record.account_id; + + -- Delete from auth.users table + DELETE FROM "auth"."users" WHERE "id" = account_record.account_id; + + -- Remove from to_delete_accounts table + DELETE FROM "public"."to_delete_accounts" WHERE "account_id" = account_record.account_id; + + -- Track the deleted user + deleted_users := "array_append"(deleted_users, account_record.account_id); + total_deleted := total_deleted + 1; + + -- Log the deletion + RAISE NOTICE 'Successfully deleted account: % (removal date: %)', + account_record.account_id, account_record.removal_date; + + EXCEPTION + WHEN OTHERS THEN + -- Log the error but continue with other accounts + RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; + END; + END LOOP; + + -- Return results + deleted_count := total_deleted; + deleted_user_ids := deleted_users; + RETURN NEXT; + + RAISE NOTICE 'Deletion process completed. Total accounts deleted: %', total_deleted; +END; +$$; + +ALTER FUNCTION "public"."delete_accounts_marked_for_deletion"() OWNER TO "postgres"; diff --git a/supabase/seed.sql b/supabase/seed.sql index 091fb7ecee..5b791c3bd1 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -55,6 +55,7 @@ BEGIN TRUNCATE TABLE "public"."usage_credit_transactions" CASCADE; TRUNCATE TABLE "public"."usage_credit_consumptions" CASCADE; TRUNCATE TABLE "public"."usage_overage_events" CASCADE; + TRUNCATE TABLE "public"."org_id_tombstones"; -- RBAC tables: must truncate in order to respect foreign keys TRUNCATE TABLE "public"."role_bindings" RESTART IDENTITY CASCADE; TRUNCATE TABLE "public"."group_members" RESTART IDENTITY CASCADE; @@ -906,6 +907,7 @@ DECLARE production_channel_id bigint; beta_channel_id bigint; development_channel_id bigint; no_access_channel_id bigint; electron_only_channel_id bigint; BEGIN PERFORM pg_advisory_xact_lock(hashtext(p_app_id)); + EXECUTE 'DELETE FROM public.org_id_tombstones WHERE org_id = $1' USING org_id; PERFORM public.reset_app_data(p_app_id); -- Ensure the base Stripe customer and org exist so FK inserts are stable between tests INSERT INTO public.stripe_info ( diff --git a/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql b/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql index 37580016f7..692db6930e 100644 --- a/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql +++ b/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql @@ -1062,7 +1062,16 @@ VALUES -- (Normally these would be created by triggers, but we insert directly for testing) INSERT INTO public.audit_logs ( - table_name, record_id, operation, user_id, org_id, old_record, new_record + table_name, + record_id, + operation, + user_id, + org_id, + old_record, + new_record, + actor_type, + actor_user_id, + actor_user_email ) VALUES ( @@ -1072,7 +1081,10 @@ VALUES 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, NULL, - '{"app_id": "com.audit.test"}'::JSONB + '{"app_id": "com.audit.test"}'::JSONB, + 'user', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, + 'audit_admin1@test.com' ), ( 'channels', @@ -1081,7 +1093,10 @@ VALUES 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, '{"name": "old_channel"}'::JSONB, - '{"name": "new_channel"}'::JSONB + '{"name": "new_channel"}'::JSONB, + 'user', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, + 'audit_admin1@test.com' ); -- Count audit logs before deletion (includes trigger-created entries from org/org_users inserts) @@ -1156,8 +1171,7 @@ SELECT 'Audit log entries still exist after user deletion' ); --- Verify audit logs that were owned by admin1 are now owned by admin2 --- The key test is that entries originally created by admin1 are transferred +-- Verify audit logs keep the deleted user's legacy id snapshot instead of rewriting ownership SELECT ok( ( @@ -1165,26 +1179,30 @@ SELECT FROM public.audit_logs WHERE - user_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc'::UUID + user_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID AND org_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID AND table_name IN ('apps', 'channels') AND record_id IN ('com.audit.test', '3001') ) = 2, - 'Audit log entries ownership transferred to remaining super_admin' + 'Audit log entries keep deleted user id snapshot' ); --- Verify no audit logs owned by admin1 remain (they should have been transferred) +-- Verify actor attribution keeps the deleted user's email snapshot SELECT ok( - NOT EXISTS ( - SELECT 1 + ( + SELECT count(*) FROM public.audit_logs WHERE org_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID - AND user_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID - ), - 'No audit log entries remain owned by deleted user' + AND actor_type = 'user' + AND actor_user_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID + AND actor_user_email = 'audit_admin1@test.com' + AND table_name IN ('apps', 'channels') + AND record_id IN ('com.audit.test', '3001') + ) = 2, + 'Audit log entries keep deleted user email snapshot' ); -- Clean up audit log test diff --git a/supabase/tests/40_test_audit_log_apikey.sql b/supabase/tests/40_test_audit_log_apikey.sql index 09967ee385..7c6915d79f 100644 --- a/supabase/tests/40_test_audit_log_apikey.sql +++ b/supabase/tests/40_test_audit_log_apikey.sql @@ -10,7 +10,7 @@ BEGIN; -- Org: 046a36ac-e03c-4590-9257-bd6c9dba9ee8 -- App: com.demo.app -SELECT plan(11); +SELECT plan(16); -- Test 1: audit_logs_allowed_orgs should fail fast when no auth and no -- API key header is set @@ -336,6 +336,98 @@ END $$; SELECT ok(TRUE, 'audit log contains correct old_record and new_record data'); +-- Tests 12-16: org ids stay safe for retained audit-log lookup +SELECT ok( + EXISTS ( + SELECT 1 + FROM pg_trigger AS t + INNER JOIN pg_class AS c ON c.oid = t.tgrelid + INNER JOIN pg_namespace AS n ON n.oid = c.relnamespace + WHERE + n.nspname = 'public' + AND c.relname = 'orgs' + AND t.tgname = 'lock_org_tombstone_guard' + AND NOT t.tgisinternal + AND (t.tgtype & 1) = 0 + AND (t.tgtype & 2) = 2 + AND (t.tgtype & 4) = 4 + AND (t.tgtype & 8) = 8 + AND (t.tgtype & 16) = 16 + ), + 'org tombstone guard serializes insert/delete/id-update statements' +); + +INSERT INTO public.orgs ( + id, + created_by, + name, + management_email, + use_new_rbac +) VALUES ( + '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid, + '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid, + 'Audit Tombstone Test Org', + 'audit-tombstone@test.com', + false +); + +SELECT throws_ok( + $q$ + UPDATE public.orgs + SET id = '18a04286-8f89-4e8b-825f-d045e4c823b4'::uuid + WHERE id = '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid; + $q$, + 'P0001', + 'org_id_update_forbidden', + 'org id cannot be changed after creation' +); + +DELETE FROM public.orgs +WHERE id = '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid; + +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.org_id_tombstones + WHERE + org_id = '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid + ), + 'deleted org id is tombstoned' +); + +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.audit_logs + WHERE + org_id = '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid + AND table_name = 'orgs' + AND operation = 'DELETE' + ), + 'deleted org audit log remains retained' +); + +SELECT throws_ok( + $q$ + INSERT INTO public.orgs ( + id, + created_by, + name, + management_email, + use_new_rbac + ) VALUES ( + '0e5b4c90-f3fa-49d8-a9f7-1f83b57ec2a1'::uuid, + '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid, + 'Reused Audit Tombstone Test Org', + 'audit-tombstone-reuse@test.com', + false + ); + $q$, + 'P0001', + 'org_id_reuse_forbidden', + 'deleted org id cannot be reused' +); + -- Finish SELECT * FROM finish(); -- noqa: AM04 diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 4ccb3d8fd6..2d554f57a4 100644 --- a/tests/audit-logs.test.ts +++ b/tests/audit-logs.test.ts @@ -3,7 +3,7 @@ import { type } from 'arktype' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { safeParseSchema } from '../supabase/functions/_backend/utils/ark_validation.ts' -import { BASE_URL, createDirectApiKeyWithBindings, fetchWithRetry, getAuthHeaders, getSupabaseClient, TEST_EMAIL, USER_ID } from './test-utils.ts' +import { BASE_URL, createDirectApiKeyWithBindings, executeSQL, fetchWithRetry, getAuthHeaders, getSupabaseClient, TEST_EMAIL, USER_ID } from './test-utils.ts' const ORG_ID = randomUUID() const globalId = randomUUID() @@ -23,6 +23,11 @@ const auditLogSchema = type({ old_record: 'unknown', new_record: 'unknown', changed_fields: 'string[] | null', + actor_type: '"user" | "apikey" | "system" | "unknown"', + actor_user_id: 'string | null', + actor_user_email: 'string | null', + actor_apikey_id: 'number | null', + actor_apikey_name: 'string | null', }) const auditLogsResponseSchema = type({ @@ -43,6 +48,11 @@ interface AuditLog { old_record: unknown new_record: unknown changed_fields: string[] | null + actor_type: 'user' | 'apikey' | 'system' | 'unknown' + actor_user_id: string | null + actor_user_email: string | null + actor_apikey_id: number | null + actor_apikey_name: string | null } function parseAuditLogsResponse(value: unknown) { @@ -52,6 +62,7 @@ function parseAuditLogsResponse(value: unknown) { let authHeaders: Record let apiKeyAuthHeaders: Record let apiKeyId: number | null = null +let actorUserEmail: string async function waitForAuditLog( url: string, @@ -88,6 +99,15 @@ async function waitForAuditLog( beforeAll(async () => { authHeaders = await getAuthHeaders() + const { data: actorUser, error: actorUserError } = await getSupabaseClient() + .from('users') + .select('email') + .eq('id', USER_ID) + .single() + if (actorUserError || !actorUser) + throw actorUserError ?? new Error('Failed to load audit actor user') + actorUserEmail = actorUser.email + // Create stripe_info for this test org const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ customer_id: customerId, @@ -276,6 +296,36 @@ describe('[GET] /organization/audit', () => { const responseData = await response.json() as { error: string } expect(responseData.error).toBe('invalid_org_id') }) + + it('audit logs keep org and user snapshots without foreign keys', async () => { + const orphanOrgId = randomUUID() + const orphanUserId = randomUUID() + const rows = await executeSQL( + ` + INSERT INTO public.audit_logs ( + table_name, + record_id, + operation, + user_id, + org_id, + actor_type, + actor_user_id, + actor_user_email + ) + VALUES ('orgs', $1::text, 'DELETE', $2::uuid, $1::uuid, 'user', $2::uuid, $3::text) + RETURNING id, org_id, user_id, actor_type, actor_user_email + `, + [orphanOrgId, orphanUserId, 'deleted-user@example.com'], + ) + + expect(rows).toHaveLength(1) + expect(rows[0].org_id).toBe(orphanOrgId) + expect(rows[0].user_id).toBe(orphanUserId) + expect(rows[0].actor_type).toBe('user') + expect(rows[0].actor_user_email).toBe('deleted-user@example.com') + + await executeSQL('DELETE FROM public.audit_logs WHERE id = $1', [rows[0].id]) + }) }) describe('audit log triggers', () => { @@ -300,6 +350,8 @@ describe('audit log triggers', () => { const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) + if (safe.success) + expect(safe.data.data.length).toBeGreaterThan(0) if (safe.success && safe.data.data.length > 0) { const latestUpdate = safe.data.data[0] @@ -307,6 +359,9 @@ describe('audit log triggers', () => { expect(latestUpdate.table_name).toBe('orgs') expect(latestUpdate.record_id).toBe(ORG_ID) expect(latestUpdate.org_id).toBe(ORG_ID) + expect(latestUpdate.actor_type).toBe('system') + expect(latestUpdate.actor_user_id).toBeNull() + expect(latestUpdate.actor_user_email).toBeNull() // Changed fields should include 'name' and 'updated_at' expect(Array.isArray(latestUpdate.changed_fields)).toBe(true) expect(latestUpdate.changed_fields).toContain('name') @@ -496,6 +551,11 @@ describe('audit logs for app_versions via API key', () => { expect(versionAuditLog.org_id).toBe(ORG_ID) // This is the key assertion: user_id should be set from the API key expect(versionAuditLog.user_id).toBe(USER_ID) + expect(versionAuditLog.actor_type).toBe('apikey') + expect(versionAuditLog.actor_user_id).toBe(USER_ID) + expect(versionAuditLog.actor_user_email).toBe(actorUserEmail) + expect(versionAuditLog.actor_apikey_id).toBe(apiKeyId) + expect(versionAuditLog.actor_apikey_name).toContain('audit-api-key-') expect(versionAuditLog.old_record).toBeNull() expect(versionAuditLog.new_record).toBeTruthy() if (versionAuditLog.new_record && typeof versionAuditLog.new_record === 'object') { @@ -535,6 +595,10 @@ describe('audit logs for app_versions via API key', () => { expect(updateAuditLog.org_id).toBe(ORG_ID) // user_id should be set from the API key expect(updateAuditLog.user_id).toBe(USER_ID) + expect(updateAuditLog.actor_type).toBe('apikey') + expect(updateAuditLog.actor_user_id).toBe(USER_ID) + expect(updateAuditLog.actor_user_email).toBe(actorUserEmail) + expect(updateAuditLog.actor_apikey_id).toBe(apiKeyId) expect(updateAuditLog.old_record).toBeTruthy() expect(updateAuditLog.new_record).toBeTruthy() // changed_fields should include 'comment' @@ -589,6 +653,10 @@ describe('audit logs for app_versions via API key', () => { expect(deleteAuditLog.org_id).toBe(ORG_ID) // user_id should be set from the API key expect(deleteAuditLog.user_id).toBe(USER_ID) + expect(deleteAuditLog.actor_type).toBe('apikey') + expect(deleteAuditLog.actor_user_id).toBe(USER_ID) + expect(deleteAuditLog.actor_user_email).toBe(actorUserEmail) + expect(deleteAuditLog.actor_apikey_id).toBe(apiKeyId) // Both old and new record should exist for UPDATE expect(deleteAuditLog.old_record).toBeTruthy() expect(deleteAuditLog.new_record).toBeTruthy() @@ -703,6 +771,10 @@ describe('audit logs for channel promotions via API key bundle flow', () => { expect(promotionAuditLog.table_name).toBe('channels') expect(promotionAuditLog.org_id).toBe(ORG_ID) expect(promotionAuditLog.user_id).toBe(USER_ID) + expect(promotionAuditLog.actor_type).toBe('apikey') + expect(promotionAuditLog.actor_user_id).toBe(USER_ID) + expect(promotionAuditLog.actor_user_email).toBe(actorUserEmail) + expect(promotionAuditLog.actor_apikey_id).toBe(apiKeyId) expect(promotionAuditLog.changed_fields).toContain('version') if (promotionAuditLog.new_record && typeof promotionAuditLog.new_record === 'object') {