Appearance
Chapter 5 - Ownership dan Restrictions
Ikhtisar Chapter
Chapter ini melengkapi sistem autentikasi dengan lapisan otorisasi. User tidak cukup hanya bisa login; setiap data task harus benar-benar terisolasi antar user. Mulai dari relasi database, penanda kepemilikan, serialisasi data sensitif, hingga pembatasan operasi CRUD berdasarkan pemilik task.
Peta Cepat
FokusRelasi database, kepemilikan data, serialisasi, otorisasi per-operasi
Masalah yang DiselesaikanUser tidak boleh membaca atau memodifikasi task milik user lain
Hasil AkhirSetiap endpoint task dibatasi ketat berdasarkan identitas user yang login
Gambaran Besar
Setelah chapter 4 membangun sistem login, pertanyaan berikutnya adalah: siapa pemilik data ini? Bayangkan dua user mendaftar lalu sama-sama membuat task. Tanpa otorisasi, user A bisa melihat, mengubah, bahkan menghapus task milik user B. Itu jelas tidak boleh terjadi.
Chapter ini menjawab masalah tersebut dari level database sampai level serialisasi respons. Pertama, relasi antara User dan Task didefinisikan di database. Kemudian setiap operasi CRUD membawa konteks user yang sedang login, dan query dikunci agar hanya mengembalikan data milik user tersebut. Terakhir, ada satu detail keamanan penting: data password tidak boleh ikut tampil di respons API, meski ada di objek task yang diambil dari database.
Alur Kepemilikan Data
text
Request masuk (dengan JWT valid)
|
v
AuthGuard memvalidasi token
|
v
@GetUser decorator mengambil user dari request
|
v
Controller meneruskan objek user ke service
|
v
Service meneruskan objek user ke repository
|
v
Repository menambah WHERE user = :userId pada query
|
v
Hanya task milik user tersebut yang dikembalikan
|
v
TransformInterceptor menghapus field sensitif dari responsRelasi One-to-Many dan Many-to-One
PostgreSQL adalah relational database — bukan sekadar tempat menyimpan baris data, tapi juga dapat mendefinisikan hubungan antar tabel. Di sini digunakan relasi:
- Satu user memiliki banyak task →
@OneToManypada entitasUser - Satu task dimiliki oleh satu user →
@ManyToOnepada entitasTask
Konsep eager loading juga muncul di sini. Ketika eager: true dipasang pada sisi relasi, TypeORM secara otomatis memuat data relasi tersebut tanpa perlu query tambahan. Di sini, eager: true dipasang pada sisi user di dalam task, artinya setiap kali task diambil dari database, objek user pemiliknya ikut termuat.
typescript
// user.entity.ts
@OneToMany((_type) => Task, (task) => task.user, { eager: false })
tasks: Task[]
// task.entity.ts
@ManyToOne((_type) => User, (user) => user.tasks, { eager: true })
user: UserStruktur Direktori
Chapter ini tidak menambahkan file baru — melainkan mempererat dua entity yang sudah ada dan memastikan setiap query membawa konteks user:
src/
├── auth/
│ ├── dto/
│ │ └── auth-credentials.dto.ts
│ ├── auth.controller.ts
│ ├── auth.module.ts ← DIUBAH: exports: [PassportModule]
│ ├── auth.service.ts
│ ├── get-user.decorator.ts
│ ├── jwt-payload.interface.ts
│ ├── jwt.strategy.ts
│ └── user.entity.ts ← DIUBAH: + @OneToMany ke Task
└── tasks/
├── dto/
│ ├── create-task.dto.ts
│ ├── get-tasks-filter.dto.ts
│ └── update-task-status.dto.ts
├── task.entity.ts ← DIUBAH: + @ManyToOne ke User
│ + @Exclude() pada field user.password
├── task-status.enum.ts
├── tasks.controller.ts ← DIUBAH: semua handler terima @GetUser()
├── tasks.module.ts
├── tasks.repository.ts ← DIUBAH: query dikunci per user
└── tasks.service.ts ← DIUBAH: user diteruskan ke repositoryAlur Relasi
user.entity.ts task.entity.ts
────────────── ──────────────
id ◄──────────────────── userId (FK otomatis dari TypeORM)
tasks[] ────────────► userTypeORM secara otomatis membuat kolom userId di tabel task ketika @ManyToOne didefinisikan.
Ringkasan Lecture
1. Tasks and Users — Database Relation
Relasi antara User dan Task didefinisikan menggunakan decorator TypeORM. Sisi User mendapat @OneToMany dengan eager: false, artinya daftar task tidak ikut dimuat saat user diambil. Sisi Task mendapat @ManyToOne dengan eager: true, artinya data user pemilik selalu ikut ketika task diambil dari database. TypeORM secara otomatis membuat kolom userId sebagai foreign key di tabel task.
typescript
// src/auth/user.entity.ts
import { OneToMany } from 'typeorm'
import { Task } from '../tasks/task.entity'
@Entity()
@Unique(['username'])
export class User {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
username: string
@Column()
password: string
@OneToMany((_type) => Task, (task) => task.user, { eager: false })
// eager: false — daftar task TIDAK otomatis dimuat saat user diambil
tasks: Task[]
}typescript
// src/tasks/task.entity.ts
import { ManyToOne } from 'typeorm'
import { User } from '../auth/user.entity'
import { Exclude } from 'class-transformer'
@Entity()
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
title: string
@Column()
description: string
@Column()
status: TaskStatus
@ManyToOne((_type) => User, (user) => user.tasks, { eager: true })
// eager: true — data user SELALU ikut dimuat saat task diambil
user: User
}Hasilnya di database: tabel task akan memiliki kolom userId yang berisi UUID dari user pemilik task tersebut.
2. Make Users Own Tasks
Agar setiap task yang dibuat terasosiasi dengan pembuatnya, objek user diteruskan dari controller ke service lalu ke repository. Di repository, properti user pada task diisi dengan objek user tersebut sebelum disimpan. TypeORM mengurus penyimpanan userId di database secara otomatis.
Decorator @GetUser yang dibuat di chapter 4 dipakai kembali di sini untuk mengekstrak user dari request.
typescript
// tasks.controller.ts — handler createTask
createTask(
@Body() createTaskDto: CreateTaskDto,
@GetUser() user: User,
): Promise<Task> {
return this.tasksService.createTask(createTaskDto, user)
}3. Serialize User Data
Masalah muncul: karena eager: true, objek user (termasuk password hash) ikut tampil di dalam respons task. Ini harus dihilangkan sebelum data dikirim ke client.
Solusinya menggunakan class-transformer. Properti user di entitas Task diberi decorator @Exclude({ toPlainOnly: true }) agar tidak muncul saat objek dikonversi ke plain JSON. Agar dekorator ini aktif, sebuah TransformInterceptor global dipasang di main.ts.
typescript
// task.entity.ts
@Exclude({ toPlainOnly: true })
@ManyToOne((_type) => User, (user) => user.tasks, { eager: true })
user: Usertypescript
// main.ts
app.useGlobalInterceptors(new TransformInterceptor())4. Restricting Getting All Tasks
Tanpa pembatasan, GET /tasks mengembalikan semua task dari semua user. Untuk memperbaiki ini, user yang login ditambahkan sebagai kriteria WHERE pada query TypeORM.
Pertama, controller meneruskan user ke service:
typescript
// src/tasks/tasks.controller.ts
@Get()
getTasks(
@Query() filterDto: GetTasksFilterDto,
@GetUser() user: User, // ambil user dari JWT
): Promise<Task[]> {
this.logger.verbose(`User "${user.username}" retrieving all tasks`)
return this.tasksService.getTasks(filterDto, user)
}Kemudian service meneruskan ke repository:
typescript
// src/tasks/tasks.service.ts
async getTasks(filterDto: GetTasksFilterDto, user: User): Promise<Task[]> {
return this.tasksRepository.getTasks(filterDto, user)
}Di repository, klausa WHERE user = :userId ditambahkan sebagai kondisi pertama sebelum filter lainnya:
typescript
// src/tasks/tasks.repository.ts
async getTasks(filterDto: GetTasksFilterDto, user: User): Promise<Task[]> {
const { status, search } = filterDto
const query = this.createQueryBuilder('task')
// kunci pertama: hanya task milik user ini
query.where({ user })
if (status) {
query.andWhere('task.status = :status', { status })
}
if (search) {
query.andWhere(
'(LOWER(task.title) LIKE LOWER(:search) OR LOWER(task.description) LIKE LOWER(:search))',
{ search: `%${search}%` },
)
}
return query.getMany()
}query.where({ user }) adalah shorthand TypeORM yang setara dengan query.where('task.userId = :userId', { userId: user.id }). Setelah ini, user A tidak akan pernah melihat task milik user B.
5. BUG FIX — Filtering Antar User
Ditemukan bug: ketika filter search aktif bersamaan dengan filter status, query menghasilkan OR yang tidak terbatas sehingga task dari user lain bisa ikut muncul. Penyebabnya adalah klausa WHERE yang tidak dikelompokkan dengan benar.
Perbaikannya sederhana: bungkus semua kondisi search dalam satu pasang tanda kurung di query builder agar AND dan OR dievaluasi dengan benar.
typescript
// tasks.repository.ts (fix)
query.andWhere(
'(task.title LIKE :search OR task.description LIKE :search)',
{ search: `%${filterDto.search}%` },
)6. Restricting Getting a Task By ID
GET /tasks/:id sebelumnya hanya mencari berdasarkan id. Sekarang pencarian dilakukan berdasarkan kombinasi id dan user. Jika task dengan id tersebut tidak dimiliki oleh user yang login, TypeORM tidak menemukan baris — dan service melempar NotFoundException. Ini sengaja dipilih daripada ForbiddenException agar tidak membocorkan keberadaan task kepada pihak yang tidak berhak.
7. Restricting Status Updates
PATCH /tasks/:id/status cukup menggunakan kembali getTaskById yang sudah dibatasi per-user. Karena user disertakan dalam pencarian, update otomatis gagal jika user mencoba mengubah task yang bukan miliknya.
8. Restricting Deleting A Task
DELETE /tasks/:id menggunakan kriteria gabungan { id, user } saat memanggil delete() dari repository. Jika task tidak ditemukan dengan kombinasi tersebut, NotFoundException dikembalikan.
Konsep Kunci
| Konsep | Penjelasan | Lokasi |
|---|---|---|
@OneToMany | Satu user memiliki banyak task | user.entity.ts |
@ManyToOne | Satu task dimiliki satu user | task.entity.ts |
eager: true | Relasi dimuat otomatis saat query | task.entity.ts |
@Exclude | Menyembunyikan field dari respons JSON | task.entity.ts |
TransformInterceptor | Mengaktifkan serialisasi class-transformer | main.ts |
@GetUser | Decorator custom untuk ambil user dari JWT | auth/ |
userId WHERE clause | Pembatasan query per user | tasks.repository.ts |
NotFoundException | Respons 404 untuk task tidak ditemukan / bukan milik user | tasks.service.ts |
Pola Query dengan Pembatasan User
typescript
// tasks.repository.ts
async getTasks(filterDto: GetTasksFilterDto, user: User): Promise<Task[]> {
const { status, search } = filterDto
const query = this.createQueryBuilder('task')
query.where({ user })
if (status) {
query.andWhere('task.status = :status', { status })
}
if (search) {
query.andWhere(
'(task.title LIKE :search OR task.description LIKE :search)',
{ search: `%${search}%` },
)
}
return query.getMany()
}Serialisasi: Dari Database ke JSON
text
Database row (ada kolom userId, user object)
|
v
TypeORM memuat task + user (eager)
|
v
Class-transformer memeriksa decorator @Exclude
|
v
Field user dibuang dari plain object
|
v
JSON response dikirim ke client (bersih)Jebakan Umum
Hati-hati dengan eager loading
eager: true nyaman tapi bisa boros performa jika entitas memiliki banyak relasi. Selalu pertimbangkan apakah data relasi benar-benar dibutuhkan di setiap query.
OR tanpa tanda kurung = bug tersembunyi
Query WHERE a = 1 AND b LIKE x OR c LIKE x bisa menghasilkan hasil yang tidak terduga. Selalu bungkus kondisi OR dalam tanda kurung untuk memastikan evaluasi logika benar.
Jangan kembalikan data user di respons task
Meskipun data user tersedia di objek task (dari eager loading), selalu gunakan @Exclude atau select field terbatas agar password hash tidak bocor.
404 lebih aman dari 403 untuk data orang lain
Mengembalikan NotFoundException alih-alih ForbiddenException tidak membocorkan informasi apakah resource tersebut ada. Ini mencegah attacker memetakan data yang dimiliki user lain.
Checklist Implementasi
- [ ] Tambah
@OneToManypadauser.entity.tsdenganeager: false - [ ] Tambah
@ManyToOnepadatask.entity.tsdenganeager: true - [ ] Tambah
@Exclude({ toPlainOnly: true })pada propertiuserditask.entity.ts - [ ] Daftarkan
TransformInterceptorsecara global dimain.ts - [ ] Tambah parameter
userpadacreateTask,getTasks,getTaskById,updateTaskStatus,deleteTask - [ ] Tambah
WHERE user = :userIdpada semua query repository - [ ] Pastikan filter
searchdibungkus tanda kurung di query builder - [ ] Verifikasi bahwa user lain mendapat
NotFoundExceptionsaat mengakses task yang bukan miliknya
Pertanyaan Reflektif
- Mengapa
eager: truedipasang di sisi task (bukan user), dan apa dampaknya jika dibalik? - Apa perbedaan antara
NotFoundExceptiondanForbiddenExceptiondari sudut pandang keamanan API? - Mengapa serialisasi (menyembunyikan field sensitif) penting meskipun data sudah "aman" di database?
- Jika ada fitur "shared task" di mana satu task bisa dimiliki beberapa user, bagaimana skema relasinya harus berubah?