SUPONO.
Membangun LMS Vokasi TKJ Berbasis Multi-Tenant: Dari Arsitektur Virtual Lab, Deployment, hingga Security Hardening | Supono
Infrastruktur / Keamanan

Membangun LMS Vokasi TKJ Berbasis Multi-Tenant: Dari Arsitektur Virtual Lab, Deployment, hingga Security Hardening

Oleh Suponoโ€ข20 September 2026

Pengembangan platform edukasi vokasi membutuhkan fondasi arsitektur yang tidak hanya mampu mengelola alur pembelajaran konvensional, namun juga dapat menyediakan lingkungan praktikum jaringan komputer yang terisolasi dan aman. Artikel teknis ini menguraikan dokumentasi komprehensif langkah demi langkah dalam membangun platform LMS Vokasi TKJ, mulai dari perancangan arsitektur multi-tenant berbasis FastAPI dan React, pengembangan simulator virtual lab MikroTik serta konverter kurikulum sekolah, persiapan production-ready, orkestrasi deployment (Docker On-Premise & Vercel Serverless), hingga audit keamanan mendalam (penetration testing) untuk memitigasi kerentanan Broken Access Control, Authentication Bypass, CORS Misconfiguration, dan Server-Side Request Forgery (SSRF).


Langkah 1: Perancangan Arsitektur Multi-Tenant dan RBAC

Fondasi aplikasi dirancang menggunakan pola arsitektur Layer-2 Multi-Tenancy pada database MongoDB. Setiap dokumen bisnis (kursus, modul, unit kompetensi, lab, sertifikat) mengikat tenant_id institusi sekolah terkait. Akses database dibungkus menggunakan wrapper ScopedDB yang menginjeksi filter tenant_id secara transparan pada setiap operasi query, find, update, dan delete.

Implementasi isolasi data tenant pada lapisan database backend didefinisikan sebagai berikut:

# backend/tenancy.py
class ScopedCollection:
    def __init__(self, coll, tenant_id):
        self._c = coll
        self._t = tenant_id

    def _q(self, q):
        q = dict(q or {})
        if self._t is not None:
            q["tenant_id"] = self._t
        return q

    def find(self, q=None, *a, **k):
        return self._c.find(self._q(q), *a, **k)

    def find_one(self, q=None, *a, **k):
        return self._c.find_one(self._q(q), *a, **k)

    async def insert_one(self, doc, *a, **k):
        if self._t is not None:
            doc = {**doc, "tenant_id": self._t}
        return await self._c.insert_one(doc, *a, **k)

def scoped(db, user):
    """Mengembalikan ScopedDB untuk pengguna tenant, atau unscoped untuk Superadmin."""
    is_god = user.get("platform_role") == "superadmin" or user.get("role") == "superadmin"
    tenant_id = None if is_god else user.get("tenant_id")
    return ScopedDB(db, tenant_id)

Struktur hak akses berbasis peran (Role-Based Access Control / RBAC) dibagi menjadi 5 tingkatan:

  1. Student (Siswa): Praktik lab terminal, pengerjaan kuis formatif, pengumpulan bukti kerja CBA, dan digital skill passport.
  2. Teacher (Guru): Pengelolaan silabus kursus, pembuatan skenario lab, dan penilaian formatif.
  3. Assessor (Asesor Eksternal LSP/DUDI): Verifikasi portofolio bukti uji kompetensi dan penerbitan sertifikat SKKNI.
  4. Admin (Admin Institusi): Verifikasi pendaftaran siswa/guru di sekolahnya, manajemen kelas, dan penagihan langganan.
  5. Superadmin (Platform Owner): Akses lintas-institusi, provisi institusi/tenant baru, dan monitoring analitik global.

Langkah 2: Pembangunan Engine Virtual Lab MikroTik & Template Kurikulum

Siswa vokasi membutuhkan lingkungan uji praktikum tanpa harus selalu terhubung ke perangkat keras fisik. Kami membangun dua mode laboratorium:

  1. Simulator Mode: Engine parser RouterOS berbasis Python murni di memori backend yang mendukung navigasi CLI (/ip/address, /interface/vlan, /ip/dhcp-server, /ip/firewall/nat) dan dilengkapi sistem penilaian otomatis (auto-grader).
  2. Real Device Mode: Konektor REST API RouterOS v7 via HTTPS Basic Auth untuk sekolah yang memiliki rak router fisik di lab sekolah.

Di sisi konten pedagogis, platform dilengkapi dengan 7 template kurikulum kejuruan siap pakai (MTCNA Dasar, MTCRE Routing, MTCSE Security, MTCTCE QoS & Hotspot, serta Kurikulum Merdeka TKJ Kelas 10, 11, dan 12) berstandar SKKNI.

Agar pihak sekolah dapat menyinkronkan dokumen silabus dan bank soal internal mereka ke LMS dengan cepat, skrip otomasi konversi spreadsheet Excel (.xlsx) ke bundel JSON disediakan:

# Menghasilkan template Excel standar sekolah
python scripts/excel_to_course_bundle.py --generate-sample templates/template_kurikulum_sekolah.xlsx

# Mengonversi file Excel kurikulum sekolah menjadi bundel kursus JSON siap impor
python scripts/excel_to_course_bundle.py templates/template_kurikulum_sekolah.xlsx --out templates/kurikulum_sekolah.json

Langkah 3: Persiapan Production-Ready & Penataan Struktur Proyek

Setelah seluruh fitur fungsional berjalan, tahap persiapan produksi (Production-Ready) dilakukan untuk membersihkan artefak pengembangan dan menyusun struktur folder profesional standar industri.

Pembersihan menyeluruh dilakukan dengan menghapus test suites lokal, cache compiler, serta file laporan pengujian agar tidak membebani repositori produksi:

# Membersihkan seluruh file pengujian, cache, dan laporan iterasi testing
rm -rf backend/tests backend/pytest.ini backend/.pytest_cache tests test_reports auth_testing.md test_result.md

Seluruh dokumen teknis, panduan operasional, dan arsitektur kemudian dikonsolidasikan ke dalam direktori terpusat docs/ dengan struktur modular:

โ”œโ”€โ”€ docs/
โ”‚   โ”œโ”€โ”€ deployment/             # Panduan instalasi Docker on-premise & cloud hosting
โ”‚   โ”œโ”€โ”€ guides/                 # Panduan pendaftaran institusi & sinkronisasi dokumen kurikulum
โ”‚   โ”œโ”€โ”€ architecture/           # Spesifikasi REST API lengkap & alur instruksional CBA
โ”‚   โ”œโ”€โ”€ roadmap/                # Roadmap backlog fitur pengembangan (TODO.md)
โ”‚   โ””โ”€โ”€ examples/               # Contoh skrip Python & Bash untuk eksekusi auto-grader
โ”œโ”€โ”€ scripts/
โ”‚   โ”œโ”€โ”€ seed_production.py      # Inisialisasi awal database produksi
โ”‚   โ”œโ”€โ”€ migrate_tenant.py       # Migrasi data & konten antar-tenant
โ”‚   โ”œโ”€โ”€ check_db.py             # Diagnostik koneksi & monitoring koleksi database
โ”‚   โ””โ”€โ”€ excel_to_course_bundle.py # Konverter kurikulum Excel ke template LMS

Pada backend, mekanisme event handler usang digantikan dengan FastAPI lifespan modern untuk mengelola koneksi database secara graceful:

# backend/server.py
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manajemen siklus hidup startup dan shutdown database MongoDB."""
    if not IS_CLOUD:
        try:
            await seed_all()
            logger.info("Inisialisasi seed selesai.")
        except Exception as e:
            logger.error(f"Gagal melakukan database seeding: {e}")
    yield
    if not IS_CLOUD:
        client.close()
        logger.info("Koneksi MongoDB client ditutup secara aman.")

app = FastAPI(title="LMS Vokasi TKJ", lifespan=lifespan)

Langkah 4: Orkestrasi Deployment (Docker On-Premise & Cloud Serverless)

Aplikasi dirancang agar dapat diluncurkan pada dua skenario deployment: On-Premise Server di jaringan lokal sekolah (bebas biaya kuota internet) atau Cloud Serverless di internet.

Skenario A: On-Premise Menggunakan Docker Compose & Nginx Reverse Proxy

Konfigurasi multi-container Docker disiapkan menggunakan Docker Compose yang mengorkestrasi MongoDB, FastAPI backend, dan Nginx web server:

# docker-compose.yml
version: '3.8'

services:
  db:
    image: mongo:latest
    container_name: rm_mongo
    restart: always
    ports:
      - "27017:27017"
    volumes:
      - rm_mongo_data:/data/db

  backend:
    build:
      context: .
      dockerfile: backend/Dockerfile
    container_name: rm_backend
    restart: always
    ports:
      - "8000:8000"
    env_file:
      - .env
    depends_on:
      - db

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: rm_frontend
    restart: always
    ports:
      - "80:80"
    depends_on:
      - backend

volumes:
  rm_mongo_data:

Untuk menghindari galat 404 pada pemanggilan API frontend, konfigurasi Nginx reverse proxy disetel tanpa menambahkan garis miring penutup pada proxy_pass, sehingga prefix /api tetap diteruskan utuh ke backend:

# frontend/nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # Reverse Proxy menuju backend FastAPI tanpa memotong prefix /api
    location /api/ {
        proxy_pass http://backend:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Skenario B: Cloud Serverless Deployment

Untuk deployment cloud instan sebagai host monorepo menggunakan konfigurasi rewrites dan entrypoint serverless:

{
  "outputDirectory": "frontend/build",
  "framework": "create-react-app",
  "rewrites": [
    {
      "source": "/api/(.*)",
      "destination": "/api/index.py"
    },
    {
      "source": "/(.*)",
      "destination": "/index.html"
    }
  ],
  "functions": {
    "api/index.py": {
      "maxDuration": 30,
      "excludeFiles": "frontend/**"
    }
  }
}

Kompilasi aset produksi frontend diverifikasi menggunakan perintah build standar:

cd frontend && npm run build
# Hasil kompilasi:
# Compiled successfully.
# 350.62 kB  build/static/js/main.js
# 20.46 kB   build/static/css/main.css

Langkah 5: Audit Keamanan Kode & Penetration Testing

Audit keamanan menyeluruh dilakukan untuk mengidentifikasi celah kerentanan tingkat aplikasi sebelum sistem dirilis ke publik. Audit ini mendeteksi beberapa kerentanan kritis yang langsung dimitigasi.

1. Mitigasi Broken Access Control (Privilege Escalation ke Superadmin)

Sebelumnya, endpoint PUT /api/admin/users/{user_id}/role memvalidasi input terhadap daftar ROLES global tanpa memeriksa hierarki pemanggil. Akibatnya, admin sekolah dapat mempromosikan akunnya sendiri menjadi superadmin.

Perbaikan dilakukan dengan melarang admin institusi mengangkat peran superadmin atau memodifikasi akun superadmin:

# backend/routers/admin.py
@router.put("/users/{user_id}/role")
async def admin_update_role(
    user_id: str, body: RoleUpdateIn, user=Depends(require_roles("admin"))
):
    is_god = user.get("platform_role") == "superadmin" or user.get("role") == "superadmin"
    db_scoped = scoped(db, user)

    if body.role not in ROLES:
        raise HTTPException(400, "Peran tidak valid")

    target = await db_scoped.users.find_one({"user_id": user_id})
    if not target:
        raise HTTPException(404, "Pengguna tidak ditemukan di institusi Anda")

    # Blokir modifikasi akun Superadmin oleh admin lokal
    if (target.get("platform_role") == "superadmin" or target.get("role") == "superadmin") and not is_god:
        raise HTTPException(403, "Tidak diizinkan memodifikasi peran Superadmin Platform")

    # Blokir eskalasi hak akses ke admin/superadmin oleh admin lokal
    if not is_god:
        if body.role in ("superadmin", "admin"):
            raise HTTPException(403, "Hanya Superadmin Platform yang berhak mengangkat peran Admin atau Superadmin")
        if target.get("role") == "admin":
            raise HTTPException(403, "Admin institusi tidak diizinkan memodifikasi sesama Admin Institusi")

    await db_scoped.users.update_one(
        {"user_id": user_id}, {"$set": {"role": body.role, "verified": True}}
    )
    return {"ok": True}

2. Penutupan Celah Pembayaran Gratis (Free Payment Bypass)

Endpoint /simulate-success/{order_id} yang awalnya digunakan untuk pengujian sandbox ditutup dari lingkungan produksi dan dikunci khusus akun Superadmin:

# backend/routers/billing.py
@router.post("/simulate-success/{order_id}")
async def simulate_success(order_id: str, user=Depends(get_current_user)):
    """Simulasi pembayaran sukses: Hanya aktif di mode development atau bagi Superadmin."""
    env = os.environ.get("ENVIRONMENT", "production").lower()
    allow_sim = os.environ.get("ENABLE_PAYMENT_SIMULATION") == "1"
    is_god = user.get("platform_role") == "superadmin" or user.get("role") == "superadmin"

    if (env == "production" and not allow_sim) or not is_god:
        raise HTTPException(
            403,
            "Endpoint simulasi pembayaran dinonaktifkan di lingkungan produksi. Hanya Superadmin yang berhak mengakses."
        )

    payment = await db.payments.find_one({"order_id": order_id})
    if not payment:
        raise HTTPException(404, "Order tidak ditemukan")
    await _activate_payment(order_id, payment_method="demo_sandbox")
    return {"ok": True, "message": f"Payment {order_id} simulated as paid"}

3. Penutupan Bypass Pendaftaran via Email test_

Sisa logika otomatisasi pengujian startswith("test_") yang memberikan persetujuan instan dihapus sepenuhnya pada [routers/auth.py], memastikan seluruh pendaftar baru berstatus pending dan wajib diverifikasi admin institusi.

4. Pengetatan Kebijakan CORS (Strict Origin Allowlist)

Penggunaan regex wildcard allow_origin_regex=".*" bersama allow_credentials=True digantikan dengan kebijakan allowlist eksplisit:

# backend/server.py
cors_origins_env = os.environ.get("CORS_ORIGINS") or os.environ.get("ALLOWED_ORIGINS", "")
if cors_origins_env:
    allowed_origins = [orig.strip() for orig in cors_origins_env.split(",") if orig.strip()]
    allow_origin_regex = None
else:
    allowed_origins = [
        "http://localhost:3000",
        "http://localhost:8000",
        "http://localhost",
        "http://127.0.0.1:3000",
        "http://127.0.0.1:8000",
    ]
    allow_origin_regex = r"^https:\/\/.*\.cloud\.app$"

cors_kwargs = {
    "allow_credentials": True,
    "allow_methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
    "allow_headers": ["*"],
}
if allow_origin_regex:
    cors_kwargs["allow_origin_regex"] = allow_origin_regex
cors_kwargs["allow_origins"] = allowed_origins

app.add_middleware(CORSMiddleware, **cors_kwargs)

5. Perlindungan Integritas Kunci Kriptografi JWT

Aplikasi menerapkan mekanisme Fail-Fast pada saat inisialisasi: server akan menolak berjalan jika mendeteksi kunci default JWT_SECRET yang tidak aman pada lingkungan produksi:

# backend/auth_utils.py
DEFAULT_INSECURE_SECRET = "super-secret-default-key-for-local-dev"
raw_secret = os.environ.get("JWT_SECRET")
is_production = (
    os.environ.get("cloud") == "1"
    or os.environ.get("ENVIRONMENT", "").lower() == "production"
)

if not raw_secret or raw_secret == DEFAULT_INSECURE_SECRET:
    if is_production:
        raise RuntimeError(
            "FATAL SECURITY ERROR: JWT_SECRET environment variable is missing or using an insecure default key! "
            "Configure a strong random 32+ character secret in your environment settings before starting the server."
        )
    JWT_SECRET = DEFAULT_INSECURE_SECRET
else:
    JWT_SECRET = raw_secret

6. Filter SSRF pada Konektor Perangkat Fisik

Fungsi validate_host_address diterapkan untuk memvalidasi hostname/IP perangkat fisik guna mencegah Server-Side Request Forgery (SSRF) yang menargetkan metadata cloud atau alamat loopback:

# backend/routeros_rest.py
import ipaddress
import socket

FORBIDDEN_SUBNETS = [
    ipaddress.ip_network("169.254.0.0/16"),   # Link-Local / Cloud Metadata (AWS/GCP)
    ipaddress.ip_network("127.0.0.0/8"),      # Loopback
    ipaddress.ip_network("0.0.0.0/8"),        # Current network
    ipaddress.ip_network("::1/128"),          # IPv6 loopback
    ipaddress.ip_network("fe80::/10"),        # IPv6 Link-Local
]

def validate_host_address(host: str) -> str:
    """Memvalidasi bahwa target host bukan loopback atau cloud metadata."""
    if not host or not host.strip():
        raise ValueError("Host perangkat tidak boleh kosong.")
    h = host.strip().lower()
    if h in ("localhost", "metadata.google.internal", "instance-data", "169.254.169.254"):
        raise ValueError("Target host dilarang (potensi celah SSRF).")
    try:
        resolved_info = socket.getaddrinfo(h, None)
        for res in resolved_info:
            ip_str = res[4][0]
            ip_obj = ipaddress.ip_address(ip_str)
            for subnet in FORBIDDEN_SUBNETS:
                if ip_obj in subnet:
                    raise ValueError(f"Target host '{h}' mengarah ke subnet terlarang ({subnet}).")
    except socket.gaierror:
        raise ValueError(f"Host '{h}' tidak dapat di-resolve atau tidak valid.")
    return h

Kesimpulan

Melalui integrasi arsitektur multi-tenant berlapis, engine virtual lab interaktif, skrip sinkronisasi kurikulum sekolah, dan pipeline kontainerisasi terpadu, platform LMS Vokasi TKJ telah berhasil ditransformasikan menjadi aplikasi siap produksi (production-ready). Audit keamanan aplikasi berbasis metodologi penetration testing membuktikan bahwa mitigasi proaktif terhadap eskalasi hak akses, perlindungan transaksi finansial, isolasi CORS, penyaringan SSRF, serta pengetatan otentikasi JWT secara efektif mengamankan platform dari berbagai vektor serangan modern, menjadikannya andal untuk diterapkan di lingkungan institusi pendidikan kejuruan maupun komersial.