Setiap bug izin terlihat sama dalam retrospeksi. Fungsi tertentu yang jauh di dalam call stack mengasumsikan pemanggil sudah memeriksa isAdmin. Padahal belum. Atau peran baru ditambahkan, dan Anda mencari role === 'editor' di 47 file berharap tidak melewatkan satu pun.

Keamanan berbasis capability memperbaiki ini dengan membuat otoritas eksplisit. Alih-alih bertanya “siapa Anda?” dan mencari izin, Anda memberikan pemanggil sebuah token yang secara harfiah mengkodekan apa yang boleh mereka lakukan. Tidak ada token, tidak ada akses. Sistem tipe dapat menegakkan ini saat compile time.

Apa yang sebenarnya dimaksud dengan keamanan berbasis capability

Capability adalah token yang tidak dapat dipalsukan yang memberikan pemegangnya hak untuk melakukan tindakan tertentu. Istilah ini berasal dari riset sistem operasi yang bermula pada tahun 1960-an, tetapi ide tersebut berlaku sama baiknya untuk kode aplikasi.

Dalam sistem tradisional berbasis peran, pengguna memiliki peran, dan Anda memeriksa peran tersebut pada saat tindakan:

function deleteProject(user: User, projectId: string) {
  if (user.role !== 'admin') {
    throw new UnauthorizedError();
  }
  // ... delete logic
}

Pola ini sederhana sampai tidak lagi. Pemeriksaan tersebut berada jauh dari tempat otoritas awalnya diberikan. Anda berakhir dengan pemeriksaan yang berlebihan, pemeriksaan yang terlupakan, dan logika yang secara implisit bergantung pada pemeriksaan yang dilakukan oleh pemanggil sepuluh frame di atas stack.

Capability membalik model tersebut. Otoritas untuk menghapus proyek menjadi sebuah nilai yang harus Anda miliki bahkan untuk memanggil fungsi tersebut:

function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
  // No check needed. If you have the cap, you have the right.
  // ... delete logic
}

Jika Anda tidak memiliki ProjectDeletionCapability, Anda tidak dapat memanggil fungsi ini. Sistem tipe menyatakan demikian.

Mengapa TypeScript cocok

Structural typing pada TypeScript biasanya merupakan fitur, tetapi untuk capability ini adalah bug. Jika ProjectDeletionCapability hanyalah interface dengan field projectId: string, objek apa pun dengan bentuk tersebut akan lolos. Anda memerlukan nominal typing. Anda memerlukan tipe yang tidak dapat dibuat secara tidak sengaja.

Cara paling bersih untuk melakukan ini di TypeScript adalah branded type menggunakan private symbol:

declare const ProjectDeletionCapabilityBrand: unique symbol;

interface ProjectDeletionCapability {
  readonly [ProjectDeletionCapabilityBrand]: true;
  readonly projectId: string;
  readonly grantedAt: Date;
  readonly grantedBy: string;
}

Karena ProjectDeletionCapabilityBrand adalah unique symbol, tidak ada kode di luar module ini yang dapat menghasilkan nilai yang memenuhi interface tersebut. Brand berfungsi sebagai segel saat compile time. Anda dapat membangun capability hanya di dalam module yang memiliki symbol tersebut.

Implementasi konkret

Berikut adalah pola yang berfungsi di kode produksi tanpa mengubah codebase Anda menjadi proyek seni abstrak.

Pertama, definisikan module capability factory. Ini adalah satu-satunya tempat yang dapat mencetak capability baru:

// capabilities.ts
import { randomUUID } from 'crypto';

declare const FileReadCapabilityBrand: unique symbol;
declare const FileWriteCapabilityBrand: unique symbol;

export interface FileReadCapability {
  readonly [FileReadCapabilityBrand]: true;
  readonly fileId: string;
  readonly scope: 'public' | 'private';
}

export interface FileWriteCapability {
  readonly [FileWriteCapabilityBrand]: true;
  readonly fileId: string;
}

// The capability factory. This is the only way to create capabilities.
export function mintFileReadCapability(
  fileId: string,
  scope: 'public' | 'private'
): FileReadCapability {
  return { [FileReadCapabilityBrand]: true, fileId, scope } as FileReadCapability;
}

export function mintFileWriteCapability(fileId: string): FileWriteCapability {
  return { [FileWriteCapabilityBrand]: true, fileId } as FileWriteCapability;
}

Lapisan otorisasi Anda, apa pun itu, mencetak capability setelah memverifikasi klaim pengguna:

// auth.ts
import { mintFileReadCapability, mintFileWriteCapability } from './capabilities';

export async function getCapabilitiesForUser(userId: string, fileId: string) {
  const perms = await db.permissions.find({ userId, fileId });
  const caps = [];

  if (perms.canRead) {
    caps.push(mintFileReadCapability(fileId, perms.scope));
  }
  if (perms.canWrite) {
    caps.push(mintFileWriteCapability(fileId));
  }

  return caps;
}

Fungsi domain Anda mengonsumsi capability secara langsung. Tidak ada ID pengguna, tidak ada pemeriksaan peran, tidak ada pencarian database:

// files.ts
import { FileReadCapability, FileWriteCapability } from './capabilities';

export async function readFile(cap: FileReadCapability): Promise<Buffer> {
  return storage.read(cap.fileId);
}

export async function writeFile(
  cap: FileWriteCapability,
  data: Buffer
): Promise<void> {
  return storage.write(cap.fileId, data);
}

Jika Anda mencoba melewatkan FileReadCapability ke writeFile, TypeScript akan menolak untuk mengompilasi. Error-nya langsung dan lokal. Anda tidak perlu melacak hierarki peran untuk memahami apakah panggilan valid.

Mengkomposisikan capability tanpa kehilangan keamanan

Kode nyata perlu mendelegasikan. Sebuah layanan mungkin memegang beberapa capability dan melewatkan subset ke fungsi pembantu. Anda dapat memodelkan ini dengan intersection type:

function publishDocument(
  readCap: FileReadCapability,
  writeCap: FileWriteCapability,
  docId: string
) {
  const draft = await readFile(readCap);
  const rendered = renderToPDF(draft);
  await writeFile(writeCap, rendered);
  await markPublished(docId);
}

Jika Anda ingin lebih ketat, Anda dapat mendefinisikan composite capability:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
  // ...
}

Kuncinya adalah otoritas mengalir melalui nilai, bukan ambient state. Anda dapat melacak setiap capability kembali ke tempat ia dicetak.

Di mana pola ini gagal

Capability tidak gratis. Setiap operasi yang dilindungi memerlukan nilai capability yang dilewatkan melalui call chain. Dalam aplikasi yang sangat berlapis, ini dapat berarti melewatkan capability melalui lima atau enam fungsi yang tidak menggunakannya secara langsung.

Ada juga masalah revocation. Sebuah capability, setelah dicetak, hanyalah objek JavaScript. Ia hidup sampai garbage collected. Jika Anda perlu mencabut akses segera, misalnya karena pengguna dihapus dari proyek, Anda tidak dapat menghancurkan capability yang sudah beredar. Anda memerlukan out-of-band check, atau Anda perlu membungkus capability dalam proxy yang memvalidasi terhadap ACL live pada setiap penggunaan. Itu memperkenalkan kembali lookup persis yang Anda coba hindari.

Audit logging juga menjadi lebih sulit. Dengan RBAC, Anda mencatat peran pengguna pada saat tindakan. Dengan capability, otoritas mungkin telah diberikan berjam-jam yang lalu oleh layanan yang berbeda. Anda perlu melampirkan metadata ke capability itu sendiri, itulah sebabnya contoh di atas menyertakan field grantedAt dan grantedBy.

Kapan menggunakan capability dan kapan melewatkannya

Gunakan capability ketika Anda memiliki izin yang sangat terperinci dan kontekstual yang mahal untuk dihitung ulang. Seorang pengguna dapat mengedit dokumen tertentu ini sampai hari Jumat. Sebuah layanan dapat membaca dari bucket ini tetapi tidak dari bucket itu. Capability mengkodekan konteks tersebut. Fungsi yang mengonsumsinya tidak perlu mengetahui konteks tersebut ada.

Lewati capability untuk peran global yang kasar. Jika aplikasi Anda memiliki tiga tingkat izin dan tidak pernah bervariasi menurut sumber daya, RBAC lebih sederhana dan lebih mudah diaudit. Jangan biarkan yang sempurna menjadi musuh dari yang cukup aman.

FAQ

Apa perbedaan antara capability dan token?

JWT atau API token membuktikan identitas. Capability membuktikan otoritas untuk tindakan tertentu. Anda dapat menempatkan capability di dalam token, tetapi konsepnya berbeda.

Dapatkah capability bekerja dengan GraphQL atau REST?

Ya. Server mencetak capability setelah mengautentikasi request, lalu melewatkannya ke resolver atau controller. Transport layer tidak perlu berubah.

Bagaimana Anda menyimpan capability?

Biasanya Anda tidak mempersistenkan capability. Anda mempersistenkan aturan yang menentukan apakah capability dapat dicetak. Capability itu sendiri adalah nilai runtime yang berumur pendek.

Apakah ini menggantikan OAuth scopes?

Tidak. OAuth scopes adalah capability yang kasar yang didelegasikan melintasi batas organisasi. Pola ini untuk otoritas yang sangat terperinci di dalam aplikasi Anda sendiri. Keduanya dapat berdampingan.

Dapatkah Anda menyerialisasi capability?

Jika Anda menserialisasi branded type, Anda kehilangan brand saat deserialisasi. Jika Anda perlu melewatkan capability antar proses, tandatangani dengan kunci yang dipercaya penerima, atau gunakan capability server yang memvalidasi dan mencetak ulang.