Skema Data — Fondasi (Modul 0 & Modul 9)¶
Status: DRAFT (13 Juli 2026) — tahap 1 dari desain skema bertahap
Sesuai keputusan dikerjakan bertahap: halaman ini hanya mencakup fondasi (tabel Tenant,
Struktur Organisasi, Kalender Akademik, Kurikulum, Jabatan Struktural, dan RBAC) — karena
seluruh modul fungsional lain (Mahasiswa, Dosen, Keuangan, dst.) bergantung padanya. Skema
tabel modul fungsional menyusul di halaman terpisah setelah fondasi ini disepakati.
1. Prinsip Desain¶
| Prinsip | Penerapan |
|---|---|
| Multi-tenancy | Kolom tenant_id di setiap tabel (kecuali Tenant sendiri) + PostgreSQL Row-Level Security — lihat Keputusan Arsitektur §1 |
| Jabatan struktural | Dicatat sebagai riwayat (jabatan + unit kerja + tanggal mulai–selesai), bukan field statis — lihat Cakupan Modul §0.1 |
| Konfigurasi per tenant | Jenis periode akademik, jenis/bidang jabatan struktural, dan ambang nilai (SKS BKD, aset, dst.) semuanya data, bukan enum tetap di kode |
| Kurikulum & prasyarat | Kurikulum ber-versi per Program Studi; mata kuliah prasyarat dimodelkan eksplisit (masuk rilis pertama) |
2. Diagram ERD¶
3. Tenant¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | UUID (PK) | |
| nama | varchar | Nama resmi PT, mis. "Universitas Muhammadiyah Surakarta" |
| kode | varchar, unique | Slug singkat, mis. ums |
| domain_default | varchar | Akses default, mis. sia.oneke.ums.id |
| custom_domain | varchar, nullable | Domain milik kampus sendiri, mis. sia.bim.ac.id |
| status | enum(aktif, nonaktif) | |
| dibuat_pada | timestamp |
Tenant TIDAK memakai RLS — pengecualian yang disengaja
Tabel ini adalah sumber resolusi tenant_id dari domain request yang masuk — query ke sini
terjadi sebelum tenant_id diketahui, jadi tidak bisa difilter RLS berbasis tenant_id
(chicken-and-egg). Ini satu-satunya tabel yang boleh diakses lintas-tenant secara sengaja.
4. Struktur Organisasi¶
4.1 Fakultas¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| nama | varchar | |
| kode | varchar |
4.2 ProgramStudi¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| fakultas_id | bigint (FK → Fakultas) | |
| nama | varchar | |
| kode | varchar | |
| jenjang | enum(D3, D4, S1, S2, S3) |
5. Kalender Akademik¶
5.1 TahunAkademik¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| nama | varchar | mis. "2025/2026" |
| semester | enum(ganjil, genap) | |
| is_aktif | boolean |
5.2 JenisPeriodeAkademik¶
Master dikonfigurasi per tenant — bukan enum tetap, supaya BAA bisa menambah jenis periode baru (lihat Cakupan Modul §1b) tanpa perubahan kode.
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| nama | varchar | mis. "Masa Input Nilai", "Registrasi Ulang" |
| kode | varchar |
5.3 PeriodeAkademik¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| tahun_akademik_id | bigint (FK → TahunAkademik) | |
| jenis_periode_id | bigint (FK → JenisPeriodeAkademik) | |
| tanggal_mulai | date | |
| tanggal_selesai | date |
6. Kurikulum & Mata Kuliah¶
6.1 Kurikulum¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| program_studi_id | bigint (FK → ProgramStudi) | |
| nama | varchar | mis. "Kurikulum 2021" |
| tahun_berlaku | integer | |
| status | enum(aktif, nonaktif) |
6.2 MataKuliah¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| program_studi_id | bigint (FK → ProgramStudi) | Prodi pemilik |
| kode | varchar | |
| nama | varchar | |
| sks | smallint |
6.3 KurikulumMataKuliah (tabel penghubung)¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| kurikulum_id | bigint (FK → Kurikulum) | |
| mata_kuliah_id | bigint (FK → MataKuliah) | |
| jenis | enum(wajib, pilihan) | |
| semester_disarankan | smallint |
6.4 MataKuliahPrasyarat (tabel penghubung, self-referencing)¶
[DIKONFIRMASI masuk rilis pertama] — lihat Cakupan Modul §0.
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| mata_kuliah_id | bigint (FK → MataKuliah) | Mata kuliah yang mensyaratkan |
| mata_kuliah_prasyarat_id | bigint (FK → MataKuliah) | Mata kuliah yang harus lulus dulu |
6.5 Ruang¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| nama | varchar | |
| kapasitas | smallint |
7. Identitas Dasar Pegawai¶
Lingkup terbatas di tahap ini
Tabel ini hanya mencakup identitas minimum yang dibutuhkan Jabatan Struktural & RBAC. Field lengkap kepegawaian (data personal, gaji, BKD, dst.) menyusul di desain skema Modul 6 (HRD) pada tahap berikutnya.
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| user_id | bigint (FK → auth user Django) | |
| nama | varchar | |
| nip_nidn | varchar | |
| tipe | enum(dosen, staff, dosen_dan_staff) |
8. Jabatan Struktural & Riwayat Jabatan¶
Lihat penjelasan lengkap & diagram hierarki di Cakupan Modul §0.1.
8.1 JabatanStruktural (master, per tenant)¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| nama | varchar | mis. "Wakil Rektor I", "Dekan", "Kepala BAA" |
| level | enum(universitas, fakultas, prodi, unit) | |
| bidang | varchar, nullable | Bebas dikonfigurasi (mis. "Akademik") — khusus level universitas (WR) |
| atasan_jabatan_id | bigint (FK → JabatanStruktural, self, nullable) | Membangun rantai persetujuan |
8.2 RiwayatJabatan¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| jabatan_struktural_id | bigint (FK → JabatanStruktural) | |
| pegawai_id | bigint (FK → Pegawai) | |
| fakultas_id | bigint (FK → Fakultas, nullable) | Diisi bila jabatan level fakultas |
| program_studi_id | bigint (FK → ProgramStudi, nullable) | Diisi bila jabatan level prodi |
| unit_nama | varchar, nullable | Diisi bila jabatan level unit (mis. "BAA") — unit pendukung tidak selalu punya tabel master sendiri |
| tanggal_mulai | date | |
| tanggal_selesai | date, nullable | Kosong = masih menjabat |
Kenapa tiga kolom nullable untuk unit kerja, bukan satu Generic FK?
Django GenericForeignKey melewati constraint referential integrity level database dan
menyulitkan penulisan RLS policy (RLS butuh join FK yang nyata). Tiga kolom nullable —
hanya satu yang terisi sesuai level milik JabatanStruktural-nya — lebih sederhana dan
tetap terjaga integritasnya di level database, dengan trade-off sedikit denormalisasi.
9. RBAC — Peran & Penugasan¶
9.1 Peran (master, global — bukan per-tenant)¶
Definisi peran (kode, nama, hak akses per modul) sama untuk semua tenant — bagian dari kode aplikasi, bukan data yang dikonfigurasi tiap kampus.
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| kode | varchar, unique | mis. kaprodi, kepala_baa, dosen, mahasiswa |
| nama | varchar | |
| deskripsi | text |
9.2 PenugasanPeran (tenant-scoped)¶
| Kolom | Tipe | Keterangan |
|---|---|---|
| id | bigint (PK) | |
| tenant_id | UUID (FK → Tenant) | |
| pegawai_id | bigint (FK → Pegawai) | |
| peran_id | bigint (FK → Peran) | |
| scope_type | enum(prodi, fakultas, unit, null), nullable | null = berlaku seluruh tenant |
| scope_id | bigint, nullable | ID Prodi/Fakultas sesuai scope_type |
| tanggal_mulai | date | |
| tanggal_selesai | date, nullable |
10. Kebijakan Row-Level Security¶
Pola yang sama berlaku untuk semua tabel di atas kecuali Tenant — versi final, hasil uji
isolasi (§11):
ALTER TABLE fakultas ENABLE ROW LEVEL SECURITY;
ALTER TABLE fakultas FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON fakultas
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
FORCE ROW LEVEL SECURITY wajib — bukan hanya ENABLE
Temuan tambahan (§11): ENABLE ROW LEVEL
SECURITY saja tidak membuat owner tabel tunduk ke RLS — hanya FORCE ROW LEVEL
SECURITY yang melakukannya. Django akan connect memakai appuser (pemilik appdb yang
sudah ada), jadi baris FORCE wajib ada di setiap CREATE TABLE/migrasi, atau RLS tidak
berlaku sama sekali untuk koneksi aplikasi. Superuser (postgres) tetap selalu bypass RLS
apa pun kondisinya — itu sudah diketahui & diterima (superuser hanya dipakai admin/operator,
bukan koneksi aplikasi).
Revisi dari draft awal — missing_ok=true wajib, bukan opsional
Draft sebelum uji isolasi memakai current_setting('app.tenant_id') (satu argumen). Uji
nyata (§11) menemukan ini error (crash,
bukan nol baris) bila middleware gagal men-set konteks tenant untuk alasan apa pun. Bentuk
dua-argumen (current_setting('app.tenant_id', true)) mengembalikan NULL alih-alih error,
sehingga baris tetap nol (aman) tanpa meng-crash query. NULLIF(..., '') menangani kasus
string kosong juga. Semua tabel di skema Modul 1–8 harus memakai bentuk ini, bukan versi
draft yang lebih awal.
Django menetapkan tenant_id per-request lewat middleware, dibungkus transaksi (atomic())
supaya konteks tenant tidak pernah bocor ke request lain lewat connection pooling — pola yang
sudah dibahas saat perbandingan keamanan Django vs Laravel:
class TenantMiddleware:
def __call__(self, request):
tenant_id = resolve_tenant(request) # dari domain/subdomain request
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute("SET LOCAL app.tenant_id = %s", [str(tenant_id)])
return self.get_response(request)
Peran (§9.1) sengaja tidak punya RLS
Sama seperti Tenant, tabel Peran bersifat global (definisi kode aplikasi, bukan data
tenant) — tidak relevan diberi kebijakan isolasi tenant.
Koneksi database aplikasi TIDAK BOLEH superuser — owner boleh, asal FORCE aktif
Ditemukan & dikonfirmasi lewat uji nyata (§11):
RLS sepenuhnya diabaikan untuk superuser Postgres — perilaku bawaan Postgres yang tidak
bisa diubah, bukan bug. Untuk pemilik tabel (owner), FORCE ROW LEVEL SECURITY
menyelesaikannya — dikonfirmasi lewat pengujian tambahan: appuser (pemilik appdb) tetap
tunduk RLS penuh selama FORCE diaktifkan di tiap tabel. Jadi Django bisa connect
memakai appuser yang sudah ada (tidak perlu bikin role terpisah) — syaratnya FORCE ROW
LEVEL SECURITY wajib ada di setiap tabel, tidak boleh terlewat. Superuser (postgres)
tetap hanya untuk operator/admin manual, tidak pernah untuk koneksi aplikasi.
11. Hasil Uji Isolasi Tenant (13 Juli 2026)¶
Status: SELESAI — 7 skenario diuji langsung di postgres-cluster
Dijalankan di database sementara (sia_rls_test, sudah dihapus setelah uji) dan langsung di
appdb (untuk skenario ke-7, tabel sementara juga sudah dihapus) — di cluster Postgres yang
sama dipakai produksi, bukan simulasi di atas kertas. Skema uji: tabel Tenant (tanpa RLS) +
Fakultas (dengan RLS, persis pola di §10), dua tenant
dummy (UMS, Muhammadiyah Bali).
| # | Skenario | Ekspektasi | Hasil |
|---|---|---|---|
| 1 | Query dengan konteks Tenant A (role non-owner) | Hanya baris Tenant A terlihat | ✅ Sesuai |
| 2 | Query dengan konteks Tenant B (role non-owner) | Hanya baris Tenant B terlihat | ✅ Sesuai |
| 3 | Query tanpa SET app.tenant_id sama sekali |
Nol baris (aman) | ⚠️ Awalnya error/crash — diperbaiki dengan missing_ok=true (lihat di atas), setelah perbaikan: nol baris ✅ |
| 4 | Query sebagai superuser (postgres), tanpa SET |
Idealnya nol baris atau ditolak | 🔴 RLS diabaikan total — semua baris dari kedua tenant terlihat. Perilaku bawaan Postgres yang tidak bisa diubah — mitigasi: superuser tidak pernah dipakai untuk koneksi aplikasi |
| 5 | INSERT dari konteks Tenant A, tenant_id diisi ID Tenant B (spoofing) |
Ditolak | ✅ Ditolak (new row violates row-level security policy) — kebijakan USING-saja otomatis merangkap sebagai WITH CHECK untuk policy tanpa FOR spesifik (perilaku standar Postgres, dikonfirmasi bekerja) |
| 6 | UPDATE menarget baris Tenant B by id, dari konteks Tenant A |
Nol baris terpengaruh | ✅ UPDATE 0 — baris tidak pernah terlihat untuk di-update sejak awal |
| 7 | Query sebagai appuser (pemilik tabel di appdb), dengan FORCE ROW LEVEL SECURITY aktif |
Tunduk RLS seperti role biasa | ✅ Dengan konteks: hanya baris tenant terkait. Tanpa konteks: nol baris. FORCE membuat owner ikut terikat RLS — Django bisa pakai appuser yang sudah ada, tidak perlu role terpisah |
Kesimpulan: pola RLS yang dirancang valid dan aman, dengan syarat wajib yang sekarang
tercatat sebagai bagian resmi kebijakan (bukan sekadar rekomendasi): (a) current_setting(...,
true) bukan bentuk satu-argumen, (b) FORCE ROW LEVEL SECURITY di setiap tabel supaya owner
(appuser) ikut tunduk RLS, (c) superuser (postgres) tidak pernah dipakai untuk koneksi
aplikasi. Ketiga syarat ini berlaku untuk seluruh tabel di enam halaman skema data (Fondasi,
Akademik Inti, Keuangan, HRD, Biro Aset, Perpustakaan) — bukan hanya Fakultas yang diuji di sini.
12. Langkah Berikutnya¶
Setelah fondasi ini disepakati, skema tabel modul fungsional (Mahasiswa, Dosen, KRS/Presensi/
Nilai, Keuangan, HRD, Aset, Perpustakaan) dirancang di halaman terpisah, mengacu ke tabel-tabel
di atas (ProgramStudi, Kurikulum, PeriodeAkademik, Pegawai, dst.) sebagai referensi.
Fondasi infrastruktur K8s (namespace, Deployment skeleton, IngressRoute) dibangun memakai
appdb/appuser yang sudah ada di postgres-cluster, dengan FORCE ROW LEVEL SECURITY di
setiap migrasi.
Kembali ke Roadmap Implementasi atau Cakupan Modul.