From fc3ce7609e910ce4b51d38aed836af8bf55ee79f Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 26 Dec 2025 15:47:50 +0000 Subject: [PATCH 01/18] feat: Implement comprehensive audit log system for tracking CRUD operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add audit logging functionality to track all changes to critical tables (orgs, channels, app_versions, org_users). Includes: - Database migration with audit_logs table, triggers, and RLS policies (super_admin only) - 90-day retention with automatic cleanup via cron - API endpoint with pagination and filtering by table/operation - Frontend UI page accessible from organization settings - Full translation support for all 15 languages - Complete test coverage for API and trigger functionality 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 --- messages/de.json | 16 + messages/en.json | 16 + messages/es.json | 16 + messages/fr.json | 16 + messages/hi.json | 16 + messages/id.json | 16 + messages/it.json | 16 + messages/ja.json | 16 + messages/ko.json | 16 + messages/pl.json | 16 + messages/pt-br.json | 16 + messages/ru.json | 16 + messages/tr.json | 16 + messages/vi.json | 16 + messages/zh-cn.json | 16 + src/components/tables/AuditLogTable.vue | 386 ++++++++++++++++++ src/constants/organizationTabs.ts | 2 + src/layouts/settings.vue | 11 + src/pages/settings/organization/AuditLogs.vue | 45 ++ src/types/supabase.types.ts | 81 ++++ .../_backend/public/organization/audit.ts | 81 ++++ .../_backend/public/organization/index.ts | 7 + .../_backend/utils/supabase.types.ts | 81 ++++ .../migrations/20251226125240_audit_log.sql | 161 ++++++++ tests/audit-logs.test.ts | 336 +++++++++++++++ 25 files changed, 1431 insertions(+) create mode 100644 src/components/tables/AuditLogTable.vue create mode 100644 src/pages/settings/organization/AuditLogs.vue create mode 100644 supabase/functions/_backend/public/organization/audit.ts create mode 100644 supabase/migrations/20251226125240_audit_log.sql create mode 100644 tests/audit-logs.test.ts diff --git a/messages/de.json b/messages/de.json index b90b329788..7409c3491f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Sind Sie sicher, dass Sie diesen Schlüssel neu generieren möchten?", "alert-unknown-error": "Unbekannter Fehler, siehe Entwicklerkonsole", "all-apps": "Alle Apps", + "all-operations": "Alle Operationen", "all-organizations": "Alle Organisationen", + "all-tables": "Alle Tabellen", + "after": "Danach", + "audit-log-details": "Audit-Log Details", + "audit-logs": "Audit-Logs", + "audit-logs-description": "Zeigen Sie eine Historie der Änderungen an Ihrer Organisation an, einschließlich Änderungen an Kanälen, Bundles und Teammitgliedern.", + "before": "Vorher", + "changed-fields": "Geänderte Felder", + "changes": "Änderungen", + "close": "Schließen", + "deleted-record": "Gelöschter Datensatz", + "error-fetching-audit-logs": "Fehler beim Abrufen der Audit-Logs", + "new-record": "Neuer Datensatz", + "no-organization-selected": "Keine Organisation ausgewählt", + "search-by-record-id": "Nach Datensatz-ID suchen", + "view": "Anzeigen", "allow-dev-build": "Erlaube Entwicklungsbau", "allow-develoment-bui": "Erlaube Entwicklungsgeräte", "allow-device-to-self": "Erlauben Sie Geräten, sich selbst zu dissoziieren/assoziiieren.", diff --git a/messages/en.json b/messages/en.json index 01de788eab..0d4a991f30 100644 --- a/messages/en.json +++ b/messages/en.json @@ -119,6 +119,22 @@ "admin-dashboard-description": "Platform-wide statistics and analytics", "all-organizations": "All Organizations", "all-apps": "All Apps", + "all-tables": "All Tables", + "all-operations": "All Operations", + "audit-logs": "Audit Logs", + "audit-logs-description": "View a history of changes made to your organization, including modifications to channels, bundles, and team members.", + "error-fetching-audit-logs": "Error fetching audit logs", + "search-by-record-id": "Search by record ID", + "changed-fields": "Changed Fields", + "changes": "Changes", + "new-record": "New Record", + "deleted-record": "Deleted Record", + "no-organization-selected": "No organization selected", + "audit-log-details": "Audit Log Details", + "before": "Before", + "after": "After", + "close": "Close", + "view": "View", "date-range": "Date Range", "30-days": "Last 30 Days", "90-days": "Last 90 Days", diff --git a/messages/es.json b/messages/es.json index dc744c0f0c..0a3d581a00 100644 --- a/messages/es.json +++ b/messages/es.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "¿Estás seguro de que quieres regenerar esta clave?", "alert-unknown-error": "Error desconocido, consulta la consola de desarrollo", "all-apps": "Todas las aplicaciones", + "all-operations": "Todas las operaciones", "all-organizations": "Todas las Organizaciones", + "all-tables": "Todas las tablas", + "after": "Después", + "audit-log-details": "Detalles del registro de auditoría", + "audit-logs": "Registros de auditoría", + "audit-logs-description": "Vea un historial de cambios realizados en su organización, incluyendo modificaciones a canales, paquetes y miembros del equipo.", + "before": "Antes", + "changed-fields": "Campos modificados", + "changes": "Cambios", + "close": "Cerrar", + "deleted-record": "Registro eliminado", + "error-fetching-audit-logs": "Error al obtener los registros de auditoría", + "new-record": "Nuevo registro", + "no-organization-selected": "Ninguna organización seleccionada", + "search-by-record-id": "Buscar por ID de registro", + "view": "Ver", "allow-dev-build": "Permitir la construcción de desarrollo", "allow-develoment-bui": "Permitir dispositivos de desarrollo", "allow-device-to-self": "Permitir que los dispositivos se disocien/asocien por sí mismos", diff --git a/messages/fr.json b/messages/fr.json index d5e833ce40..c4f3a46f76 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Êtes-vous sûr de vouloir régénérer cette clé ?", "alert-unknown-error": "Erreur inconnue, voir la console de développement", "all-apps": "Toutes les applications", + "all-operations": "Toutes les opérations", "all-organizations": "Toutes les Organisations", + "all-tables": "Toutes les tables", + "after": "Après", + "audit-log-details": "Détails du journal d'audit", + "audit-logs": "Journaux d'audit", + "audit-logs-description": "Consultez l'historique des modifications apportées à votre organisation, y compris les modifications des canaux, des bundles et des membres de l'équipe.", + "before": "Avant", + "changed-fields": "Champs modifiés", + "changes": "Modifications", + "close": "Fermer", + "deleted-record": "Enregistrement supprimé", + "error-fetching-audit-logs": "Erreur lors de la récupération des journaux d'audit", + "new-record": "Nouvel enregistrement", + "no-organization-selected": "Aucune organisation sélectionnée", + "search-by-record-id": "Rechercher par ID d'enregistrement", + "view": "Voir", "allow-dev-build": "Permettre la construction de développement", "allow-develoment-bui": "Autoriser les appareils de développement", "allow-device-to-self": "Permettre aux appareils de s'auto-dissocier / s'associer", diff --git a/messages/hi.json b/messages/hi.json index 616a842815..b6b9c9e634 100644 --- a/messages/hi.json +++ b/messages/hi.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "क्या आप वाकई इस कुंजी को पुनर्जनित करना चाहते हैं?", "alert-unknown-error": "अज्ञात त्रुटि, डेव कंसोल देखें", "all-apps": "सभी ऐप्स", + "all-operations": "सभी ऑपरेशन", "all-organizations": "सभी संगठन", + "all-tables": "सभी टेबल", + "after": "बाद में", + "audit-log-details": "ऑडिट लॉग विवरण", + "audit-logs": "ऑडिट लॉग", + "audit-logs-description": "अपने संगठन में किए गए परिवर्तनों का इतिहास देखें, जिसमें चैनलों, बंडलों और टीम सदस्यों में संशोधन शामिल हैं।", + "before": "पहले", + "changed-fields": "बदले गए फ़ील्ड", + "changes": "परिवर्तन", + "close": "बंद करें", + "deleted-record": "हटाया गया रिकॉर्ड", + "error-fetching-audit-logs": "ऑडिट लॉग प्राप्त करने में त्रुटि", + "new-record": "नया रिकॉर्ड", + "no-organization-selected": "कोई संगठन चयनित नहीं", + "search-by-record-id": "रिकॉर्ड आईडी द्वारा खोजें", + "view": "देखें", "allow-dev-build": "विकास बिल्ड की अनुमति दें", "allow-develoment-bui": "विकास उपकरणों की अनुमति दें", "allow-device-to-self": "उपकरणों को स्वत: अलग/सहयुक्त होने की अनुमति दें", diff --git a/messages/id.json b/messages/id.json index d1c936728a..b6abb4c757 100644 --- a/messages/id.json +++ b/messages/id.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Apakah Anda yakin ingin menghasilkan kunci ini lagi?", "alert-unknown-error": "Kesalahan tidak diketahui, lihat konsol pengembang", "all-apps": "Semua Aplikasi", + "all-operations": "Semua Operasi", "all-organizations": "Semua Organisasi", + "all-tables": "Semua Tabel", + "after": "Setelah", + "audit-log-details": "Detail Log Audit", + "audit-logs": "Log Audit", + "audit-logs-description": "Lihat riwayat perubahan yang dilakukan pada organisasi Anda, termasuk modifikasi pada channel, bundle, dan anggota tim.", + "before": "Sebelum", + "changed-fields": "Field yang Diubah", + "changes": "Perubahan", + "close": "Tutup", + "deleted-record": "Rekaman Dihapus", + "error-fetching-audit-logs": "Kesalahan saat mengambil log audit", + "new-record": "Rekaman Baru", + "no-organization-selected": "Tidak ada organisasi yang dipilih", + "search-by-record-id": "Cari berdasarkan ID rekaman", + "view": "Lihat", "allow-dev-build": "Izinkan pembangunan versi pengembangan", "allow-develoment-bui": "Izinkan pembangunan versi pengembangan", "allow-device-to-self": "Izinkan perangkat untuk memisahkan/mengasosiasikan diri sendiri", diff --git a/messages/it.json b/messages/it.json index c6402557aa..7658cc8eec 100644 --- a/messages/it.json +++ b/messages/it.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Sei sicuro di voler rigenerare questa chiave?", "alert-unknown-error": "Errore sconosciuto, vedi console dev", "all-apps": "Tutte le App", + "all-operations": "Tutte le operazioni", "all-organizations": "Tutte le Organizzazioni", + "all-tables": "Tutte le tabelle", + "after": "Dopo", + "audit-log-details": "Dettagli del registro di audit", + "audit-logs": "Registri di audit", + "audit-logs-description": "Visualizza una cronologia delle modifiche apportate alla tua organizzazione, incluse le modifiche a canali, bundle e membri del team.", + "before": "Prima", + "changed-fields": "Campi modificati", + "changes": "Modifiche", + "close": "Chiudi", + "deleted-record": "Record eliminato", + "error-fetching-audit-logs": "Errore nel recupero dei registri di audit", + "new-record": "Nuovo record", + "no-organization-selected": "Nessuna organizzazione selezionata", + "search-by-record-id": "Cerca per ID record", + "view": "Visualizza", "allow-dev-build": "Consenti la costruzione dello sviluppo", "allow-develoment-bui": "Consenti dispositivi di sviluppo", "allow-device-to-self": "Consenti ai dispositivi di dissociarsi/associarsi autonomamente", diff --git a/messages/ja.json b/messages/ja.json index 40a69c79bd..30353d1bc3 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "このキーを再生成してもよろしいですか?", "alert-unknown-error": "不明なエラー、開発者コンソールを参照してください", "all-apps": "すべてのアプリ", + "all-operations": "すべての操作", "all-organizations": "すべての組电", + "all-tables": "すべてのテーブル", + "after": "変更後", + "audit-log-details": "監査ログの詳細", + "audit-logs": "監査ログ", + "audit-logs-description": "チャンネル、バンドル、チームメンバーへの変更を含む、組織に加えられた変更の履歴を表示します。", + "before": "変更前", + "changed-fields": "変更されたフィールド", + "changes": "変更内容", + "close": "閉じる", + "deleted-record": "削除されたレコード", + "error-fetching-audit-logs": "監査ログの取得エラー", + "new-record": "新規レコード", + "no-organization-selected": "組織が選択されていません", + "search-by-record-id": "レコードIDで検索", + "view": "表示", "allow-dev-build": "開発ビルドを許可する", "allow-develoment-bui": "開発ビルドを許可する", "allow-device-to-self": "デバイスに自己分離/関連付けを許可する", diff --git a/messages/ko.json b/messages/ko.json index ec12e72188..4e6daf2208 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "이 키를 재생성하길 원하는 것이 확실한가요?", "alert-unknown-error": "알 수 없는 오류, 개발자 콘솔을 확인하세요", "all-apps": "모든 앱들", + "all-operations": "모든 작업", "all-organizations": "모든 조직", + "all-tables": "모든 테이블", + "after": "변경 후", + "audit-log-details": "감사 로그 상세", + "audit-logs": "감사 로그", + "audit-logs-description": "채널, 번들 및 팀 구성원에 대한 수정 사항을 포함하여 조직에 대한 변경 기록을 확인하세요.", + "before": "변경 전", + "changed-fields": "변경된 필드", + "changes": "변경 사항", + "close": "닫기", + "deleted-record": "삭제된 레코드", + "error-fetching-audit-logs": "감사 로그를 가져오는 중 오류 발생", + "new-record": "새 레코드", + "no-organization-selected": "조직이 선택되지 않았습니다", + "search-by-record-id": "레코드 ID로 검색", + "view": "보기", "allow-dev-build": "개발 빌드 허용", "allow-develoment-bui": "개발 장치 허용", "allow-device-to-self": "장치가 자체적으로 분리/연결을 허용하십시오.", diff --git a/messages/pl.json b/messages/pl.json index 825d46fd59..e34b848116 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Czy na pewno chcesz wygenerować ten klucz ponownie?", "alert-unknown-error": "Nieznany błąd, zobacz konsolę deweloperską", "all-apps": "Wszystkie aplikacje", + "all-operations": "Wszystkie operacje", "all-organizations": "Wszystkie Organizacje", + "all-tables": "Wszystkie tabele", + "after": "Po", + "audit-log-details": "Szczegóły dziennika audytu", + "audit-logs": "Dzienniki audytu", + "audit-logs-description": "Zobacz historię zmian wprowadzonych w Twojej organizacji, w tym modyfikacje kanałów, pakietów i członków zespołu.", + "before": "Przed", + "changed-fields": "Zmienione pola", + "changes": "Zmiany", + "close": "Zamknij", + "deleted-record": "Usunięty rekord", + "error-fetching-audit-logs": "Błąd podczas pobierania dzienników audytu", + "new-record": "Nowy rekord", + "no-organization-selected": "Nie wybrano organizacji", + "search-by-record-id": "Szukaj według ID rekordu", + "view": "Zobacz", "allow-dev-build": "Zezwól na budowę wersji rozwojowej", "allow-develoment-bui": "Zezwól na urządzenia deweloperskie", "allow-device-to-self": "Pozwól urządzeniom na samodzielne rozłączanie/łączenie się", diff --git a/messages/pt-br.json b/messages/pt-br.json index 9bbdbf4f2a..f982388ba5 100644 --- a/messages/pt-br.json +++ b/messages/pt-br.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Você tem certeza de que deseja regenerar essa chave?", "alert-unknown-error": "Erro desconhecido, veja o console do desenvolvedor", "all-apps": "Todos os Aplicativos", + "all-operations": "Todas as operações", "all-organizations": "Todas as Organizações", + "all-tables": "Todas as tabelas", + "after": "Depois", + "audit-log-details": "Detalhes do log de auditoria", + "audit-logs": "Logs de auditoria", + "audit-logs-description": "Veja um histórico de alterações feitas na sua organização, incluindo modificações em canais, bundles e membros da equipe.", + "before": "Antes", + "changed-fields": "Campos alterados", + "changes": "Alterações", + "close": "Fechar", + "deleted-record": "Registro excluído", + "error-fetching-audit-logs": "Erro ao buscar logs de auditoria", + "new-record": "Novo registro", + "no-organization-selected": "Nenhuma organização selecionada", + "search-by-record-id": "Pesquisar por ID do registro", + "view": "Visualizar", "allow-dev-build": "Permitir construção de desenvolvimento", "allow-develoment-bui": "Permitir dispositivos de desenvolvimento", "allow-device-to-self": "Permita que os dispositivos se dissociem/associem automaticamente", diff --git a/messages/ru.json b/messages/ru.json index 0082a5f8f0..b3533b99d4 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Вы уверены, что хотите восстановить этот ключ?", "alert-unknown-error": "Неизвестная ошибка, смотрите консоль разработчика", "all-apps": "Все приложения", + "all-operations": "Все операции", "all-organizations": "Все Организации", + "all-tables": "Все таблицы", + "after": "После", + "audit-log-details": "Детали журнала аудита", + "audit-logs": "Журналы аудита", + "audit-logs-description": "Просмотрите историю изменений, внесенных в вашу организацию, включая изменения каналов, бандлов и членов команды.", + "before": "До", + "changed-fields": "Измененные поля", + "changes": "Изменения", + "close": "Закрыть", + "deleted-record": "Удаленная запись", + "error-fetching-audit-logs": "Ошибка при получении журналов аудита", + "new-record": "Новая запись", + "no-organization-selected": "Организация не выбрана", + "search-by-record-id": "Поиск по ID записи", + "view": "Просмотр", "allow-dev-build": "Разрешить сборку для разработки", "allow-develoment-bui": "Разрешить сборку для разработки", "allow-device-to-self": "Разрешить устройствам самостоятельно отсоединяться/подключаться", diff --git a/messages/tr.json b/messages/tr.json index 44cc3ee92b..676e36b68a 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Bu anahtarı yeniden oluşturmak istediğinizden emin misiniz?", "alert-unknown-error": "Bilinmeyen hata, dev konsoluna bakın", "all-apps": "Tüm Uygulamalar", + "all-operations": "Tüm İşlemler", "all-organizations": "Tüm Organizasyonlar", + "all-tables": "Tüm Tablolar", + "after": "Sonra", + "audit-log-details": "Denetim Günlüğü Detayları", + "audit-logs": "Denetim Günlükleri", + "audit-logs-description": "Kanallar, paketler ve ekip üyelerindeki değişiklikler dahil olmak üzere kuruluşunuzda yapılan değişikliklerin geçmişini görüntüleyin.", + "before": "Önce", + "changed-fields": "Değiştirilen Alanlar", + "changes": "Değişiklikler", + "close": "Kapat", + "deleted-record": "Silinen Kayıt", + "error-fetching-audit-logs": "Denetim günlükleri alınırken hata oluştu", + "new-record": "Yeni Kayıt", + "no-organization-selected": "Organizasyon seçilmedi", + "search-by-record-id": "Kayıt kimliğine göre ara", + "view": "Görüntüle", "allow-dev-build": "Geliştirme yapısına izin ver", "allow-develoment-bui": "Geliştirme cihazlarına izin ver", "allow-device-to-self": "Cihazların kendiliğinden ayrılmasına/bağlanmasına izin verin", diff --git a/messages/vi.json b/messages/vi.json index 3e760ba828..977a397ade 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "Bạn có chắc bạn muốn tạo lại khóa này không?", "alert-unknown-error": "Lỗi không xác định, xem bảng điều khiển phát triển", "all-apps": "Tất cả ứng dụng", + "all-operations": "Tất cả các thao tác", "all-organizations": "Tất cả các Tổ chức", + "all-tables": "Tất cả các bảng", + "after": "Sau", + "audit-log-details": "Chi tiết nhật ký kiểm toán", + "audit-logs": "Nhật ký kiểm toán", + "audit-logs-description": "Xem lịch sử các thay đổi đã thực hiện trong tổ chức của bạn, bao gồm các sửa đổi đối với kênh, gói và thành viên nhóm.", + "before": "Trước", + "changed-fields": "Các trường đã thay đổi", + "changes": "Thay đổi", + "close": "Đóng", + "deleted-record": "Bản ghi đã xóa", + "error-fetching-audit-logs": "Lỗi khi tải nhật ký kiểm toán", + "new-record": "Bản ghi mới", + "no-organization-selected": "Chưa chọn tổ chức", + "search-by-record-id": "Tìm theo ID bản ghi", + "view": "Xem", "allow-dev-build": "Cho phép xây dựng phát triển", "allow-develoment-bui": "Cho phép xây dựng phát triển", "allow-device-to-self": "Cho phép thiết bị tự ngắt kết nối/kết nối", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index d8bd95de54..0a6e414fc5 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -145,7 +145,23 @@ "alert-regenerate-key": "您确定要重新生成此密钥吗", "alert-unknown-error": "未知错误,请参阅开发控制台", "all-apps": "所有应用程序", + "all-operations": "所有操作", "all-organizations": "所有组织", + "all-tables": "所有表格", + "after": "之后", + "audit-log-details": "审计日志详情", + "audit-logs": "审计日志", + "audit-logs-description": "查看对您组织所做更改的历史记录,包括对频道、包和团队成员的修改。", + "before": "之前", + "changed-fields": "已更改的字段", + "changes": "更改内容", + "close": "关闭", + "deleted-record": "已删除的记录", + "error-fetching-audit-logs": "获取审计日志时出错", + "new-record": "新记录", + "no-organization-selected": "未选择组织", + "search-by-record-id": "按记录ID搜索", + "view": "查看", "allow-dev-build": "允许开发构建", "allow-develoment-bui": "允许开发构建", "allow-device-to-self": "允许设备自我解除关联/关联", diff --git a/src/components/tables/AuditLogTable.vue b/src/components/tables/AuditLogTable.vue new file mode 100644 index 0000000000..495eed0efd --- /dev/null +++ b/src/components/tables/AuditLogTable.vue @@ -0,0 +1,386 @@ + + + diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts index e6888911ea..ad261f5d33 100644 --- a/src/constants/organizationTabs.ts +++ b/src/constants/organizationTabs.ts @@ -1,5 +1,6 @@ import type { Tab } from '~/components/comp_def' import IconChart from '~icons/heroicons/chart-bar' +import IconAudit from '~icons/heroicons/clipboard-document-list' import IconPlan from '~icons/heroicons/credit-card' import IconCredits from '~icons/heroicons/currency-dollar' import IconInfo from '~icons/heroicons/information-circle' @@ -8,6 +9,7 @@ import IconUsers from '~icons/heroicons/users' export const organizationTabs: Tab[] = [ { label: 'general', key: '/settings/organization', icon: IconInfo }, { label: 'members', key: '/settings/organization/members', icon: IconUsers }, + { label: 'audit-logs', key: '/settings/organization/audit-logs', icon: IconAudit }, { label: 'plans', key: '/settings/organization/plans', icon: IconPlan }, { label: 'usage', key: '/settings/organization/usage', icon: IconChart }, { label: 'credits', key: '/settings/organization/credits', icon: IconCredits }, diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue index d936976af2..e80a3baa35 100644 --- a/src/layouts/settings.vue +++ b/src/layouts/settings.vue @@ -55,6 +55,17 @@ watchEffect(() => { if (!needsPlans && hasPlans) organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/plans') + // Audit logs - visible only to super_admins + const needsAuditLogs = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) + const hasAuditLogs = organizationTabs.value.find(tab => tab.key === '/settings/organization/audit-logs') + if (needsAuditLogs && !hasAuditLogs) { + const base = baseOrgTabs.find(t => t.key === '/settings/organization/audit-logs') + if (base) + organizationTabs.value.push({ ...base }) + } + if (!needsAuditLogs && hasAuditLogs) + organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/audit-logs') + if (!Capacitor.isNativePlatform() && organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) && !organizationTabs.value.find(tab => tab.key === '/billing')) { diff --git a/src/pages/settings/organization/AuditLogs.vue b/src/pages/settings/organization/AuditLogs.vue new file mode 100644 index 0000000000..4f429667b9 --- /dev/null +++ b/src/pages/settings/organization/AuditLogs.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 04eaf6f562..354d7c9ca6 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -238,6 +238,60 @@ export type Database = { }, ] } + audit_logs: { + Row: { + id: number + created_at: string + table_name: string + record_id: string + operation: string + user_id: string | null + org_id: string + old_record: Json | null + new_record: Json | null + changed_fields: string[] | null + } + Insert: { + id?: number + created_at?: string + table_name: string + record_id: string + operation: string + user_id?: string | null + org_id: string + old_record?: Json | null + new_record?: Json | null + changed_fields?: string[] | null + } + Update: { + id?: number + created_at?: string + table_name?: string + record_id?: string + operation?: string + user_id?: string | null + org_id?: string + old_record?: Json | null + new_record?: Json | null + changed_fields?: 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"] + }, + ] + } apps: { Row: { app_id: string @@ -444,6 +498,33 @@ export type Database = { }, ] } + cache_entry: { + Row: { + cached_at: string | null + end_date: string | null + id: number | null + org_id: string | null + response: Json | null + start_date: string | null + } + Insert: { + cached_at?: string | null + end_date?: string | null + id?: number | null + org_id?: string | null + response?: Json | null + start_date?: string | null + } + Update: { + cached_at?: string | null + end_date?: string | null + id?: number | null + org_id?: string | null + response?: Json | null + start_date?: string | null + } + Relationships: [] + } capgo_credits_steps: { Row: { created_at: string diff --git a/supabase/functions/_backend/public/organization/audit.ts b/supabase/functions/_backend/public/organization/audit.ts new file mode 100644 index 0000000000..b6beae6347 --- /dev/null +++ b/supabase/functions/_backend/public/organization/audit.ts @@ -0,0 +1,81 @@ +import type { Context } from 'hono' +import type { Database } from '../../utils/supabase.types.ts' +import { z } from 'zod/mini' +import { simpleError } from '../../utils/hono.ts' +import { apikeyHasOrgRight, hasOrgRightApikey, supabaseApikey } from '../../utils/supabase.ts' + +const bodySchema = z.object({ + orgId: z.string(), + tableName: z.optional(z.string()), + operation: z.optional(z.string()), + page: z.optional(z.coerce.number()), + limit: z.optional(z.coerce.number()), +}) + +const auditLogSchema = z.object({ + id: z.number(), + created_at: z.string(), + table_name: z.string(), + record_id: z.string(), + operation: z.string(), + user_id: z.nullable(z.string()), + org_id: z.string(), + old_record: z.unknown(), + new_record: z.unknown(), + changed_fields: z.nullable(z.array(z.string())), +}) + +export async function getAuditLogs(c: Context, bodyRaw: any, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { + const bodyParsed = bodySchema.safeParse(bodyRaw) + if (!bodyParsed.success) { + throw simpleError('invalid_body', 'Invalid body', { error: bodyParsed.error }) + } + const body = bodyParsed.data + + // Validate org access + if (!(await hasOrgRightApikey(c, body.orgId, apikey.user_id, 'read', c.get('capgkey') as string))) { + throw simpleError('invalid_org_id', 'You can\'t access this organization', { org_id: body.orgId }) + } + + if (!apikeyHasOrgRight(apikey, body.orgId)) { + throw simpleError('invalid_org_id', 'You can\'t access this organization', { org_id: body.orgId }) + } + + const limit = Math.min(body.limit ?? 50, 100) + const page = body.page ?? 0 + const from = page * limit + const to = (page + 1) * limit - 1 + + let query = supabaseApikey(c, c.get('capgkey') as string) + .from('audit_logs') + .select('*', { count: 'exact' }) + .eq('org_id', body.orgId) + .order('created_at', { ascending: false }) + .range(from, to) + + // Apply optional filters + if (body.tableName) { + query = query.eq('table_name', body.tableName) + } + if (body.operation) { + query = query.eq('operation', body.operation) + } + + const { data, error, count } = await query + + if (error) { + throw simpleError('cannot_get_audit_logs', 'Cannot get audit logs', { error }) + } + + const dataParsed = z.array(auditLogSchema).safeParse(data) + if (!dataParsed.success) { + throw simpleError('cannot_parse_audit_logs', 'Cannot parse audit logs', { error: dataParsed.error }) + } + + return c.json({ + data: dataParsed.data, + total: count ?? 0, + page, + limit, + }) +} diff --git a/supabase/functions/_backend/public/organization/index.ts b/supabase/functions/_backend/public/organization/index.ts index 0fe4e9f55f..6cf3a75c28 100644 --- a/supabase/functions/_backend/public/organization/index.ts +++ b/supabase/functions/_backend/public/organization/index.ts @@ -1,6 +1,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { getBodyOrQuery, honoFactory } from '../../utils/hono.ts' import { middlewareKey } from '../../utils/hono_middleware.ts' +import { getAuditLogs } from './audit.ts' import { deleteOrg } from './delete.ts' import { get } from './get.ts' import { deleteMember } from './members/delete.ts' @@ -52,3 +53,9 @@ app.delete('/members', middlewareKey(['all', 'write', 'read', 'upload']), async const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] return deleteMember(c, body, apikey) }) + +app.get('/audit', middlewareKey(['all', 'write', 'read', 'upload']), async (c) => { + const body = await getBodyOrQuery(c) + const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] + return getAuditLogs(c, body, apikey) +}) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 04eaf6f562..354d7c9ca6 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -238,6 +238,60 @@ export type Database = { }, ] } + audit_logs: { + Row: { + id: number + created_at: string + table_name: string + record_id: string + operation: string + user_id: string | null + org_id: string + old_record: Json | null + new_record: Json | null + changed_fields: string[] | null + } + Insert: { + id?: number + created_at?: string + table_name: string + record_id: string + operation: string + user_id?: string | null + org_id: string + old_record?: Json | null + new_record?: Json | null + changed_fields?: string[] | null + } + Update: { + id?: number + created_at?: string + table_name?: string + record_id?: string + operation?: string + user_id?: string | null + org_id?: string + old_record?: Json | null + new_record?: Json | null + changed_fields?: 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"] + }, + ] + } apps: { Row: { app_id: string @@ -444,6 +498,33 @@ export type Database = { }, ] } + cache_entry: { + Row: { + cached_at: string | null + end_date: string | null + id: number | null + org_id: string | null + response: Json | null + start_date: string | null + } + Insert: { + cached_at?: string | null + end_date?: string | null + id?: number | null + org_id?: string | null + response?: Json | null + start_date?: string | null + } + Update: { + cached_at?: string | null + end_date?: string | null + id?: number | null + org_id?: string | null + response?: Json | null + start_date?: string | null + } + Relationships: [] + } capgo_credits_steps: { Row: { created_at: string diff --git a/supabase/migrations/20251226125240_audit_log.sql b/supabase/migrations/20251226125240_audit_log.sql new file mode 100644 index 0000000000..92b9f0976f --- /dev/null +++ b/supabase/migrations/20251226125240_audit_log.sql @@ -0,0 +1,161 @@ +-- Audit Log Table for tracking CRUD operations +-- Tables tracked: orgs, channels, app_versions, org_users + +-- Create the audit_logs table +CREATE TABLE IF NOT EXISTS "public"."audit_logs" ( + "id" BIGSERIAL PRIMARY KEY, + "created_at" TIMESTAMPTZ DEFAULT now() NOT NULL, + "table_name" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "user_id" UUID, + "org_id" UUID NOT NULL, + "old_record" JSONB, + "new_record" JSONB, + "changed_fields" TEXT[] +); + +-- Add comments +COMMENT ON TABLE "public"."audit_logs" IS 'Audit log for tracking changes to orgs, channels, app_versions, and org_users tables'; +COMMENT ON COLUMN "public"."audit_logs"."table_name" IS 'Name of the table that was modified (orgs, channels, app_versions, org_users)'; +COMMENT ON COLUMN "public"."audit_logs"."record_id" IS 'Primary key of the affected record'; +COMMENT ON COLUMN "public"."audit_logs"."operation" IS 'Type of operation: INSERT, UPDATE, or DELETE'; +COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'User who made the change (from auth.uid())'; +COMMENT ON COLUMN "public"."audit_logs"."org_id" IS 'Organization context for filtering'; +COMMENT ON COLUMN "public"."audit_logs"."old_record" IS 'Previous state of the record (null for INSERT)'; +COMMENT ON COLUMN "public"."audit_logs"."new_record" IS 'New state of the record (null for DELETE)'; +COMMENT ON COLUMN "public"."audit_logs"."changed_fields" IS 'Array of field names that changed (for UPDATE operations)'; + +-- Create indexes for efficient querying +CREATE INDEX idx_audit_logs_org_id ON "public"."audit_logs"("org_id"); +CREATE INDEX idx_audit_logs_table_name ON "public"."audit_logs"("table_name"); +CREATE INDEX idx_audit_logs_user_id ON "public"."audit_logs"("user_id"); +CREATE INDEX idx_audit_logs_created_at ON "public"."audit_logs"("created_at" DESC); +CREATE INDEX idx_audit_logs_org_created ON "public"."audit_logs"("org_id", "created_at" DESC); +CREATE INDEX idx_audit_logs_operation ON "public"."audit_logs"("operation"); + +-- Create the audit trigger function +CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() +RETURNS TRIGGER 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; +BEGIN + -- Get current user from auth context + v_user_id := auth.uid(); + + -- Convert records to JSONB based on operation type + IF TG_OP = 'DELETE' THEN + v_old_record := to_jsonb(OLD); + v_new_record := NULL; + ELSIF TG_OP = 'INSERT' THEN + v_old_record := NULL; + v_new_record := to_jsonb(NEW); + ELSE -- UPDATE + v_old_record := to_jsonb(OLD); + v_new_record := to_jsonb(NEW); + + -- Calculate changed fields by comparing old and new values + FOR v_key IN SELECT 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 := array_append(v_changed_fields, v_key); + END IF; + END LOOP; + 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 '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; + + -- Only insert if we have a valid org_id + 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 + ) VALUES ( + TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, + v_old_record, v_new_record, v_changed_fields + ); + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Attach triggers to tracked tables + +-- Orgs audit trigger +CREATE TRIGGER audit_orgs_trigger + AFTER INSERT OR UPDATE OR DELETE ON "public"."orgs" + FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + +-- Channels audit trigger +CREATE TRIGGER audit_channels_trigger + AFTER INSERT OR UPDATE OR DELETE ON "public"."channels" + FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + +-- App versions audit trigger +CREATE TRIGGER audit_app_versions_trigger + AFTER INSERT OR UPDATE OR DELETE ON "public"."app_versions" + FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + +-- Org users audit trigger +CREATE TRIGGER audit_org_users_trigger + AFTER INSERT OR UPDATE OR DELETE ON "public"."org_users" + FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + +-- Enable Row Level Security +ALTER TABLE "public"."audit_logs" ENABLE ROW LEVEL SECURITY; + +-- RLS Policy: Only super_admins can view audit logs for their organizations +CREATE POLICY "Super admins can view audit logs for their orgs" + ON "public"."audit_logs" + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM "public"."org_users" ou + WHERE ou.org_id = audit_logs.org_id + AND ou.user_id = auth.uid() + AND ou.user_right = 'super_admin' + ) + ); + +-- No INSERT/UPDATE/DELETE policies - only triggers can write to this table + +-- Cleanup function for 90-day retention +CREATE OR REPLACE FUNCTION "public"."cleanup_old_audit_logs"() +RETURNS void AS $$ +BEGIN + DELETE FROM "public"."audit_logs" + WHERE created_at < NOW() - INTERVAL '90 days'; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Schedule daily cleanup at 3 AM UTC +SELECT cron.schedule( + 'cleanup-audit-logs', + '0 3 * * *', + $$SELECT "public"."cleanup_old_audit_logs"()$$ +); diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts new file mode 100644 index 0000000000..d78dfa4b02 --- /dev/null +++ b/tests/audit-logs.test.ts @@ -0,0 +1,336 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { BASE_URL, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts' + +const ORG_ID = randomUUID() +const globalId = randomUUID() +const name = `Test Audit Organization ${globalId}` +const customerId = `cus_audit_${ORG_ID}` + +// Schema for audit log response +const auditLogSchema = z.object({ + id: z.number(), + created_at: z.string(), + table_name: z.string(), + record_id: z.string(), + operation: z.string(), + user_id: z.nullable(z.string()), + org_id: z.string(), + old_record: z.unknown(), + new_record: z.unknown(), + changed_fields: z.nullable(z.array(z.string())), +}) + +const auditLogsResponseSchema = z.object({ + data: z.array(auditLogSchema), + total: z.number(), + page: z.number(), + limit: z.number(), +}) + +beforeAll(async () => { + // Create stripe_info for this test org + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: customerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_${globalId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + // Create test organization (this should trigger an INSERT audit log via the trigger) + const { error } = await getSupabaseClient().from('orgs').insert({ + id: ORG_ID, + name, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: customerId, + }) + if (error) + throw error +}) + +afterAll(async () => { + // Clean up: delete audit logs first (they reference the org) + await getSupabaseClient().from('audit_logs').delete().eq('org_id', ORG_ID) + + // Clean up test organization and stripe_info + await getSupabaseClient().from('orgs').delete().eq('id', ORG_ID) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId) +}) + +describe('[GET] /organization/audit', () => { + it('get audit logs for organization', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + expect(safe.data.page).toBe(0) + expect(safe.data.limit).toBe(50) + expect(Array.isArray(safe.data.data)).toBe(true) + } + }) + + it('get audit logs with pagination', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&page=0&limit=10`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + expect(safe.data.page).toBe(0) + expect(safe.data.limit).toBe(10) + } + }) + + it('get audit logs filtered by table name', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + // All returned logs should be for the 'orgs' table + for (const log of safe.data.data) { + expect(log.table_name).toBe('orgs') + } + } + }) + + it('get audit logs filtered by operation', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&operation=INSERT`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + // All returned logs should be INSERT operations + for (const log of safe.data.data) { + expect(log.operation).toBe('INSERT') + } + } + }) + + it('get audit logs with combined filters', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs&operation=INSERT`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + for (const log of safe.data.data) { + expect(log.table_name).toBe('orgs') + expect(log.operation).toBe('INSERT') + } + } + }) + + it('limit is capped at 100', async () => { + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&limit=200`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + if (safe.success) { + // Server should cap limit at 100 + expect(safe.data.limit).toBe(100) + } + }) + + it('get audit logs with missing orgId returns error', async () => { + const response = await fetch(`${BASE_URL}/organization/audit`, { + headers, + }) + expect(response.status).toBe(400) + const responseData = await response.json() as { error: string } + expect(responseData.error).toBe('invalid_body') + }) + + it('get audit logs with invalid orgId returns error', async () => { + const invalidOrgId = randomUUID() + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${invalidOrgId}`, { + headers, + }) + expect(response.status).toBe(400) + const responseData = await response.json() as { error: string } + expect(responseData.error).toBe('invalid_org_id') + }) +}) + +describe('Audit log triggers', () => { + it('organization UPDATE creates audit log with changed_fields', async () => { + const newName = `Updated Audit Organization ${randomUUID()}` + + // Update the organization + const { error: updateError } = await getSupabaseClient() + .from('orgs') + .update({ name: newName }) + .eq('id', ORG_ID) + expect(updateError).toBeNull() + + // Wait a bit for the trigger to execute + await new Promise(resolve => setTimeout(resolve, 100)) + + // Fetch audit logs for this org + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs&operation=UPDATE`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + + if (safe.success && safe.data.data.length > 0) { + const latestUpdate = safe.data.data[0] + expect(latestUpdate.operation).toBe('UPDATE') + expect(latestUpdate.table_name).toBe('orgs') + expect(latestUpdate.record_id).toBe(ORG_ID) + expect(latestUpdate.org_id).toBe(ORG_ID) + // Changed fields should include 'name' and 'updated_at' + expect(Array.isArray(latestUpdate.changed_fields)).toBe(true) + expect(latestUpdate.changed_fields).toContain('name') + // old_record should have the old name + expect(latestUpdate.old_record).toBeTruthy() + // new_record should have the new name + expect(latestUpdate.new_record).toBeTruthy() + if (latestUpdate.new_record && typeof latestUpdate.new_record === 'object') { + expect((latestUpdate.new_record as Record).name).toBe(newName) + } + } + }) + + it('org_users INSERT creates audit log', async () => { + // Get another user to add to the org + const { data: anotherUser, error: userError } = await getSupabaseClient() + .from('users') + .select('id') + .neq('id', USER_ID) + .limit(1) + .single() + + expect(userError).toBeNull() + expect(anotherUser).toBeTruthy() + + if (!anotherUser) { + console.warn('Skipping test: Could not find another user') + return + } + + // Add user to org + const { error: insertError } = await getSupabaseClient() + .from('org_users') + .insert({ + org_id: ORG_ID, + user_id: anotherUser.id, + user_right: 'read', + }) + expect(insertError).toBeNull() + + // Wait a bit for the trigger to execute + await new Promise(resolve => setTimeout(resolve, 100)) + + // Fetch audit logs + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=org_users&operation=INSERT`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + + if (safe.success && safe.data.data.length > 0) { + const latestInsert = safe.data.data[0] + expect(latestInsert.operation).toBe('INSERT') + expect(latestInsert.table_name).toBe('org_users') + expect(latestInsert.org_id).toBe(ORG_ID) + expect(latestInsert.old_record).toBeNull() // INSERT has no old record + expect(latestInsert.new_record).toBeTruthy() + } + + // Clean up: delete the org_user + await getSupabaseClient() + .from('org_users') + .delete() + .eq('org_id', ORG_ID) + .eq('user_id', anotherUser.id) + }) + + it('org_users DELETE creates audit log', async () => { + // Get another user to add and then remove + const { data: anotherUser, error: userError } = await getSupabaseClient() + .from('users') + .select('id') + .neq('id', USER_ID) + .limit(1) + .single() + + expect(userError).toBeNull() + expect(anotherUser).toBeTruthy() + + if (!anotherUser) { + console.warn('Skipping test: Could not find another user') + return + } + + // Add user to org + const { error: insertError } = await getSupabaseClient() + .from('org_users') + .insert({ + org_id: ORG_ID, + user_id: anotherUser.id, + user_right: 'read', + }) + expect(insertError).toBeNull() + + // Wait for insert trigger + await new Promise(resolve => setTimeout(resolve, 100)) + + // Delete the org_user + const { error: deleteError } = await getSupabaseClient() + .from('org_users') + .delete() + .eq('org_id', ORG_ID) + .eq('user_id', anotherUser.id) + expect(deleteError).toBeNull() + + // Wait for delete trigger + await new Promise(resolve => setTimeout(resolve, 100)) + + // Fetch audit logs for DELETE + const response = await fetch(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=org_users&operation=DELETE`, { + headers, + }) + expect(response.status).toBe(200) + const responseData = await response.json() + const safe = auditLogsResponseSchema.safeParse(responseData) + expect(safe.success).toBe(true) + + if (safe.success && safe.data.data.length > 0) { + const latestDelete = safe.data.data[0] + expect(latestDelete.operation).toBe('DELETE') + expect(latestDelete.table_name).toBe('org_users') + expect(latestDelete.org_id).toBe(ORG_ID) + expect(latestDelete.new_record).toBeNull() // DELETE has no new record + expect(latestDelete.old_record).toBeTruthy() + } + }) +}) From 794d74f11b71e73f5244c20b34bb65be222beb3e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 26 Dec 2025 16:51:44 +0000 Subject: [PATCH 02/18] fix: Address CodeRabbit review feedback for audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use get_identity() instead of auth.uid() in trigger to capture API key users - Update RLS policy to use get_identity() for API key authentication support - Remove direct cron.schedule, wire cleanup into process_all_cron_tasks - Fix Indonesian translation: use "saluran/paket" instead of "channel/bundle" 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 --- messages/id.json | 4 +- .../migrations/20251226125240_audit_log.sql | 221 +++++++++++++++++- 2 files changed, 213 insertions(+), 12 deletions(-) diff --git a/messages/id.json b/messages/id.json index b6abb4c757..2536278a33 100644 --- a/messages/id.json +++ b/messages/id.json @@ -151,9 +151,9 @@ "after": "Setelah", "audit-log-details": "Detail Log Audit", "audit-logs": "Log Audit", - "audit-logs-description": "Lihat riwayat perubahan yang dilakukan pada organisasi Anda, termasuk modifikasi pada channel, bundle, dan anggota tim.", + "audit-logs-description": "Lihat riwayat perubahan yang dilakukan pada organisasi Anda, termasuk modifikasi pada saluran, paket, dan anggota tim.", "before": "Sebelum", - "changed-fields": "Field yang Diubah", + "changed-fields": "Bidang yang Diubah", "changes": "Perubahan", "close": "Tutup", "deleted-record": "Rekaman Dihapus", diff --git a/supabase/migrations/20251226125240_audit_log.sql b/supabase/migrations/20251226125240_audit_log.sql index 92b9f0976f..9791d62e21 100644 --- a/supabase/migrations/20251226125240_audit_log.sql +++ b/supabase/migrations/20251226125240_audit_log.sql @@ -20,7 +20,7 @@ COMMENT ON TABLE "public"."audit_logs" IS 'Audit log for tracking changes to org COMMENT ON COLUMN "public"."audit_logs"."table_name" IS 'Name of the table that was modified (orgs, channels, app_versions, org_users)'; COMMENT ON COLUMN "public"."audit_logs"."record_id" IS 'Primary key of the affected record'; COMMENT ON COLUMN "public"."audit_logs"."operation" IS 'Type of operation: INSERT, UPDATE, or DELETE'; -COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'User who made the change (from auth.uid())'; +COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'User who made the change (from auth.uid() or API key)'; COMMENT ON COLUMN "public"."audit_logs"."org_id" IS 'Organization context for filtering'; COMMENT ON COLUMN "public"."audit_logs"."old_record" IS 'Previous state of the record (null for INSERT)'; COMMENT ON COLUMN "public"."audit_logs"."new_record" IS 'New state of the record (null for DELETE)'; @@ -46,8 +46,9 @@ DECLARE v_user_id UUID; v_key TEXT; BEGIN - -- Get current user from auth context - v_user_id := auth.uid(); + -- Get current user from auth context or API key + -- Uses get_identity() to support both JWT auth and API key authentication + v_user_id := public.get_identity(); -- Convert records to JSONB based on operation type IF TG_OP = 'DELETE' THEN @@ -130,6 +131,7 @@ CREATE TRIGGER audit_org_users_trigger ALTER TABLE "public"."audit_logs" ENABLE ROW LEVEL SECURITY; -- RLS Policy: Only super_admins can view audit logs for their organizations +-- Uses get_identity() to support both JWT auth and API key authentication CREATE POLICY "Super admins can view audit logs for their orgs" ON "public"."audit_logs" FOR SELECT @@ -137,7 +139,7 @@ CREATE POLICY "Super admins can view audit logs for their orgs" EXISTS ( SELECT 1 FROM "public"."org_users" ou WHERE ou.org_id = audit_logs.org_id - AND ou.user_id = auth.uid() + AND ou.user_id = public.get_identity('{all,write,read}'::public.key_mode[]) AND ou.user_right = 'super_admin' ) ); @@ -153,9 +155,208 @@ BEGIN END; $$ LANGUAGE plpgsql SECURITY DEFINER; --- Schedule daily cleanup at 3 AM UTC -SELECT cron.schedule( - 'cleanup-audit-logs', - '0 3 * * *', - $$SELECT "public"."cleanup_old_audit_logs"()$$ -); +-- Update process_all_cron_tasks to include audit log cleanup at 3 AM UTC +-- Per AGENTS.md, we don't create new cron jobs but add to the existing consolidated function +CREATE OR REPLACE FUNCTION public.process_all_cron_tasks () RETURNS void LANGUAGE plpgsql +SET + search_path = '' AS $$ +DECLARE + current_hour int; + current_minute int; + current_second int; +BEGIN + -- Get current time components in UTC + current_hour := EXTRACT(HOUR FROM now()); + current_minute := EXTRACT(MINUTE FROM now()); + current_second := EXTRACT(SECOND FROM now()); + + -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) + IF current_second % 10 = 0 THEN + -- Process high-frequency queues with default batch size (950) + BEGIN + PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create']); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; + END; + + -- Process channel device counts with batch size 1000 + BEGIN + PERFORM public.process_channel_device_counts_queue(1000); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; + END; + + -- Process manifest bundle counts with batch size 1000 + BEGIN + PERFORM public.process_manifest_bundle_counts_queue(1000); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_manifest_bundle_counts_queue failed: %', SQLERRM; + END; + END IF; + + -- Every minute (at :00 seconds): Per-minute tasks + IF current_second = 0 THEN + BEGIN + PERFORM public.delete_accounts_marked_for_deletion(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; + END; + + -- Process with batch size 10 + BEGIN + PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; + END; + + -- on_manifest_create uses default batch size + BEGIN + PERFORM public.process_function_queue(ARRAY['on_manifest_create']); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; + END; + END IF; + + -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 + IF current_minute % 5 = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; + END; + END IF; + + -- Every hour (at :00:00): Hourly cleanup + IF current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.cleanup_frequent_job_details(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; + END; + END IF; + + -- Every 2 hours (at :00:00): Low-frequency queues with default batch size + IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_version_create', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; + END; + END IF; + + -- Every 6 hours (at :00:00): Stats jobs + IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.process_cron_stats_jobs(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; + END; + END IF; + + -- Daily at 00:00:00 - Midnight tasks + IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.cleanup_queue_messages(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; + END; + + BEGIN + PERFORM public.delete_old_deleted_apps(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; + END; + + BEGIN + PERFORM public.remove_old_jobs(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; + END; + END IF; + + -- Daily at 00:40:00 - Old app version retention + IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN + BEGIN + PERFORM public.update_app_versions_retention(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; + END; + END IF; + + -- Daily at 01:01:00 - Admin stats creation + IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN + BEGIN + PERFORM public.process_admin_stats(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_admin_stats failed: %', SQLERRM; + END; + END IF; + + -- Daily at 03:00:00 - Free trial, credits, and audit log cleanup + IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.process_free_trial_expired(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; + END; + + BEGIN + PERFORM public.expire_usage_credits(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; + END; + + -- Cleanup old audit logs (90-day retention) + BEGIN + PERFORM public.cleanup_old_audit_logs(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'cleanup_old_audit_logs failed: %', SQLERRM; + END; + END IF; + + -- Daily at 04:00:00 - Sync sub scheduler + IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + PERFORM public.process_cron_sync_sub_jobs(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; + END; + END IF; + + -- Daily at 12:00:00 - Noon tasks + IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN + BEGIN + DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; + END; + + -- Weekly stats email (every Saturday at noon) + IF EXTRACT(DOW FROM now()) = 6 THEN + BEGIN + PERFORM public.process_stats_email_weekly(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; + END; + END IF; + + -- Monthly stats email (1st of month at noon) + IF EXTRACT(DAY FROM now()) = 1 THEN + BEGIN + PERFORM public.process_stats_email_monthly(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; + END; + END IF; + + -- Production deploy/install stats email (1st of month at noon) + IF EXTRACT(DAY FROM now()) = 1 THEN + BEGIN + PERFORM public.process_production_deploy_install_stats_email(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_production_deploy_install_stats_email failed: %', SQLERRM; + END; + END IF; + END IF; +END; +$$; From 52fdb809937cfb1b5727d53efc4ccd0dc0ba19dd Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 26 Dec 2025 17:00:02 +0000 Subject: [PATCH 03/18] fix: Resolve lint errors in audit log components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused 'watch' import from AuditLogs.vue - Fix static class in dynamic binding in AuditLogTable.vue 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 --- src/components/tables/AuditLogTable.vue | 2 +- src/pages/settings/organization/AuditLogs.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tables/AuditLogTable.vue b/src/components/tables/AuditLogTable.vue index 495eed0efd..cf0f0377e0 100644 --- a/src/components/tables/AuditLogTable.vue +++ b/src/components/tables/AuditLogTable.vue @@ -306,7 +306,7 @@ watch([page, search], () => {
- + {{ getOperationLabel(selectedLog.operation) }} diff --git a/src/pages/settings/organization/AuditLogs.vue b/src/pages/settings/organization/AuditLogs.vue index 4f429667b9..526b281163 100644 --- a/src/pages/settings/organization/AuditLogs.vue +++ b/src/pages/settings/organization/AuditLogs.vue @@ -1,6 +1,6 @@