โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 5 ยท Membership, Auth & Paywall

Tier langganan & peran redaksi

Portalmu punya dua pertanyaan otorisasi yang sama sekali berbeda: boleh membaca apa, dan boleh mengubah apa. Mencampurnya adalah sumber lubang izin yang paling umum.

Sumber asli owasp.org Artikel Rangkuman ~8 menit baca

Intisari

  • Tier menjawab "boleh baca apa"; peran menjawab "boleh ubah apa". Pisahkan keduanya.
  • Keputusan izin tinggal di satu fungsi, bukan tersebar sebagai if di halaman.
  • Batasi operasi tulis lewat WHERE, bukan lewat if โ€” kepemilikan jadi bagian dari kueri.
  • Tier kedaluwarsa itu keadaan normal, bukan kesalahan. Rancang masa tenggang sejak awal.
  • Default harus menolak. Peran yang tidak dikenal tidak boleh berarti akses penuh.

Dua sistem, satu aplikasi

Tier langgananPeran
MenjawabBoleh membaca apaBoleh mengubah apa
Contoh nilaigratis, dasar, propembaca, penulis, editor, admin
Berubah karenaPembayaranKeputusan manusia
Bisa kedaluwarsaYaTidak
Diperiksa diServer island paywallAction & endpoint mutasi

Ini dua kolom terpisah di tabel member, bukan satu. Menggabungkannya jadi satu kolom level terasa hemat sampai kamu perlu seorang editor yang juga berlangganan tier pro, atau admin yang langganannya kedaluwarsa. Dua pertanyaan berbeda butuh dua jawaban terpisah.

Tier: satu fungsi, satu tempat

// src/lib/tier.ts
export type Tier = "gratis" | "dasar" | "pro";

const URUTAN: Record<Tier, number> = { gratis: 0, dasar: 1, pro: 2 };

const MASA_TENGGANG_HARI = 3;

export function tierEfektif(m: {
  tier: Tier;
  langgananSampai: Date | null;
}): Tier {
  if (m.tier === "gratis") return "gratis";
  if (!m.langgananSampai) return "gratis";

  const batas = new Date(m.langgananSampai);
  batas.setDate(batas.getDate() + MASA_TENGGANG_HARI);

  return new Date() <= batas ? m.tier : "gratis";
}

export function tierCukup(punya: Tier, butuh: Tier): boolean {
  return URUTAN[punya] >= URUTAN[butuh];
}

Masa tenggang tiga hari itu keputusan bisnis yang menghemat banyak tiket dukungan. Pembayaran berulang gagal karena hal-hal sepele: kartu kedaluwarsa, saldo kurang sesaat, gangguan di sisi gateway. Memutus akses tepat pada detik langganan berakhir berarti pelanggan yang membayar tiap bulan selama dua tahun tiba-tiba diperlakukan seperti orang asing โ€” dan menghubungi dukungan dengan marah. Tiga hari cukup untuk percobaan penagihan ulang berhasil.

Peran: default menolak

// src/lib/izin.ts
export type Peran = "pembaca" | "penulis" | "editor" | "admin";

export type Aksi =
  | "artikel:buat" | "artikel:ubah-sendiri" | "artikel:ubah-semua"
  | "artikel:terbitkan" | "artikel:hapus"
  | "member:lihat" | "member:ubah";

const IZIN: Record<Peran, Aksi[]> = {
  pembaca: [],
  penulis: ["artikel:buat", "artikel:ubah-sendiri"],
  editor: [
    "artikel:buat", "artikel:ubah-sendiri", "artikel:ubah-semua",
    "artikel:terbitkan", "member:lihat",
  ],
  admin: [
    "artikel:buat", "artikel:ubah-sendiri", "artikel:ubah-semua",
    "artikel:terbitkan", "artikel:hapus", "member:lihat", "member:ubah",
  ],
};

export function boleh(peran: Peran | undefined, aksi: Aksi): boolean {
  if (!peran) return false;              // tidak login โ†’ tidak boleh
  return IZIN[peran]?.includes(aksi) ?? false;   // peran asing โ†’ tidak boleh
}

Dua ?? false itu yang membuat sistemnya aman secara bawaan. Kalau suatu saat ada baris di database dengan peran "superadmin" yang tidak pernah kamu definisikan โ€” hasil migrasi data yang tidak sempurna, misalnya โ€” ia mendapat nol izin, bukan semuanya.

Menegakkannya di action

export const terbitkanArtikel = defineAction({
  input: z.object({ id: z.number().int().positive() }),
  handler: async ({ id }, ctx) => {
    const member = ctx.locals.member;

    if (!boleh(member?.peran, "artikel:terbitkan")) {
      throw new ActionError({ code: "FORBIDDEN", message: "Tidak berwenang." });
    }

    const hasil = await dbTulis
      .updateTable("artikel")
      .set({ status: "terbit", terbit_pada: new Date() })
      .where("id", "=", id)
      .where("status", "=", "tinjau")
      .executeTakeFirstOrThrow();

    if (hasil.numUpdatedRows === 0n) {
      throw new ActionError({ code: "CONFLICT", message: "Artikel tidak dalam status tinjau." });
    }

    await purgeCacheTag(`artikel-${id}`);   // Fase 8
    return { ok: true };
  },
});

Kepemilikan lewat WHERE

export const ubahArtikel = defineAction({
  input: z.object({ id: z.number().int().positive(), judul: z.string().min(3) }),
  handler: async ({ id, judul }, ctx) => {
    const m = ctx.locals.member;

    if (!boleh(m?.peran, "artikel:ubah-sendiri")) {
      throw new ActionError({ code: "FORBIDDEN" });
    }

    let q = dbTulis.updateTable("artikel").set({ judul }).where("id", "=", id);

    // Penulis biasa hanya bisa menyentuh miliknya sendiri.
    if (!boleh(m!.peran, "artikel:ubah-semua")) {
      q = q.where("penulis_id", "=", m!.id);
    }

    const hasil = await q.executeTakeFirstOrThrow();

    if (hasil.numUpdatedRows === 0n) {
      // Sengaja tidak membedakan "tidak ada" dan "bukan milikmu" โ€”
      // membedakannya membocorkan keberadaan artikel orang lain.
      throw new ActionError({ code: "NOT_FOUND" });
    }
    return { ok: true };
  },
});

Pola ini menghapus seluruh kelas bug otorisasi. Artikel milik orang lain tidak pernah ditemukan, jadi tidak mungkin tersunting โ€” bahkan kalau seseorang nanti menghapus pemeriksaan boleh() di atasnya. Pertahanan yang menempel di kueri tidak bisa lupa dipasang.

Tier di paywall

export async function cekHak(member, artikel, โ€ฆ): Promise<Hak> {
  const punya = member ? tierEfektif(member) : "gratis";

  if (tierCukup(punya, artikel.tierMinimal)) {
    return { boleh: true, alasan: "langganan" };
  }
  // โ€ฆ lanjut ke paywall terukur
}

Kolom artikel.tierMinimal memberi redaksi kendali per artikel: laporan mendalam bisa pro, artikel harian gratis, analisis pasar dasar. Tanpa kolom itu, satu-satunya pilihan adalah premium atau tidak โ€” dan model bisnismu terkunci di sana.

Yang harus dicatat

PeristiwaKenapa dicatat
Perubahan peranSiapa menaikkan siapa jadi admin, dan kapan
Artikel diterbitkan / ditarikJejak redaksional; sering diminta saat ada sengketa
Perubahan tier di luar pembayaranPemberian manual harus punya alasan tertulis
Percobaan akses yang ditolakPola berulang berarti ada yang menyelidiki sistemmu

Latihan: implementasikan tierEfektif, boleh(), dan action ubahArtikel dengan pembatasan lewat WHERE. Lalu uji tiga hal: penulis A tidak bisa menyunting artikel penulis B (dan mendapat 404, bukan 403); member dengan langgananSampai kemarin masih berhak hari ini karena masa tenggang; dan member dengan peran "entah-apa" di database mendapat nol izin.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.