RBAC dengan Claude Code: authentication, authorization, tenant, test, dan audit log
Bangun RBAC aman dengan Claude Code: permission model, tenant boundary, Express middleware, test negatif, audit log, dan batas menuju ABAC.
RBAC tidak otomatis aman hanya karena aplikasi punya role bernama admin, editor, dan viewer. Banyak bug authorization muncul dari celah kecil: menganggap user yang sudah login pasti boleh melakukan apa saja, lupa menambahkan filter tenant_id pada query, hanya menyembunyikan tombol di UI tetapi endpoint tetap terbuka, atau membiarkan editor mengubah object milik orang lain dengan ID yang ditebak.
Artikel ini memecah RBAC menjadi pekerjaan yang jelas untuk Claude Code. Kita akan membedakan authentication dan authorization, mendesain role / permission / resource / action, menerapkan deny by default, menjaga tenant boundary, menambah object-level authorization, Express middleware, database schema, test, audit log, dan kapan RBAC perlu bergeser ke ABAC. Untuk login dan token, baca panduan JWT authentication Claude Code. Untuk pengamanan operasional, lengkapi dengan best practice security Claude Code.
Referensi tepercaya yang dipakai di sini adalah OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC, dan Auth0 Core RBAC. OWASP menekankan least privilege, deny by default, validasi permission di setiap request, logging, dan test authorization.
Authentication berbeda dari authorization
Authentication menjawab “siapa user ini”. Password, SSO, JWT, session cookie, dan MFA ada di lapisan ini. Authorization menjawab “apakah actor ini boleh melakukan action ini pada resource ini”. Fakta bahwa user sudah login tidak berarti ia boleh menghapus invoice, mengundang admin, atau membaca data customer lain.
Dalam RBAC, empat istilah harus eksplisit:
| Elemen | Contoh | Pertanyaan review |
|---|---|---|
| role | viewer, editor, billing_admin, owner | Apakah berdasarkan tanggung jawab kerja |
| permission | article:update, invoice:read | Apakah didefinisikan di satu tempat |
| resource | article, invoice, user | Object apa yang dilindungi |
| action | read, create, update, delete | Apakah lebih jelas dari HTTP method |
Kesalahan umum adalah membuat satu role admin yang sangat besar, lalu menumpuk pengecualian. Saat review, sulit membedakan pengecualian yang memang kebutuhan produk dengan bypass sementara. Lebih baik definisikan permission lebih dulu, lalu perlakukan role sebagai kumpulan permission.
flowchart TD
A["Authenticated actor"] --> B["Tenant boundary"]
B --> C["Role to permission map"]
C --> D["Resource and action check"]
D --> E["Object-level rule"]
E --> F["Allow"]
B --> X["Deny and audit"]
C --> X
D --> X
E --> X
Mulai dari deny by default
Deny by default berarti semua operasi ditolak kecuali kode secara eksplisit mengizinkannya. Saat meminta Claude Code mengubah authorization, tuliskan aturan penolakan lebih dulu: permission tidak dikenal, role kosong, tenant tidak cocok, resource hilang, dan owner object tidak cocok harus gagal secara tertutup.
Contoh Express berikut bisa disalin ke project baru. Demo membaca identitas dari header agar test mudah dipahami. Di produksi, ganti bagian itu dengan JWT atau session yang sudah diverifikasi.
{
"name": "rbac-express-demo",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"test": "vitest run"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.15.0",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
Inti RBAC di TypeScript
Jangan menyebar pengecekan role ke semua controller. Letakkan keputusan di fungsi authorize, lalu route hanya mengirim nama permission. Dengan begitu area review lebih kecil dan Claude Code punya titik perubahan yang jelas saat resource baru ditambahkan.
// src/rbac.ts
export const permissions = [
"project:read",
"article:read",
"article:create",
"article:update",
"article:delete",
"invoice:read",
"invoice:refund",
"user:manage"
] as const;
export type Permission = (typeof permissions)[number];
export type Role = "viewer" | "editor" | "billing_admin" | "owner";
export type Actor = {
id: string;
tenantId: string;
roles: Role[];
};
export type ResourceRecord = {
id: string;
tenantId: string;
ownerId?: string;
};
export type AuthorizationDecision = {
allow: boolean;
reason: string;
};
export const rolePermissions = {
viewer: ["project:read", "article:read", "invoice:read"],
editor: ["project:read", "article:read", "article:create", "article:update"],
billing_admin: ["project:read", "invoice:read", "invoice:refund"],
owner: [...permissions]
} as const satisfies Record<Role, readonly Permission[]>;
const knownPermissions = new Set<Permission>(permissions);
export function authorize(
actor: Actor,
permission: Permission,
record?: ResourceRecord
): AuthorizationDecision {
if (!knownPermissions.has(permission)) {
return { allow: false, reason: "unknown_permission" };
}
if (actor.roles.length === 0) {
return { allow: false, reason: "no_role" };
}
if (record && record.tenantId !== actor.tenantId) {
return { allow: false, reason: "tenant_mismatch" };
}
const roleAllows = actor.roles.some((role) =>
rolePermissions[role].includes(permission)
);
if (!roleAllows) {
return { allow: false, reason: "role_missing_permission" };
}
if (
permission === "article:update" &&
!actor.roles.includes("owner") &&
record?.ownerId !== actor.id
) {
return { allow: false, reason: "not_resource_owner" };
}
return { allow: true, reason: "allowed" };
}
export function auditAuthorization(input: {
actor?: Actor;
permission: Permission;
resourceId?: string;
decision: AuthorizationDecision;
}) {
console.info(
JSON.stringify({
type: "authorization",
actorId: input.actor?.id ?? "anonymous",
tenantId: input.actor?.tenantId ?? "unknown",
permission: input.permission,
resourceId: input.resourceId ?? null,
allow: input.decision.allow,
reason: input.decision.reason,
at: new Date().toISOString()
})
);
}
Route meminta article:update, bukan sekadar bertanya “apakah user ini editor”. Role hanya satu input; tenant boundary dan aturan owner object tetap harus diperiksa.
Express middleware untuk setiap request
Authorization harus berjalan sebelum handler mengubah data. Middleware membuat route yang dilindungi terlihat jelas dan mengurangi risiko ada endpoint yang lupa dicek.
// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import {
type Actor,
type Permission,
type ResourceRecord,
type Role,
auditAuthorization,
authorize,
rolePermissions
} from "./rbac.js";
declare global {
namespace Express {
interface Request {
actor?: Actor;
}
}
}
type Article = ResourceRecord & {
title: string;
body: string;
};
const articles: Article[] = [
{ id: "a1", tenantId: "tenant-a", ownerId: "user-1", title: "Roadmap", body: "Draft" },
{ id: "a2", tenantId: "tenant-a", ownerId: "user-2", title: "Release", body: "Ready" },
{ id: "b1", tenantId: "tenant-b", ownerId: "user-9", title: "Private", body: "Secret" }
];
function parseRoles(value: string | undefined): Role[] {
return (value ?? "")
.split(",")
.map((role) => role.trim())
.filter((role): role is Role => role in rolePermissions);
}
function authenticateForDemo(req: Request, _res: Response, next: NextFunction) {
const userId = req.header("x-user-id");
const tenantId = req.header("x-tenant-id");
if (userId && tenantId) {
req.actor = {
id: userId,
tenantId,
roles: parseRoles(req.header("x-roles"))
};
}
next();
}
function findArticle(req: Request): Article | undefined {
return articles.find((article) => article.id === req.params.articleId);
}
function requirePermission(
permission: Permission,
loadResource?: (req: Request) => ResourceRecord | undefined
) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.actor) {
return res.status(401).json({ error: "unauthenticated" });
}
const record = loadResource?.(req);
if (loadResource && !record) {
return res.status(404).json({ error: "not_found" });
}
const decision = authorize(req.actor, permission, record);
auditAuthorization({
actor: req.actor,
permission,
resourceId: record?.id,
decision
});
if (!decision.allow) {
return res.status(403).json({ error: "forbidden", reason: decision.reason });
}
return next();
};
}
export const app = express();
app.use(express.json());
app.use(authenticateForDemo);
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get(
"/articles/:articleId",
requirePermission("article:read", findArticle),
(req, res) => {
res.json(findArticle(req));
}
);
app.patch(
"/articles/:articleId",
requirePermission("article:update", findArticle),
(req, res) => {
const article = findArticle(req);
if (!article) return res.status(404).json({ error: "not_found" });
article.title = String(req.body.title ?? article.title);
article.body = String(req.body.body ?? article.body);
return res.json(article);
}
);
app.delete(
"/articles/:articleId",
requirePermission("article:delete", findArticle),
(req, res) => {
const index = articles.findIndex((article) => article.id === req.params.articleId);
if (index >= 0) articles.splice(index, 1);
return res.status(204).send();
}
);
app.get("/admin/users", requirePermission("user:manage"), (_req, res) => {
res.json([{ id: "user-1" }, { id: "user-2" }]);
});
if (process.env.NODE_ENV !== "test") {
app.listen(3000, () => {
console.log("RBAC demo listening on http://localhost:3000");
});
}
Di produksi, authenticateForDemo harus diganti dengan sumber identity yang sudah diverifikasi. Walaupun identity provider mengirim role atau permission claims, API tetap perlu memeriksa tenant dan aturan object di server.
Database schema harus membawa tenant boundary
Bug authorization bisa terjadi sebelum middleware: query mengambil row yang salah, lalu handler menganggapnya aman. Karena itu tenant_id, constraint unik gabungan, dan audit log harus terlihat di schema.
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
UNIQUE (tenant_id, email)
);
CREATE TABLE roles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
UNIQUE (resource, action)
);
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role_id TEXT NOT NULL REFERENCES roles(id),
tenant_id TEXT NOT NULL REFERENCES tenants(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id),
permission_id TEXT NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE articles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE authorization_audit_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT,
actor_id TEXT,
permission TEXT NOT NULL,
resource_id TEXT,
allowed BOOLEAN NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_tenant_owner ON articles(tenant_id, owner_id);
CREATE INDEX idx_audit_tenant_created ON authorization_audit_logs(tenant_id, created_at);
Untuk SaaS B2B, custom role per tenant sering dibutuhkan, sehingga tenant_id + name sebagai unique biasanya tepat. Untuk sistem yang sangat regulatif, role global yang tetap dan assignment lokal bisa lebih mudah diaudit.
Test harus kuat di jalur penolakan
Test RBAC memang harus membuktikan request yang boleh akan sukses, tetapi lebih penting membuktikan request yang harus ditolak benar-benar gagal: role sama tetapi tenant berbeda, tenant sama tetapi owner berbeda, user login tanpa permission, resource tidak ada, dan request tanpa authentication.
// test/rbac.test.ts
import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/server.js";
const editorUser1 = {
"x-user-id": "user-1",
"x-tenant-id": "tenant-a",
"x-roles": "editor"
};
const editorTenantB = {
"x-user-id": "user-9",
"x-tenant-id": "tenant-b",
"x-roles": "editor"
};
const owner = {
"x-user-id": "owner-1",
"x-tenant-id": "tenant-a",
"x-roles": "owner"
};
describe("RBAC middleware", () => {
it("rejects unauthenticated requests", async () => {
const res = await request(app).get("/articles/a1");
expect(res.status).toBe(401);
});
it("allows an editor to update an owned article in the same tenant", async () => {
const res = await request(app)
.patch("/articles/a1")
.set(editorUser1)
.send({ title: "Updated roadmap" });
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated roadmap");
});
it("blocks cross-tenant access even when the role matches", async () => {
const res = await request(app).get("/articles/a1").set(editorTenantB);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("tenant_mismatch");
});
it("blocks editors from managing users", async () => {
const res = await request(app).get("/admin/users").set(editorUser1);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("role_missing_permission");
});
it("allows owners to delete tenant resources", async () => {
const res = await request(app).delete("/articles/a2").set(owner);
expect(res.status).toBe(204);
});
});
npm install
npm test
npm run dev
Saat meminta Claude Code menambah test, sebutkan tenant_mismatch, not_resource_owner, role_missing_permission, dan unauthenticated. Reason yang dikirim ke client harus sama dengan reason di audit log agar debugging production tidak menjadi tebak-tebakan.
Empat use case nyata
Use case pertama adalah project management B2B. Owner boleh mengundang member dan mengubah billing, sedangkan editor hanya mengubah konten. Tenant boundary lebih penting daripada nama role. projectId yang ditebak harus gagal di query dan middleware.
Use case kedua adalah CMS internal. Editor boleh mengubah draft sendiri, tetapi menghapus artikel yang sudah publish atau mengubah artikel penulis lain perlu role lebih kuat. Ini adalah object-level authorization di atas RBAC, karena article:update juga bergantung pada ownerId dan status.
Use case ketiga adalah billing dan refund. billing_admin boleh membaca invoice, tetapi refund bisa bergantung pada limit nominal, status approval, atau second reviewer. RBAC membuka pintu; atribut bisnis menentukan apakah action spesifik aman.
Use case keempat adalah support impersonation. Tim support boleh melihat workspace customer untuk debugging, tetapi tidak boleh mengubah email, payment method, atau owner role. Semua aksi impersonation harus dicatat terpisah dari aksi customer.
Contoh kegagalan dan perbaikannya
| Kegagalan | Risiko | Perbaikan |
|---|---|---|
| Mengembalikan semua record untuk user login | Horizontal privilege escalation | Selalu filter tenant_id dan resource ID |
| Hanya menyembunyikan tombol di UI | API langsung tetap berhasil | Tolak di middleware server |
Satu role admin terlalu besar | Review permission kehilangan makna | Pecah permission dan desain ulang role |
| Hanya log action yang diizinkan | Percobaan abuse tidak terlihat | Log allow dan deny dengan reason |
| Test hanya happy path | Regression membuka celah diam-diam | Otomatiskan matrix penolakan |
UI bukan batas keamanan. Menyembunyikan tombol memperbaiki pengalaman, tetapi perlindungan endpoint harus terjadi di server.
Apa yang sebaiknya didelegasikan ke Claude Code
Claude Code berguna untuk mengubah policy yang jelas menjadi code, test, dan diff yang mudah direview. Ia tidak seharusnya menentukan policy security sendiri. Berikan file, aturan penolakan, dan kriteria review.
Help implement RBAC.
Only change src/rbac.ts, src/server.ts, and test/rbac.test.ts.
Requirements:
- An authenticated actor has userId, tenantId, and roles
- Permissions use the resource:action format
- Unknown permission, empty role list, and tenant mismatch deny by default
- article:update is allowed only for owner role or the article owner
- 403 responses include a reason, and audit logs use the same reason
- Add more denial tests than success tests
Do not:
- Add an admin bypass
- Treat UI visibility as authorization
- Modify the existing authentication flow
Saat review, cek apakah authorize adalah satu-satunya titik keputusan, semua route protected memakai middleware, query object punya batas tenant, audit log tidak membocorkan token atau secret, dan test mencakup kasus penolakan. Jika Claude Code membuat diff besar, pisahkan perubahan authorization dari refactor yang tidak terkait.
Kapan RBAC perlu menjadi ABAC
RBAC bagus untuk tanggung jawab kerja. Ia mulai berat ketika rule bergantung pada atribut: refund di atas batas tertentu perlu dua approval, data pribadi EU hanya boleh dilihat dari region EU, export audit hanya untuk plan Enterprise, atau permission hanya berlaku pada jam kerja.
ABAC adalah Attribute-Based Access Control, yaitu kontrol akses berbasis atribut. Ia mengevaluasi atribut actor, resource, environment, dan request. Cara praktisnya adalah memakai RBAC sebagai gerbang kasar, lalu menambah aturan ABAC di tempat yang membutuhkan aturan bisnis. Walaupun memakai policy engine, permission names, tenant boundary, audit log, dan test harus tetap jelas lebih dulu.
Ringkasan
RBAC yang baik ditentukan oleh perilaku penolakan yang jelas. Pisahkan authentication dari authorization, definisikan permission sebagai resource:action, terapkan deny by default, cek tenant boundary, tambahkan object-level rule, lalu buktikan dengan test dan audit log. Struktur ini juga memberi Claude Code scope yang aman: implementasikan policy, tambahkan test negatif, dan jaga diff tetap bisa direview.
Untuk flow API penuh, gabungkan artikel ini dengan panduan pengembangan API Claude Code, panduan JWT authentication, dan security best practices. Jika membutuhkan konsultasi, training, atau bantuan implementasi, siapkan tabel role saat ini, daftar API protected, dan operasi paling berbahaya jika sampai terbuka.
Saat mencoba pendekatan ini, verifikasi empat hal: test penolakan lulus, ID dari tenant lain mengembalikan 403, audit log berisi allow dan deny, dan Claude Code tidak menambahkan shortcut yang melewati authorize. Mulai dari satu operasi berisiko, tulis role, permission, resource, action, tenantId, dan ownerId, lalu ubah matrix itu menjadi code.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission receipt Claude Code: mencatat scope, bukti, dan rollback
Pola permission receipt untuk Claude Code: aksi yang diizinkan, batas approval, command verifikasi, rollback, dan cek CTA revenue.
Agent Harness Aman untuk Claude Code dan Codex: Permission, Verifikasi, dan Rollback
Rancang Agent Harness praktis untuk Claude Code dan Codex dengan policy, plan, verification, dan recovery layer.
Subagent Claude Code: panduan praktis untuk delegasi artikel dan kode
Panduan subagent Claude Code untuk membagi pekerjaan artikel dan kode: aturan delegasi, prompt, risiko, dan checklist.