Claude Code로 RBAC 구현하기: 인증, 인가, 테넌트 경계와 감사 로그
Claude Code로 안전한 RBAC를 설계합니다. 인증과 인가, 권한 모델, Express middleware, 테스트, 테넌트 경계와 감사 로그까지 다룹니다.
RBAC는 admin, editor, viewer 같은 역할 이름을 붙였다고 끝나는 보안 기능이 아닙니다. 실제 사고는 더 작은 빈틈에서 시작됩니다. 로그인한 사용자를 곧바로 신뢰하거나, 특정 DB 조회에서 tenant_id 조건을 빼먹거나, 프런트엔드에서 버튼만 숨기고 API는 그대로 열어 두거나, 편집자가 추측한 ID로 다른 사람의 리소스를 수정하게 두는 경우입니다.
이 글은 Claude Code에 맡기기 좋은 작업 단위로 RBAC를 정리합니다. 인증과 인가의 차이, role / permission / resource / action, deny by default, 테넌트 경계, 객체 단위 인가, Express middleware, DB schema, 테스트, 감사 로그, 그리고 ABAC로 넘어가야 하는 기준까지 다룹니다. 로그인과 토큰 계층은 Claude Code JWT 인증 가이드를, 운영 보안은 Claude Code 보안 모범 사례를 함께 보면 좋습니다.
외부 기준으로는 OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC, Auth0 Core RBAC를 참고할 수 있습니다. OWASP는 최소 권한, 기본 거부, 모든 요청의 권한 검증, 로깅, 인가 테스트를 명확히 강조합니다.
인증과 인가는 다르다
인증은 “이 사용자가 누구인가”를 확인합니다. 비밀번호, SSO, JWT, 세션 쿠키, 다중 인증이 여기에 속합니다. 인가는 “이 사용자가 이 리소스에 이 작업을 해도 되는가”를 판단합니다. 로그인했다는 사실만으로 청구서를 삭제하거나, 다른 고객의 데이터를 보거나, 관리자 초대를 할 수 있어서는 안 됩니다.
RBAC를 설계할 때는 네 가지 단어를 분리해서 써야 합니다.
| 요소 | 예시 | 검토 질문 |
|---|---|---|
| role | viewer, editor, billing_admin, owner | 직무 책임을 기준으로 나누었는가 |
| permission | article:update, invoice:read | 코드 한 곳에서 정의되는가 |
| resource | article, invoice, user | 보호할 대상이 무엇인가 |
| action | read, create, update, delete | HTTP 메서드보다 정확한가 |
흔한 실수는 강력한 admin role을 먼저 만들고 예외를 계속 붙이는 것입니다. 그러면 리뷰할 때 어떤 예외가 제품 요구사항이고 어떤 예외가 임시 우회인지 알기 어렵습니다. permission을 먼저 정의하고 role을 permission 묶음으로 다루면 Claude Code에도 요구사항을 설명하기 쉽고 테스트도 단순해집니다.
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
deny by default를 먼저 고정한다
deny by default는 명시적으로 허용하지 않은 작업을 모두 거부한다는 원칙입니다. RBAC 구현에서 가장 먼저 고정해야 할 것은 편리한 관리자 우회가 아니라 이 거부 규칙입니다. Claude Code에 작업을 맡길 때도 알 수 없는 permission, 빈 role, 테넌트 불일치, 리소스 없음, 객체 소유자 불일치는 모두 실패해야 한다고 명시합니다.
아래 예시는 그대로 복사해서 실행할 수 있는 Express 구성입니다. 데모에서는 테스트가 쉽도록 header에서 사용자 정보를 읽지만, 실제 서비스에서는 검증된 JWT나 session 결과로 바꿔야 합니다.
{
"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"]
}
TypeScript로 RBAC 핵심 작성하기
role 확인을 여러 controller에 흩뿌리면 어떤 API가 어떤 권한에 의존하는지 추적하기 어렵습니다. 결정은 authorize 함수에 모으고, route는 permission 이름만 넘기도록 만듭니다. 그래야 새 resource를 추가할 때 Claude Code의 변경 범위가 작아집니다.
// 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가 “이 사용자가 editor인가?”가 아니라 article:update를 요청한다는 것입니다. role은 하나의 입력일 뿐이고, 테넌트 경계와 객체 소유자 규칙은 별도로 적용됩니다.
Express middleware로 모든 요청을 검사한다
인가 검사는 handler가 데이터를 바꾸기 전에 실행되어야 합니다. OWASP의 권고처럼 보호 대상 요청마다 권한을 확인해야 하며, middleware는 그 위치를 눈에 보이게 만듭니다.
// 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");
});
}
실서비스에서는 authenticateForDemo를 검증된 인증 결과로 교체합니다. IdP가 role이나 permission claim을 보내더라도 API 서버에서 테넌트와 객체 단위 규칙을 다시 확인해야 합니다.
DB schema에도 테넌트 경계를 넣는다
인가 버그는 middleware 전에 발생하기도 합니다. 잘못된 행을 조회한 뒤 handler가 안전하다고 가정하는 경우입니다. schema에 tenant_id, 복합 unique 제약, 감사 로그를 명시하면 리뷰할 위치가 분명해집니다.
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);
B2B SaaS에서는 테넌트별 커스텀 role이 필요할 수 있어 tenant_id + name unique가 자연스럽습니다. 반대로 규제가 강한 환경에서는 전역 role 정의를 고정하고 테넌트 안에서는 배정만 허용하는 방식이 감사에 유리합니다.
성공보다 거부 테스트를 두껍게 만든다
RBAC 테스트는 정상 동작을 확인해야 하지만, 더 중요한 것은 거부되어야 할 요청이 실제로 실패하는지입니다. 같은 role이지만 다른 테넌트, 같은 테넌트지만 다른 owner, 로그인했지만 permission 부족, 없는 리소스, 미인증 요청을 반드시 넣습니다.
// 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
Claude Code에 테스트 추가를 맡길 때는 tenant_mismatch, not_resource_owner, role_missing_permission, unauthenticated를 명시합니다. 클라이언트에 반환되는 reason과 감사 로그의 reason이 같아야 운영 중 원인 추적이 쉬워집니다.
실제 유스케이스 네 가지
첫 번째는 B2B 프로젝트 관리입니다. owner는 멤버 초대와 결제를 관리하지만 editor는 콘텐츠만 수정합니다. 여기서는 role 이름보다 테넌트 경계가 중요합니다. 공격자가 projectId를 추측해도 DB 조회와 middleware 모두에서 거부되어야 합니다.
두 번째는 사내 CMS입니다. 편집자는 자신의 초안은 수정할 수 있지만, 게시된 글 삭제나 다른 작성자의 글 수정은 더 강한 role이 필요합니다. 이 경우 article:update라도 ownerId와 상태를 함께 보는 객체 단위 인가가 필요합니다.
세 번째는 청구와 환불입니다. billing_admin은 invoice를 읽을 수 있지만, 환불은 금액 제한, 승인 상태, 이중 승인 같은 속성이 필요합니다. RBAC가 입구를 좁히고, 비즈니스 속성이 최종 허용 여부를 결정합니다.
네 번째는 고객 지원의 대리 접근입니다. 지원 담당자는 문제 해결을 위해 워크스페이스를 볼 수 있지만, 이메일, 결제 수단, owner role을 바꾸면 안 됩니다. 모든 대리 작업은 사용자 본인의 작업과 구분해 감사 로그에 남겨야 합니다.
실패 사례와 수정 방향
| 실패 사례 | 위험 | 수정 |
|---|---|---|
| 로그인 사용자에게 모든 레코드를 반환 | 수평 권한 상승 | 매번 tenant_id와 resource ID로 제한 |
| UI에서만 버튼 숨김 | API 직접 호출은 여전히 성공 | 서버 middleware에서 인가 |
거대한 admin role 하나 | 권한 리뷰가 무의미해짐 | permission 분해 후 role 재설계 |
| 성공 로그만 기록 | 공격 탐지가 어려움 | 허용과 거부를 reason과 함께 기록 |
| 성공 테스트만 작성 | 회귀로 조용히 구멍이 생김 | 거부 케이스를 매트릭스로 자동화 |
프런트엔드의 표시 제어는 보안 경계가 아닙니다. 삭제 버튼을 숨기는 것은 UX 개선일 뿐이고, 실제 보호는 API의 article:delete 검사에서 이루어져야 합니다.
Claude Code에 맡길 수 있는 범위
Claude Code는 명확한 정책을 코드와 테스트로 옮기는 데 유용합니다. 하지만 보안 정책 자체를 스스로 정하게 해서는 안 됩니다. 파일 범위, 거부 규칙, 리뷰 기준을 함께 줍니다.
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
리뷰할 때는 authorize가 유일한 결정 지점인지, 보호된 route가 middleware를 통과하는지, 객체 조회에 테넌트 조건이 있는지, 감사 로그에 token이나 민감정보가 없는지, 거부 테스트가 충분한지 확인합니다. Claude Code가 큰 diff를 만들면 인가 변경과 무관한 리팩터링을 분리한 뒤 리뷰합니다.
ABAC로 넘어가야 하는 시점
RBAC는 직무 책임을 표현하기 좋습니다. 하지만 조건이 속성에 의존하기 시작하면 role이 폭발합니다. 예를 들어 큰 금액의 환불은 이중 승인이 필요하고, EU 개인정보는 EU 리전에서만 볼 수 있으며, Enterprise 플랜만 감사 로그를 export할 수 있고, 업무 시간 외에는 특정 작업이 금지될 수 있습니다.
ABAC는 Attribute-Based Access Control, 즉 속성 기반 접근 제어입니다. actor, resource, 환경, 요청 속성을 함께 봅니다. 현실적인 접근은 RBAC를 큰 문으로 두고, 비즈니스 조건이 필요한 지점에 ABAC 규칙을 추가하는 것입니다. Casbin 같은 정책 엔진을 쓰더라도 permission 이름, 테넌트 경계, 감사 로그, 테스트가 먼저 명확해야 합니다.
정리
좋은 RBAC는 role 수가 아니라 거부 동작의 명확함으로 결정됩니다. 인증과 인가를 분리하고, permission을 resource:action으로 정의하고, deny by default를 적용하고, 테넌트 경계와 객체 단위 규칙을 middleware에서 확인합니다. 여기에 DB schema, 감사 로그, 실패 테스트를 더하면 Claude Code도 안전한 범위 안에서 작업할 수 있습니다.
전체 API 흐름은 Claude Code API 개발 가이드, JWT 인증 가이드, 보안 모범 사례와 함께 설계하는 것이 좋습니다. 컨설팅, 교육, 구현 지원이 필요하다면 현재 role 표, 보호할 API 목록, 노출되면 가장 위험한 작업을 정리해 두면 첫 리뷰가 훨씬 정확해집니다.
이 글의 내용을 실제로 시험할 때는 네 가지를 확인하세요. 거부 테스트가 통과하는지, 다른 테넌트 ID가 403을 반환하는지, 감사 로그에 allow와 deny가 모두 남는지, Claude Code가 authorize를 우회하는 예외를 만들지 않았는지입니다. 먼저 위험한 작업 하나를 고르고 role, permission, resource, action, tenantId, ownerId를 표로 만든 뒤 코드에 옮기면 빠진 조건을 찾기 쉽습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code Permission Receipt Pattern: 권한, 증거, 롤백을 남기는 운영
Claude Code 작업마다 허용 범위, 승인 경계, 검증 명령, 롤백 메모, Gumroad와 상담 CTA 확인을 남기는 permission receipt 패턴입니다.
Claude Code/Codex 안전 Agent Harness 설계: 권한, 검증, 롤백
Claude Code와 Codex를 안전하게 운영하기 위한 Agent Harness를 권한 정책, 실행 계획, 검증, 복구 계층으로 설계합니다.
Claude Code 서브에이전트 실전 가이드: 기사와 코드 작업을 안전하게 위임하기
Claude Code 서브에이전트로 기사와 코드 작업을 안전하게 나누는 방법. 위임 규칙, 프롬프트, 실패 사례를 정리합니다.