모든 권한 버그는 돌이켜볼 때 똑같이 보인다. 호출 스택 깊숙한 어딘가에 있는 함수가 호출자가 이미 isAdmin을 검사했을 것이라고 가정한다. 그러나 그렇지 않았다. 또는 새로운 역할이 추가되면, 47개 파일 전체에서 role === 'editor'를 grep하며 하나라도 놓치지 않았는지 바라며 뒤진다.
능력 기반 보안은 권한을 명시적으로 만들어 이 문제를 해결한다. “누구인가?”라고 묻고 권한을 조회하는 대신, 호출자에게 그들이 할 수 있는 일을 문자 그대로 인코딩한 토큰을 건다. 토큰이 없으면 접근 불가. 타입 시스템이 컴파일 시점에 이를 강제할 수 있다.
능력 기반 보안이 실제로 의미하는 것
능력은 보유자에게 특정 행동을 수행할 권리를 부여하는 위조할 수 없는 토큰이다. 이 용어는 1960년대로 거슬러 올라가는 운영체제 연구에서 유래했지만, 애플리케이션 코드에도 똑같이 적용된다.
전통적인 역할 기반 시스템에서는 사용자가 역할을 가지고, 그 역할을 행동 지점에서 검사한다:
function deleteProject(user: User, projectId: string) {
if (user.role !== 'admin') {
throw new UnauthorizedError();
}
// ... delete logic
}
이 패턴은 간단해 보이다가 어느 순간 그렇지 않게 된다. 검사는 권한이 원래 부여된 곳에서 멀리 떨어져 있다. 중복된 검사, 잊혀진 검사, 스택 위쪽 10프레임 전에 호출자가 수행한 검사에 암묵적으로 의존하는 로직이 생긴다.
능력은 이 모델을 뒤집는다. 프로젝트를 삭제할 권한은 함수를 호출하려면 반드시 소지하고 있어야 하는 값이 된다:
function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
// No check needed. If you have the cap, you have the right.
// ... delete logic
}
ProjectDeletionCapability가 없다면 이 함수를 호출할 수 없다. 타입 시스템이 그렇게 말한다.
TypeScript가 적합한 이유
TypeScript의 구조적 타이핑은 보통 장점이지만, 능력에 대해서는 버그다. ProjectDeletionCapability가 단순히 projectId: string 필드를 가진 인터페이스라면, 그 모양을 가진 어떤 객체도 통과한다. 공칭 타이핑이 필요하다. 우연히 생성될 수 없는 타입이 필요하다.
TypeScript에서 이를 가장 깔끔하게 구현하는 방법은 private symbol을 사용한 브랜디드 타입이다:
declare const ProjectDeletionCapabilityBrand: unique symbol;
interface ProjectDeletionCapability {
readonly [ProjectDeletionCapabilityBrand]: true;
readonly projectId: string;
readonly grantedAt: Date;
readonly grantedBy: string;
}
ProjectDeletionCapabilityBrand가 unique symbol이기 때문에, 이 모듈 외부의 코드는 인터페이스를 만족하는 값을 생성할 수 없다. 브랜드는 컴파일 타임 봉인 역할을 한다. 능력은 해당 symbol을 소유한 모듈 낸에서만 생성할 수 있다.
구체적인 구현
코드베이스를 추상 미술 작품으로 만들지 않으면서도 프로덕션 코드에서 동작하는 패턴을 소개한다.
먼저, 능력 팩토리 모듈을 정의한다. 이곳만이 새로운 능력을 발행할 수 있다:
// 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;
}
인증 계층은 사용자의 클레임을 검증한 후 능력을 발행한다:
// 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;
}
도메인 함수는 능력을 직접 소비한다. 사용자 ID도, 역할 검사도, 데이터베이스 조회도 없다:
// 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);
}
FileReadCapability를 writeFile에 전달하려고 하면 TypeScript는 컴파일을 거부한다. 오류는 즉각적이고 지역적이다. 호출이 유효한지 이해하기 위해 역할 계층을 추적할 필요가 없다.
안전성을 잃지 않고 능력을 조합하기
실제 코드에는 위임이 필요하다. 서비스가 여러 능력을 보유하고 그 중 일부를 헬퍼 함수에 전달할 수 있다. 교차 타입으로 이를 모델링할 수 있다:
function publishDocument(
readCap: FileReadCapability,
writeCap: FileWriteCapability,
docId: string
) {
const draft = await readFile(readCap);
const rendered = renderToPDF(draft);
await writeFile(writeCap, rendered);
await markPublished(docId);
}
더 엄격하게 하고 싶다면 복합 능력을 정의할 수 있다:
interface FileReadWriteCapability
extends FileReadCapability,
FileWriteCapability {}
function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
// ...
}
핵심은 권한이 주변 상태가 아니라 값을 통해 흐른다는 것이다. 모든 능력이 어디에서 발행되었는지 추적할 수 있다.
이 패턴이 무너지는 지점
능력은 공짜가 아니다. 보호되는 모든 연산에는 능력 값이 호출 체인을 통해 전달되어야 한다. 깊게 계층화된 애플리케이션에서는 이것이 능력을 직접 사용하지 않는 다섯 개나 여섯 개의 함수를 통해 능력을 관통해야 할 수도 있다.
취소 문제도 있다. 능력은 한 번 발행되면 그냥 JavaScript 객체다. 가비지 컬렉션될 때까지 살아있다. 사용자가 프로젝트에서 제거되었다는 이유로 접근을 즉시 취소해야 한다면, 이미 전달된 능력을 파괴할 수 없다. 외부 검사가 필요하거나, 능력을 매번 사용할 때 실시간 ACL에 대해 검증하는 프록시로 감싸야 한다. 그러면 피하려 했던 바로 그 조회를 다시 도입하게 된다.
감사 로깅도 더 어려워진다. RBAC에서는 행동 시점에 사용자의 역할을 기록한다. 능력 기반에서는 권한이 몇 시간 전에 다른 서비스에 의해 부여되었을 수도 있다. 능력 자체에 메타데이터를 첨부해야 하며, 이것이 위 예제에 grantedAt과 grantedBy 필드를 포함한 이유다.
능력을 사용할 때와 건너뛸 때
능력은 재계산 비용이 큰 세분화된 맥락적 권한이 있을 때 사용하라. 사용자는 금요일까지 이 특정 문서를 편집할 수 있다. 서비스는 이 버킷에서는 읽을 수 있지만 저 버킷에서는 안 된다. 능력은 그 맥락을 인코딩한다. 이를 소비하는 함수는 그 맥락이 존재한다는 사실을 알 필요가 없다.
느슨한 전역 역할에는 능력을 사용하지 마라. 앱에 세 개의 권한 레벨이 있고 리소스별로 변하지 않는다면 RBAC가 더 간단하고 감사하기 쉽다. 완벽함이 어느 정도 안전한 것의 적이 되게 하지 마라.
FAQ
능력과 토큰의 차이는 무엇인가?
JWT나 API 토큰은 신원을 증명한다. 능력은 특정 행동에 대한 권한을 증명한다. 토큰 안에 능력을 넣을 수 있지만, 개념은 구분된다.
능력은 GraphQL이나 REST에서도 작동하는가?
그렇다. 서버는 요청을 인증한 후 능력을 발행하고, 리졸버나 컨트롤러에 전달한다. 전송 계층은 바뀔 필요가 없다.
능력은 어떻게 저장하는가?
보통 능력 자체를 영속화하지 않는다. 능력이 발행될 수 있는지 결정하는 규칙을 영속화한다. 능력 자체는 단기간의 런타임 값이다.
이것이 OAuth 스코프를 대체하는가?
아니다. OAuth 스코프는 조직 경계를 넘어 위임되는 거친 능력이다. 이 패턴은 자신의 애플리케이션 내부에서 사용하는 세분화된 권한을 위한 것이다. 둘은 공존할 수 있다.
능력을 직렬화할 수 있는가?
브랜디드 타입을 직렬화하면 역직렬화 시 브랜드를 잃는다. 능력을 프로세스 간에 전달해야 한다면, 수신자가 신뢰하는 키로 서명하거나, 능력을 검증하고 다시 발행하는 능력 서버를 사용하라.