Skema basis data organisasi dan tim B2B yang tetap mudah dikelola
Skema basis data organisasi dan tim B2B: pola relasional praktis untuk undangan, status keanggotaan, pewarisan peran, dan perubahan yang siap diaudit.

Masalah yang diselesaikan pola skema ini
Sebagian besar aplikasi B2B bukan sekadar aplikasi “akun pengguna”. Mereka adalah workspace bersama di mana orang tergabung ke sebuah organisasi, terbagi ke tim, dan mendapatkan izin berbeda tergantung pekerjaan mereka. Sales, dukungan, keuangan, dan admin butuh akses berbeda, dan akses itu berubah dari waktu ke waktu.
Model yang terlalu sederhana cepat rusak. Jika Anda hanya menyimpan satu tabel users dengan satu kolom role, Anda tidak bisa mengekspresikan “orang yang sama adalah Admin di satu org, tetapi Viewer di org lain.” Anda juga tidak bisa menangani kasus umum seperti kontraktor yang hanya boleh melihat satu tim, atau karyawan yang meninggalkan proyek tapi masih bagian dari perusahaan.
Undangan sering jadi sumber bug lain. Jika sebuah undangan hanya berupa baris email, menjadi tidak jelas apakah orang itu sudah “masuk” ke org, tim mana yang seharusnya mereka gabung, dan apa yang terjadi jika mereka mendaftar dengan email berbeda. Ketidakkonsistenan kecil di sini cenderung berubah menjadi masalah keamanan.
Pola ini menargetkan empat tujuan:
- Keamanan: izin berasal dari keanggotaan eksplisit, bukan asumsi.
- Kejelasan: org, tim, dan peran masing-masing punya sumber kebenaran sendiri.
- Konsistensi: undangan dan keanggotaan mengikuti siklus hidup yang dapat diprediksi.
- Riwayat: Anda bisa menjelaskan siapa yang memberi akses, mengubah peran, atau menghapus seseorang.
Janji pola ini adalah satu model relasional yang tetap mudah dipahami seiring fitur tumbuh: banyak org per pengguna, banyak tim per org, pewarisan peran yang dapat diprediksi, dan perubahan yang ramah audit. Ini struktur yang bisa Anda terapkan hari ini dan kembangkan nanti tanpa menulis ulang semuanya.
Istilah kunci: org, tim, user, dan membership
Jika Anda ingin skema yang masih terbaca setelah enam bulan, mulailah dengan menyepakati beberapa istilah. Sebagian besar kebingungan datang dari mencampur “siapa seseorang” dengan “apa yang bisa mereka lakukan.”
Sebuah Organization (org) adalah batas tenant tingkat atas. Ini mewakili pelanggan atau akun bisnis yang memiliki data. Jika dua pengguna berada di org berbeda, mereka seharusnya tidak melihat data satu sama lain secara default. Aturan itu mencegah banyak akses lintas-tenant yang tidak sengaja.
Sebuah Team adalah kelompok lebih kecil di dalam sebuah org. Tim memodelkan unit kerja nyata: Sales, Support, Finance, atau “Proyek A.” Tim tidak menggantikan batas org; mereka berada di bawahnya.
Seorang User adalah identitas. Ini adalah login dan profil orang: email, nama, password atau SSO ID, dan mungkin pengaturan MFA. Seorang user bisa ada tanpa akses ke apa pun.
Sebuah Membership adalah catatan akses. Ia menjawab: “Pengguna ini tergabung ke org ini (dan opsional ke tim ini) dengan status dan peran ini.” Memisahkan identitas (User) dari akses (Membership) membuat model untuk kontraktor, offboarding, dan akses multi-org jauh lebih mudah.
Makna sederhana yang bisa dipakai di kode dan UI:
- Member: pengguna dengan membership aktif di org atau tim.
- Role: sekumpulan izin bernama (misalnya Org Admin, Team Manager).
- Permission: aksi tunggal yang diizinkan (misalnya “lihat invoice”).
- Tenant boundary: aturan bahwa data berskala ke org.
Perlakukan membership sebagai mesin status kecil, bukan boolean. State tipikal adalah invited, active, suspended, dan removed. Ini menjaga undangan, persetujuan, dan offboarding konsisten dan dapat diaudit.
Model relasional tunggal: tabel inti dan relasinya
Skema multi-tenant yang baik dimulai dari satu ide: simpan “siapa tergabung di mana” di satu tempat, dan jadikan semuanya tabel pendukung. Dengan begitu Anda bisa menjawab pertanyaan dasar (siapa di org, siapa di tim, apa yang bisa mereka lakukan) tanpa melompat ke model yang tidak terkait.
Tabel inti yang biasanya diperlukan:
- organizations: satu baris per akun pelanggan (tenant). Menyimpan nama, status, bidang billing, dan id yang tidak berubah.
- teams: grup di dalam organisasi (Support, Sales, Admin). Selalu milik satu organization.
- users: satu baris per orang. Ini bersifat global, bukan per organisasi.
- memberships: jembatan yang mengatakan “user ini tergabung di organization ini” dan opsional “juga di tim ini.”
- role_grants (atau role_assignments): peran apa yang dimiliki sebuah membership, di level org, level tim, atau keduanya.
Jaga kunci dan constraint ketat. Gunakan primary key surrogate (UUID atau bigint) untuk tiap tabel. Tambahkan foreign key seperti teams.organization_id -> organizations.id dan memberships.user_id -> users.id. Lalu tambahkan beberapa unique constraint untuk menghentikan duplikasi sebelum muncul di produksi.
Aturan yang menangkap sebagian besar data buruk lebih awal:
- Satu slug org atau key eksternal:
unique(organizations.slug) - Nama tim per org:
unique(teams.organization_id, teams.name) - Tidak ada duplikat membership org:
unique(memberships.organization_id, memberships.user_id) - Tidak ada duplikat membership tim (jika Anda memodelkan membership tim secara terpisah):
unique(team_memberships.team_id, team_memberships.user_id)
Tentukan apa yang bersifat append-only versus yang bisa diperbarui. Organizations, teams, dan users bisa diperbarui. Memberships sering diperbarui untuk status saat ini (active, suspended), tetapi perubahan juga sebaiknya menulis ke log akses append-only sehingga audit mudah nanti.
Undangan dan status keanggotaan yang konsisten
Cara termudah menjaga akses tetap bersih adalah memperlakukan undangan sebagai catatan sendiri, bukan membership yang setengah jadi. Membership berarti “pengguna ini benar-benar tergabung.” Undangan berarti “kami menawarkan akses, tapi belum nyata.” Memisahkan keduanya menghindari anggota hantu, izin yang setengah dibuat, dan misteri “siapa yang mengundang orang ini?”
Model status sederhana dan dapat diandalkan
Untuk memberships, gunakan set status kecil yang bisa dijelaskan kepada siapa saja:
- active: pengguna bisa mengakses org (dan tim yang mereka ikuti)
- suspended: diblokir sementara, tapi riwayat tetap utuh
- removed: tidak lagi anggota, disimpan untuk audit dan pelaporan
Banyak tim menghindari status membership “invited” dan malah menyimpan “invited” secara ketat di tabel invitations. Itu cenderung lebih bersih: baris membership hanya ada untuk pengguna yang benar-benar punya akses (active), atau yang pernah punya (suspended/removed).
Undangan email sebelum akun ada
Aplikasi B2B sering mengundang lewat email saat belum ada akun pengguna. Simpan email di catatan undangan, bersama dengan tempat undangan berlaku (org atau tim), peran yang dimaksud, dan siapa yang mengirimnya. Jika orang itu kemudian mendaftar dengan email tersebut, Anda bisa mencocokkan undangan tertunda dan membiarkan mereka menerima.
Saat invite diterima, tangani dalam satu transaksi: tandai undangan sebagai accepted, buat membership, dan tulis entri audit (siapa yang menerima, kapan, dan email mana yang dipakai).
Tentukan state akhir undangan dengan jelas:
- expired: melewati batas waktu dan tidak bisa diterima
- revoked: dibatalkan oleh admin dan tidak bisa diterima
- accepted: dikonversi menjadi membership
Cegah duplikasi invite dengan menegakkan “hanya satu invite pending per org atau tim per email.” Jika Anda mendukung re-invite, perpanjang masa berlaku pada invite pending yang ada atau batalkan yang lama dan terbitkan token baru.
Peran dan pewarisan tanpa membuat akses bingung
Sebagian besar aplikasi B2B memerlukan dua level akses: apa yang bisa seseorang lakukan di organisasi secara keseluruhan, dan apa yang bisa mereka lakukan di dalam tim tertentu. Mencampur ini ke satu kolom role adalah titik ketika aplikasi mulai terasa tidak konsisten.
Peran level org menjawab pertanyaan seperti: bolehkah orang ini mengelola billing, mengundang orang, atau melihat semua tim? Peran level tim menjawab: bolehkah mereka mengedit item di Tim A, menyetujui permintaan di Tim B, atau hanya melihat?
Pewarisan peran paling mudah ditangani bila mengikuti satu aturan: peran org berlaku di mana-mana kecuali sebuah tim secara eksplisit mengatakan sebaliknya. Itu menjaga perilaku dapat diprediksi dan mengurangi data duplikat.
Cara bersih memodelkan ini adalah menyimpan penerapan peran dengan cakupan:
role_assignments:user_id,org_id, optionalteam_id(NULL berarti org-wide),role_id,created_at,created_by
Jika Anda ingin “satu peran per cakupan,” tambahkan constraint unik pada (user_id, org_id, team_id).
Maka akses efektif untuk sebuah tim menjadi:
-
Cari assignment khusus tim (
team_id = X). Jika ada, gunakan itu. -
Jika tidak ada, fallback ke assignment org-wide (
team_id IS NULL).
Untuk default prinsip least-privilege, pilih peran org minimal (sering “Member”) dan jangan berikan kekuatan admin tersembunyi. Pengguna baru tidak seharusnya mendapat akses tim implisit kecuali produk Anda benar-benar memerlukannya. Jika Anda memang auto-grant, lakukan dengan membuat membership tim eksplisit, bukan memperlebar peran org secara diam-diam.
Override harus jarang dan jelas. Contoh: Maria adalah “Manager” org (bisa mengundang, melihat laporan), tetapi di tim Finance dia harus menjadi “Viewer.” Anda menyimpan satu assignment org-wide untuk Maria, plus satu override scoped tim untuk Finance. Tidak ada penyalinan izin, dan pengecualian terlihat.
Nama peran bekerja baik untuk pola umum. Gunakan permission eksplisit hanya saat Anda punya kasus satu-kali yang nyata (misalnya “boleh mengekspor tapi tidak bisa mengedit”), atau saat kepatuhan memerlukan daftar aksi yang jelas. Bahkan saat itu, pertahankan ide cakupan yang sama agar model mental tetap konsisten.
Perubahan ramah-audit: melacak siapa yang mengubah akses
Jika aplikasi Anda hanya menyimpan peran saat ini di baris membership, Anda kehilangan cerita. Ketika seseorang bertanya, “Siapa yang memberi Alex akses admin Selasa lalu?” Anda tidak punya jawaban yang dapat diandalkan. Anda butuh riwayat perubahan, bukan hanya state saat ini.
Pendekatan paling sederhana adalah tabel audit khusus yang merekam event akses. Perlakukan itu sebagai jurnal append-only: Anda tidak pernah mengedit baris audit lama; Anda hanya menambah baris baru.
Tabel audit praktis biasanya berisi:
actor_user_id(siapa yang membuat perubahan)subject_typedansubject_id(membership, team, org)action(invite_sent, role_changed, membership_suspended, team_deleted)occurred_at(kapan terjadi)reason(opsional teks bebas seperti “offboarding kontraktor”)
Untuk menangkap “sebelum” dan “sesudah,” simpan snapshot kecil dari field yang Anda pedulikan. Batasi hanya ke data kontrol akses, bukan profil pengguna penuh. Misalnya: before_role, after_role, before_state, after_state, before_team_id, after_team_id. Jika Anda lebih suka fleksibilitas, gunakan dua kolom JSON (before, after), tapi jaga payload kecil dan konsisten.
Untuk membership dan tim, soft delete biasanya lebih baik daripada hard delete. Alih-alih menghapus baris, tandai sebagai disabled dengan field seperti deleted_at dan deleted_by. Ini menjaga foreign key tetap utuh dan mempermudah menjelaskan akses masa lalu. Hard delete masih masuk akal untuk catatan yang benar-benar sementara (seperti invites yang kadaluwarsa), tapi hanya jika Anda yakin tidak akan membutuhkannya lagi.
Dengan ini, Anda dapat menjawab pertanyaan kepatuhan umum dengan cepat:
- Siapa yang memberi atau menghapus akses, dan kapan?
- Apa tepatnya yang berubah (peran, tim, status)?
- Apakah akses dihapus sebagai bagian dari alur offboarding normal?
Langkah demi langkah: merancang skema di database relasional
Mulai sederhana: satu tempat untuk menyatakan siapa tergabung ke mana, dan kenapa. Bangun langkah demi langkah, dan tambahkan aturan agar data tidak melenceng menjadi “hampir benar.”
Urutan praktis yang bekerja baik di PostgreSQL dan DB relasional lain:
-
Buat
organizationsdanteams, masing-masing dengan primary key stabil (UUID atau bigint). Tambahkanteams.organization_idsebagai foreign key, dan putuskan lebih awal apakah nama tim harus unik dalam sebuah org. -
Pisahkan
usersdari membership. Letakkan field identitas diusers(email, status, created_at). Letakkan “tergabung ke org/tim” di tabelmembershipsdenganuser_id,organization_id, optionalteam_id(jika Anda memodelkannya demikian), dan kolomstate(active, suspended, removed). -
Tambahkan
invitationssebagai tabel sendiri, bukan kolom di membership. Simpanorganization_id, optionalteam_id,email,token,expires_at, danaccepted_at. Tegakkan uniqueness untuk “satu invite terbuka per org + email + team” agar tidak membuat duplikat. -
Modelkan peran dengan tabel eksplisit. Pendekatan sederhana adalah
roles(admin, member, dll.) plusrole_assignmentsyang menunjuk ke cakupan org (tanpateam_id) atau cakupan tim (team_iddiisi). Jaga aturan pewarisan konsisten dan dapat dites. -
Tambahkan jejak audit sejak hari pertama. Gunakan tabel
access_eventsdenganactor_user_id,target_user_id(atau email untuk invites),action(invite_sent, role_changed, removed),scope(org/team), dancreated_at.
Setelah tabel ini ada, jalankan beberapa query admin dasar untuk memvalidasi kenyataan: “siapa yang punya akses org-wide?”, “tim mana yang tidak punya admin?”, dan “undangan mana yang kedaluwarsa tapi masih terbuka?” Pertanyaan itu cenderung mengungkap constraint yang hilang lebih awal.
Aturan dan constraint yang mencegah data berantakan
Skema tetap rapi ketika database, bukan hanya kode Anda, menegakkan batas tenant. Aturan paling sederhana: setiap tabel yang discoped tenant membawa org_id, dan setiap lookup menyertakannya. Bahkan jika seseorang lupa filter di aplikasi, database seharusnya menolak koneksi lintas-org.
Guardrail yang menjaga data tetap bersih
Mulailah dengan foreign key yang selalu menunjuk “dalam org yang sama.” Misalnya, jika Anda menyimpan team membership terpisah, sebuah baris team_memberships harus mereferensikan team_id dan user_id, tapi juga membawa org_id. Dengan composite key, Anda bisa memastikan team yang direferensikan memang milik org yang sama.
Constraint yang mencegah masalah umum:
- Satu membership org aktif per user per org: unique pada
(org_id, user_id)dengan kondisi partial untuk baris aktif (jika didukung). - Satu invite pending per email per org atau tim: unique pada
(org_id, team_id, email)ketikastate = 'pending'. - Token undangan unik secara global dan tidak pernah digunakan ulang: unique pada
invite_token. - Tim milik tepat satu org:
teams.org_idNOT NULL dengan foreign key keorgs(id). - Akhiri membership alih-alih menghapusnya: simpan
ended_at(dan opsionalended_by) untuk melindungi riwayat audit.
Indexing untuk lookup yang sering dilakukan
Index query yang sering dijalankan aplikasi Anda:
(org_id, user_id)untuk “org apa saja yang dimiliki user ini?”(org_id, team_id)untuk “daftar anggota tim ini”(invite_token)untuk “terima undangan”(org_id, state)untuk “undangan pending” dan “anggota aktif”
Buat nama org bisa diubah. Gunakan orgs.id yang immutable di mana-mana, dan anggap orgs.name (dan slug) sebagai field yang bisa diedit. Mengganti nama hanya menyentuh satu baris.
Memindahkan tim antar org biasanya keputusan kebijakan. Opsi paling aman adalah melarangnya (atau menggandakan tim) karena membership, peran, dan riwayat audit terikat pada org. Jika Anda harus mengizinkan pemindahan, lakukan dalam satu transaksi dan perbarui semua baris anak yang membawa org_id.
Untuk mencegah record yatim ketika pengguna pergi, hindari hard delete. Nonaktifkan user, akhiri membership mereka, dan batasi delete pada baris induk (ON DELETE RESTRICT) kecuali Anda benar-benar ingin cascading removal.
Contoh skenario: satu org, dua tim, mengubah akses dengan aman
Bayangkan perusahaan bernama Northwind Co dengan satu org dan dua tim: Sales dan Support. Mereka mempekerjakan kontraktor, Mia, untuk membantu tiket Support selama satu bulan. Di sinilah model harus tetap dapat diprediksi: satu orang, satu membership org, membership tim opsional, dan status yang jelas.
Seorang admin org (Ava) mengundang Mia lewat email. Sistem membuat baris invitation terkait org, dengan status pending dan tanggal kedaluwarsa. Belum ada perubahan lain, jadi tidak ada “user setengah jadi” dengan akses yang tidak jelas.
Saat Mia menerima, undangan ditandai accepted, dan baris membership org dibuat dengan state active. Ava memberi peran org Mia member (bukan admin). Kemudian Ava menambahkan membership tim Support dan menetapkan peran tim seperti support_agent.
Sekarang tambahkan satu twist: Ben adalah karyawan penuh waktu dengan peran org admin, tetapi dia seharusnya tidak melihat data Support. Anda bisa menangani itu dengan override level tim yang secara eksplisit menurunkan perannya di Support sambil mempertahankan kemampuan admin org untuk pengaturan org.
Seminggu kemudian, Mia melanggar kebijakan dan ditangguhkan. Alih-alih menghapus baris, Ava mengubah state membership org Mia menjadi suspended. Membership tim bisa tetap ada tetapi menjadi tidak efektif karena membership org tidak aktif.
Riwayat audit tetap bersih karena setiap perubahan adalah event:
- Ava mengundang Mia (siapa, apa, kapan)
- Mia menerima undangan
- Ava menambahkan Mia ke Support dan menetapkan
support_agent - Ava membuat override Support untuk Ben
- Ava menangguhkan Mia
Dengan model ini, UI bisa menampilkan ringkasan akses yang jelas: status org (aktif atau ditangguhkan), peran org, daftar tim dengan peran dan override, serta feed “Perubahan akses terbaru” yang menjelaskan mengapa seseorang bisa atau tidak bisa melihat Sales atau Support.
Kesalahan umum dan jebakan yang harus dihindari
Sebagian besar bug akses datang dari model data yang “hampir benar”. Skema terlihat baik pada awalnya, lalu kasus tepi menumpuk: re-invite, pemindahan tim, perubahan peran, dan offboarding.
Jebakan umum adalah mencampur undangan dan membership di satu baris. Jika Anda menyimpan “invited” dan “active” di record yang sama tanpa arti yang jelas, Anda akhirnya menanyakan pertanyaan yang mustahil seperti “Apakah orang ini anggota jika mereka tidak pernah menerima?” Pisahkan undangan dan membership, atau buat mesin status yang eksplisit dan konsisten.
Kesalahan lain yang sering terjadi adalah menaruh satu kolom peran di tabel user dan menganggap itu selesai. Peran hampir selalu bernuansa cakupan (peran org, peran tim, peran proyek). Peran global memaksa trik seperti “user adalah admin untuk satu pelanggan, tetapi read-only untuk yang lain,” yang merusak ekspektasi multi-tenant dan membuat dukungan repot.
Jebakan yang biasanya menyakitkan di kemudian hari:
- Mengizinkan membership lintas-org tanpa sengaja (team_id menunjuk ke org A, membership menunjuk ke org B).
- Hard deleting membership dan kehilangan jejak “siapa yang punya akses minggu lalu?”.
- Hilangnya aturan uniqueness sehingga user mendapat akses duplikat lewat baris identik.
- Membiarkan pewarisan menumpuk diam-diam (org admin plus team member plus override) sehingga tidak ada yang bisa menjelaskan mengapa akses ada.
- Memperlakukan “invite accepted” sebagai event UI, bukan fakta database.
Contoh cepat: seorang kontraktor diundang ke org, bergabung ke Tim Sales, lalu dihapus dan diundang lagi sebulan kemudian. Jika Anda menimpa baris lama, Anda kehilangan riwayat. Jika Anda membiarkan duplikat, mereka mungkin berakhir dengan dua membership aktif. Status yang jelas, peran berskala, dan constraint yang tepat mencegah keduanya.
Pemeriksaan cepat dan langkah berikutnya untuk memasukkannya ke aplikasi Anda
Sebelum menulis kode, lakukan tinjauan cepat model Anda dan lihat apakah masih masuk akal di atas kertas. Model akses multi-tenant yang baik harus terasa membosankan: aturan yang sama berlaku di mana-mana, dan “kasus khusus” jarang.
Checklist cepat untuk menangkap celah umum:
- Setiap membership menunjuk tepat ke satu user dan satu org, dengan unique constraint untuk mencegah duplikat.
- State undangan, membership, dan penghapusan eksplisit (tidak diimplikasikan oleh null), dan transisinya dibatasi (misalnya, tidak bisa menerima undangan yang sudah expired).
- Peran disimpan di satu tempat dan akses efektif dihitung konsisten (termasuk aturan pewarisan jika digunakan).
- Menghapus org/tim/user tidak menghapus riwayat (gunakan soft delete atau field arsip bila butuh jejak audit).
- Setiap perubahan akses menghasilkan event audit dengan actor, target, cakupan, timestamp, dan alasan/sumber.
Uji desain dengan pertanyaan nyata. Jika Anda tidak bisa menjawab ini dengan satu query dan aturan yang jelas, Anda mungkin perlu constraint atau state tambahan:
- Apa yang terjadi jika seorang pengguna diundang dua kali, lalu email berubah?
- Bisakah admin tim menghapus pemilik org dari tim tersebut?
- Jika peran org memberi akses ke semua tim, bisakah satu tim menimpanya?
- Jika undangan diterima setelah peran diubah, peran mana yang berlaku?
- Ketika dukungan menanyakan “siapa yang menghapus akses,” bisakah Anda membuktikannya dengan cepat?
Tuliskan apa yang harus dipahami admin dan staf dukungan: state membership (dan apa yang memicunya), siapa yang bisa mengundang/menghapus, apa arti pewarisan peran dalam bahasa biasa, dan di mana melihat event audit saat terjadi insiden.
Terapkan constraint dulu (unique, foreign key, transisi yang diijinkan), lalu bangun logika bisnis di atasnya agar database membantu menjaga kebenaran. Simpan keputusan kebijakan (pewarisan on/off, peran default, expiry undangan) di tabel konfigurasi daripada konstanta kode.
Jika Anda ingin membangun ini tanpa menulis setiap backend dan layar admin, AppMaster (appmaster.io) dapat membantu Anda memodelkan tabel-tabel ini di PostgreSQL dan mengimplementasikan transisi undangan serta membership sebagai proses bisnis eksplisit, sambil tetap menghasilkan kode sumber nyata untuk deployment produksi.
FAQ
Gunakan catatan membership terpisah sehingga peran dan akses terkait ke sebuah org (dan opsional tim), bukan identitas pengguna global. Ini memungkinkan orang yang sama menjadi Admin di satu org dan Viewer di org lain tanpa solusi sementara.
Pisahkan: sebuah invitation adalah penawaran dengan email, cakupan, dan masa berlaku, sementara sebuah membership berarti pengguna benar-benar memiliki akses. Ini menghindari “anggota hantu”, status yang tidak jelas, dan bug keamanan saat email berubah.
Satu set kecil seperti active, suspended, dan removed sudah cukup untuk sebagian besar aplikasi B2B. Jika Anda menyimpan “invited” hanya di tabel invitations, maka membership tetap jelas: mewakili akses sekarang atau yang pernah ada, bukan akses yang menunggu.
Simpan peran org dan peran tim sebagai assignment dengan cakupan (org-wide jika team_id bernilai null, khusus tim jika diisi). Saat memeriksa akses untuk sebuah tim, gunakan assignment khusus tim jika ada, jika tidak, gunakan fallback ke assignment org-wide.
Mulai dengan aturan yang konsisten: peran org berlaku di mana-mana secara default, dan peran tim menimpa hanya ketika diset secara eksplisit. Buat override jarang dan terlihat agar orang bisa menjelaskan akses tanpa menebak-nebak.
Terapkan "hanya satu invite pending per org/tim per email" dengan constraint unik dan siklus hidup pending/accepted/revoked/expired yang jelas. Jika perlu re-invite, perpanjang expiry pada invite pending yang ada atau batalkan yang lama sebelum membuat token baru.
Setiap baris yang discoped ke tenant harus membawa org_id, dan foreign key/constraint Anda harus mencegah pencampuran org (misalnya, sebuah team yang direferensikan oleh membership harus milik org yang sama). Ini mengurangi dampak dari lupa filter di kode aplikasi.
Simpan log event akses append-only yang merekam siapa melakukan apa, kepada siapa, kapan, dan pada cakupan apa (org atau tim). Catat field before/after kunci (role, state, team) sehingga Anda bisa menjawab “siapa yang memberi admin pada hari Selasa lalu?” secara andal.
Hindari hard delete untuk membership dan tim; tandai sebagai ended/disabled sehingga riwayat tetap bisa diquery dan foreign key tidak rusak. Untuk undangan, Anda juga bisa menyimpannya (bahkan jika sudah kedaluwarsa) untuk jejak keamanan penuh, tapi setidaknya jangan reuse token.
Index jalur panas Anda: (org_id, user_id) untuk pemeriksaan membership org, (org_id, team_id) untuk daftar anggota tim, (invite_token) untuk penerimaan undangan, dan (org_id, state) untuk layar admin seperti “anggota aktif” atau “undangan pending.” Index harus sesuai query nyata Anda, bukan setiap kolom.


