Multi-Tenant

Cara kerja multi-tenant yellow-bank-soal - isolasi data via website_id, header X-Website-ID, model Website.

Multi-Tenant

Model: backend/app/models/website.py Router helper: backend/app/routers/wordpress.py Lihat juga: Integrasi → WordPress Auth, Data → Data Models

Konsep

Satu instance yellow-bank-soal bisa melayani banyak situs WordPress sekaligus. Setiap situs punya:

  • Tryout sendiri (soal, konfigurasi)
  • User sendiri (siswa, admin)
  • Session & jawaban terisolasi
  • Statistik & kalibrasi IRT terpisah

Isolasi total — data situs A tidak pernah bocor ke situs B.

Model: Website

Entitas tenant utama. Setiap baris di tabel websites = satu situs WordPress.

python
class Website(Base):
    __tablename__ = "websites"

    id: Mapped[int]                    # Primary key (auto-increment)
    site_url: Mapped[str]              # URL WordPress, unique
    site_name: Mapped[str]             # Nama display
    created_at: Mapped[datetime]
    updated_at: Mapped[datetime]

    # Relationships (cascade delete-orphan)
    users: Mapped[list["User"]]        # User milik website ini
    tryouts: Mapped[list["Tryout"]]    # Tryout milik website ini

Constraint:

  • site_url unique — tidak boleh ada 2 website dengan URL sama
  • id dipakai sebagai website_id FK di semua tabel lain

Header Wajib: X-Website-ID

Setiap request ke API wajib menyertakan header:

http
X-Website-ID: 1

Helper get_website_id_from_header di router memvalidasi:

python
def get_website_id_from_header(
    x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
    if x_website_id is None:
        raise HTTPException(400, "X-Website-ID header is required")
    try:
        return int(x_website_id)
    except ValueError:
        raise HTTPException(400, "X-Website-ID must be a valid integer")

Pola Isolasi Data

Semua tabel yang berhubungan dengan konten tenant punya kolom website_id:

flowchart TD
    W["Website tenant"]
    W -->|"1:N"| U["User"]
    W -->|"1:N"| T["Tryout"]
    W -->|"1:N"| S["Session"]
    W -->|"1:N"| I["Item"]
    W -->|"1:N"| UA["UserAnswer"]
    W -->|"1:N"| TS["TryoutStats"]
    W -->|"1:N"| AR["AIGenerationRun"]

    T -->|"1:N"| I
    T -->|"1:N"| S
    T -->|"1:1"| TS
    S -->|"1:N"| UA
    I -->|"1:N"| UA

Foreign Key cascade:

  • Hapus Website → cascade delete semua User, Tryout, Session, dll miliknya
  • Pattern: ondelete="CASCADE", onupdate="CASCADE" di semua FK ke websites.id

Pola Query

Setiap query service wajib filter website_id. Contoh di cat_selection.py:

python
query = (
    select(Item)
    .where(
        Item.tryout_id == tryout_id,
        Item.website_id == website_id,    # ← filter wajib
        _servable_item_filter(),
    )
)

Aturan kontributor kode: setiap select() ke tabel tenant-wajib harus include website_id di where. Tanpa itu, data antar tenant bisa bocor.

Composite Unique Constraint

Untuk hindari konflik nama antar tenant, beberapa tabel pakai composite unique:

TabelConstraint
users(wp_user_id, website_id) — user WP sama di website berbeda adalah entitas berbeda
tryouts(tryout_id, website_id) — tryout_id "132380" bisa dipakai website A dan B

Artinya: tryout_id dan wp_user_id tidak harus globally unique — hanya unique per website.

Alur Request Multi-Tenant

sequenceDiagram
    autonumber
    participant Client
    participant API as FastAPI Router
    participant Helper as get_website_id_from_header
    participant Service
    participant DB

    Client->>API: Request + Header X-Website-ID: 1
    API->>Helper: Extract header
    alt Header missing
        Helper-->>API: HTTPException 400
        API-->>Client: 400 Bad Request
    else Header invalid (non-int)
        Helper-->>API: HTTPException 400
        API-->>Client: 400 Bad Request
    else Header valid
        Helper-->>API: website_id = 1
        API->>Service: call with website_id=1
        Service->>DB: SELECT ... WHERE website_id = 1
        DB-->>Service: Data tenant 1 saja
        Service-->>API: Result
        API-->>Client: 200 OK
    end

Menambah Website Baru

Tidak ada endpoint publik untuk ini — dilakukan via admin langsung ke DB atau migration:

sql
INSERT INTO websites (site_url, site_name)
VALUES ('https://example.com', 'Example Site');

Atau via SQLAlchemy:

python
new_website = Website(
    site_url="https://example.com",
    site_name="Example Site",
)
db.add(new_website)
await db.commit()

Setelah dibuat, id website baru dipakai sebagai X-Website-ID di request dari situs tersebut.

Test Multi-Tenant

Untuk verifikasi isolasi di test:

python
async def test_data_isolation(db_session):
    # Setup: 2 websites dengan data masing-masing
    website_a = await create_website(url="https://a.com")
    website_b = await create_website(url="https://b.com")

    await create_user(website_a, wp_user_id="123")
    await create_user(website_b, wp_user_id="123")  # same WP ID, diff website

    # Test: query dari A tidak return data B
    users_a = await get_users(db_session, website_id=website_a.id)
    assert len(users_a) == 1
    assert users_a[0].website_id == website_a.id

    # Test: website_id + wp_user_id composite unique
    with pytest.raises(IntegrityError):
        await create_user(website_a, wp_user_id="123")  # duplicate

Admin Visibility

Admin app menampilkan data per-website (filtered by X-Website-ID dari admin session). Super-admin (role system_admin) bisa melintasi tenant untuk support global.

Lihat juga: Modul → Excel Import/Export yang juga website_id-aware.

Edge Cases

KasusPenanganan
Header X-Website-ID missingHTTP 400 — semua request wajib scope tenant
website_id tidak ada di DBHTTP 404 WebsiteNotFoundError
website_id bukan integerHTTP 400 "must be a valid integer"
User akses data tenant lainTidak mungkin — semua query filter website_id dari header
Token WP valid untuk website_id berbedaverify_wordpress_token cek website_id eksplisit

Performance Consideration

  • Index website_id ada di semua tabel tenant — query filter tidak full scan
  • No cross-tenant JOIN — semua query di-scope ke 1 tenant
  • Connection pooling — 1 DB PostgreSQL, semua tenant share (tanpa DB-per-tenant overhead)

Security Consideration

  • Row-level isolation — aplikasi yang enforce, bukan DB. Bug di query = data bocor antar tenant
  • Audit log wajib capture website_id — untuk追踪 aksi admin lintas tenant
  • Backup/restore per-tenant belum didukung (whole-DB only)

Bacaan Lanjutan

Last updated Jul 25, 2026