Skip to content

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 respons

Relasi 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 → @OneToMany pada entitas User
  • Satu task dimiliki oleh satu user → @ManyToOne pada entitas Task

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: User

Struktur 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 repository

Alur Relasi

user.entity.ts          task.entity.ts
──────────────          ──────────────
id ◄──────────────────── userId  (FK otomatis dari TypeORM)
tasks[]  ────────────► user

TypeORM 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: User
typescript
// 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

KonsepPenjelasanLokasi
@OneToManySatu user memiliki banyak taskuser.entity.ts
@ManyToOneSatu task dimiliki satu usertask.entity.ts
eager: trueRelasi dimuat otomatis saat querytask.entity.ts
@ExcludeMenyembunyikan field dari respons JSONtask.entity.ts
TransformInterceptorMengaktifkan serialisasi class-transformermain.ts
@GetUserDecorator custom untuk ambil user dari JWTauth/
userId WHERE clausePembatasan query per usertasks.repository.ts
NotFoundExceptionRespons 404 untuk task tidak ditemukan / bukan milik usertasks.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 @OneToMany pada user.entity.ts dengan eager: false
  • [ ] Tambah @ManyToOne pada task.entity.ts dengan eager: true
  • [ ] Tambah @Exclude({ toPlainOnly: true }) pada properti user di task.entity.ts
  • [ ] Daftarkan TransformInterceptor secara global di main.ts
  • [ ] Tambah parameter user pada createTask, getTasks, getTaskById, updateTaskStatus, deleteTask
  • [ ] Tambah WHERE user = :userId pada semua query repository
  • [ ] Pastikan filter search dibungkus tanda kurung di query builder
  • [ ] Verifikasi bahwa user lain mendapat NotFoundException saat mengakses task yang bukan miliknya

Pertanyaan Reflektif

  1. Mengapa eager: true dipasang di sisi task (bukan user), dan apa dampaknya jika dibalik?
  2. Apa perbedaan antara NotFoundException dan ForbiddenException dari sudut pandang keamanan API?
  3. Mengapa serialisasi (menyembunyikan field sensitif) penting meskipun data sudah "aman" di database?
  4. Jika ada fitur "shared task" di mana satu task bisa dimiliki beberapa user, bagaimana skema relasinya harus berubah?