프로그래밍을 처음 배울 때 권한은 보통 “로그인한 사용자만 특정 기능을 사용할 수 있게 하는 것”이라고 배운다.
if (!currentUser) {
throw new UnauthorizedError();
}
return documentRepository.findById(documentId);
로그인하지 않은 요청을 거부한 뒤 문서를 반환한다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 여부만으로 결정할 수 없는 문제가 생긴다.
권한은 로그인 여부를 확인하는 기능이 아니다.
권한은 확인된 사용자가 특정 데이터에 특정 행동을 수행할 수 있는지, 소유권·역할·조직·자원 상태·요청 맥락을 바탕으로 결정하는 규칙이다.
크리스가 팀 협업 문서 서비스를 개발한다고 생각해 보자.
사용자는 로그인한 뒤 문서를 만들고, 다른 사람과 공유하고, 댓글을 작성할 수 있다.
type CurrentUser = {
id: string;
email: string;
};
async function getDocument(
documentId: string,
currentUser: CurrentUser
) {
return documentRepository.findById(documentId);
}
이 함수는 currentUser가 존재하므로 요청자가 로그인했다는 사실은 알고 있다. 그러나 그 사용자가 해당 문서를 읽을 수 있는지는 확인하지 않는다.
문서 URL을 우연히 알거나 다른 사용자의 요청에서 ID를 얻으면 접근할 수 있다.
GET /api/documents/8ef73a6d-865c-47d7-82c9-b422fc8866d2
문서 ID가 UUID처럼 추측하기 어려운 값이어도 권한이 생기지는 않는다. ID를 알고 있다는 사실은 접근할 수 있다는 증거가 아니다.
인증과 권한은 서로 다른 질문에 답한다.
| 단계 | 질문 |
|---|---|
| 인증 | 요청한 사용자는 누구인가? |
| 권한 | 그 사용자는 이 데이터에 이 행동을 할 수 있는가? |
먼저 인증된 사용자를 확인하고, 그다음 요청한 행동을 허용할지 판단해야 한다.
async function getDocument(
documentId: string,
currentUser: CurrentUser
) {
const document =
await documentRepository.findById(documentId);
if (!document) {
throw new DocumentNotFoundError();
}
const canRead = await authorizationService.can({
actor: currentUser,
action: "document:read",
resource: document,
});
if (!canRead) {
throw new DocumentNotFoundError();
}
return document;
}
이 코드는 문서가 존재한다는 사실과 현재 사용자가 읽을 수 있다는 사실을 따로 확인한다.
권한이 없다면 ForbiddenError 대신 DocumentNotFoundError를 반환할 수도 있다. 다른 조직의 문서가 존재한다는 사실 자체를 노출하지 않기 위한 정책이다.
권한 코드는 종종 사용자 역할 하나만 확인하는 형태로 시작한다.
if (currentUser.role === "admin") {
return document;
}
하지만 admin이라는 문자열만으로는 무엇을 어디까지 허용하는지 알기 어렵다.
권한 판단은 최소한 다음 요소를 구분해야 한다.
type AuthorizationInput = {
actor: CurrentUser;
action:
| "document:read"
| "document:update"
| "document:delete"
| "document:share";
resource: Document;
context: {
workspaceId: string;
now: Date;
};
};
각 값은 서로 다른 질문을 표현한다.
actor는 행동을 시도하는 주체다.action은 수행하려는 구체적인 행동이다.resource는 행동의 대상이다.context는 조직, 시간, 요청 경로처럼 판단에 필요한 주변 정보다.이를 짧게 표현하면 다음과 같다.
누가(actor)
무엇에(resource)
어떤 행동을(action)
어떤 상황에서(context)
수행하려 하는가
“로그인한 사용자에게 허용한다”는 규칙은 주체만 확인한다. 실제 권한은 네 요소가 만나는 지점에서 결정된다.
문서 작성자만 문서를 수정할 수 있다고 가정해 보자.
다음 코드는 로그인한 사용자의 역할만 확인한다.
function canUpdateDocument(
currentUser: CurrentUser
) {
return currentUser.role === "member";
}
이 규칙을 사용하면 조직의 모든 일반 구성원이 다른 사용자의 문서까지 수정할 수 있다.
문서 소유권을 함께 확인해야 한다.
function canUpdateDocument(
currentUser: CurrentUser,
document: Document
) {
return document.ownerId === currentUser.id;
}
ownerId와 currentUser.id가 같을 때만 수정할 수 있다.
그러나 협업 서비스에서는 소유자 외에도 편집자로 초대된 사용자가 있을 수 있다.
type DocumentMembership = {
documentId: string;
userId: string;
accessLevel: "viewer" | "editor";
};
이 관계를 포함하면 권한 규칙은 다음처럼 확장된다.
function canUpdateDocument({
currentUser,
document,
membership,
}: {
currentUser: CurrentUser;
document: Document;
membership: DocumentMembership | null;
}) {
return (
document.ownerId === currentUser.id ||
membership?.accessLevel === "editor"
);
}
문서를 수정할 수 있는 사용자는 소유자이거나 편집자로 공유받은 사용자다.
소유권은 사용자 객체 안에 고정된 역할이 아니다. 특정 사용자와 특정 데이터 사이의 관계다.
같은 사용자가 한 문서에서는 소유자이고, 다른 문서에서는 편집자이며, 또 다른 문서에는 아무 권한도 없을 수 있다.
역할 기반 권한은 여러 행동을 관리하기 쉽게 만든다.
type WorkspaceRole =
| "owner"
| "admin"
| "member"
| "guest";
const rolePermissions: Record<
WorkspaceRole,
string[]
> = {
owner: [
"workspace:manage",
"member:invite",
"document:create",
],
admin: [
"member:invite",
"document:create",
],
member: [
"document:create",
],
guest: [],
};
이 매핑은 조직 소유자, 관리자, 구성원, 게스트가 기본적으로 수행할 수 있는 행동을 설명한다.
function roleAllows(
role: WorkspaceRole,
permission: string
) {
return rolePermissions[role].includes(permission);
}
역할을 통해 반복되는 기본 권한을 한곳에서 관리할 수 있다.
하지만 역할만으로 자원별 권한을 모두 표현하기는 어렵다.
조직 관리자가 다음 행동을 할 수 있다고 가정해 보자.
그렇다고 모든 구성원의 개인 문서를 자동으로 읽을 수 있어야 하는 것은 아니다.
function canReadDocument({
membership,
document,
documentAccess,
}: {
membership: WorkspaceMembership;
document: Document;
documentAccess: DocumentMembership | null;
}) {
if (
membership.workspaceId !== document.workspaceId
) {
return false;
}
if (document.visibility === "workspace") {
return true;
}
return (
document.ownerId === membership.userId ||
documentAccess !== null
);
}
먼저 같은 조직인지 확인하고, 조직 공개 문서라면 접근을 허용한다. 비공개 문서는 소유권이나 명시적인 공유 관계가 있어야 한다.
역할은 권한 판단의 입력 중 하나다. 조직, 소유권, 공유 관계, 자원 상태를 대신하는 전역 답이 아니다.
여러 회사가 하나의 서비스를 사용하는 구조에서는 조직 경계가 중요하다.
type Document = {
id: string;
workspaceId: string;
ownerId: string;
title: string;
};
문서는 특정 워크스페이스에 속한다.
다음 조회는 문서 ID만 사용한다.
const document =
await documentRepository.findById(documentId);
문서 ID가 외부에 노출되면 다른 워크스페이스의 문서를 불러올 수 있다.
현재 사용자가 접근할 수 있는 조직 범위를 쿼리에 포함하는 편이 낫다.
const document =
await documentRepository.findOne({
id: documentId,
workspaceId: currentMembership.workspaceId,
});
이제 다른 워크스페이스의 문서는 현재 조회 범위에 들어오지 않는다.
요청으로 전달된 workspaceId를 그대로 신뢰해서는 안 된다.
const workspaceId = request.params.workspaceId;
외부 입력은 검증 전까지 신뢰할 수 없다. 형식이 올바른지 검증한 뒤, 현재 사용자에게 실제로 해당 조직의 멤버십이 있는지 확인해야 한다.
import { z } from "zod";
const WorkspaceIdSchema = z.string().uuid();
const workspaceId = WorkspaceIdSchema.parse(
request.params.workspaceId
);
const membership =
await membershipRepository.findActive({
workspaceId,
userId: currentUser.id,
});
if (!membership) {
throw new WorkspaceNotFoundError();
}
형식 검증은 UUID 모양을 확인한다. 멤버십 조회는 사용자가 해당 조직 범위에 들어갈 수 있는지 확인한다.
다중 조직 서비스에서 권한은 마지막 if 문 하나로만 적용되는 기능이 아니다. 데이터가 조회되는 범위 자체에 조직 경계가 반영되어야 한다.
상세 API에서 권한을 검사하더라도 목록 API가 모든 문서를 반환하면 정보가 노출된다.
const documents =
await documentRepository.findAll({
workspaceId,
});
이 코드는 비공개 문서까지 모두 불러온 뒤 프론트엔드에 전달할 수 있다.
화면에서 비공개 문서를 숨기는 방식도 충분하지 않다.
{document.canRead && (
<DocumentCard document={document} />
)}
이미 응답에 제목과 작성자 정보가 포함되었다면 렌더링하지 않아도 데이터는 브라우저에 도착해 있다.
목록 조회 단계에서 접근 가능한 데이터만 선택해야 한다.
const documents =
await documentRepository.findVisibleToUser({
workspaceId,
userId: currentUser.id,
});
저장소의 조회 규칙은 개념적으로 다음 조건을 표현할 수 있다.
SELECT d.*
FROM documents d
LEFT JOIN document_memberships dm
ON dm.document_id = d.id
AND dm.user_id = $2
WHERE d.workspace_id = $1
AND (
d.visibility = 'workspace'
OR d.owner_id = $2
OR dm.user_id IS NOT NULL
);
같은 워크스페이스 안에서 조직 공개 문서, 사용자가 소유한 문서, 명시적으로 공유받은 문서만 반환한다.
목록 권한과 상세 권한이 서로 다른 규칙으로 따로 구현되면 다음 문제가 생길 수 있다.
권한 정책은 버튼과 상세 API뿐 아니라 목록, 검색, 통계, 내보내기에도 일관되게 적용되어야 한다.
같은 문서를 읽을 수 있어도 사용자마다 볼 수 있는 필드가 다를 수 있다.
type Document = {
id: string;
title: string;
content: string;
ownerId: string;
workspaceId: string;
internalReviewNotes: string | null;
deletedAt: Date | null;
};
일반 구성원에게 내부 검토 기록이나 삭제 정보를 그대로 반환할 필요는 없다.
return document;
도메인 객체를 응답으로 바로 반환하면 저장된 필드가 API에 우연히 노출될 수 있다.
권한에 맞는 응답 모델을 구성하는 편이 낫다.
function toDocumentResponse({
document,
canViewInternalNotes,
}: {
document: Document;
canViewInternalNotes: boolean;
}) {
return {
id: document.id,
title: document.title,
content: document.content,
...(canViewInternalNotes
? {
internalReviewNotes:
document.internalReviewNotes,
}
: {}),
};
}
이 코드는 문서의 기본 내용만 반환하고, 별도의 권한이 있을 때만 내부 검토 기록을 포함한다.
권한에는 여러 수준이 존재할 수 있다.
| 수준 | 예시 |
|---|---|
| 자원 수준 | 문서 자체를 읽을 수 있는가 |
| 행동 수준 | 읽기, 수정, 공유, 삭제 중 무엇을 할 수 있는가 |
| 필드 수준 | 내부 메모나 작성자 이메일을 볼 수 있는가 |
| 범위 수준 | 자신의 문서, 프로젝트 문서, 조직 전체 문서 중 어디까지인가 |
“문서 읽기 가능”이라는 하나의 불리언으로 모든 데이터 노출 규칙을 표현하기 어려울 수 있다.
권한 이름을 read, write, delete로만 정하면 실제 서비스의 의미가 사라질 수 있다.
type Permission =
| "read"
| "write"
| "delete";
write가 문서 본문 수정, 소유자 변경, 외부 공유, 게시 상태 변경을 모두 의미하면 지나치게 넓은 권한이 된다.
비즈니스 행동을 구체적으로 표현할 수 있다.
type DocumentAction =
| "document:read"
| "document:update_content"
| "document:rename"
| "document:share"
| "document:transfer_ownership"
| "document:archive"
| "document:delete";
이제 각 행동에 서로 다른 규칙을 적용할 수 있다.
function canPerformDocumentAction({
actor,
action,
document,
access,
}: {
actor: CurrentUser;
action: DocumentAction;
document: Document;
access: DocumentMembership | null;
}) {
const isOwner = document.ownerId === actor.id;
const isEditor = access?.accessLevel === "editor";
switch (action) {
case "document:read":
return isOwner || access !== null;
case "document:update_content":
case "document:rename":
return isOwner || isEditor;
case "document:share":
case "document:transfer_ownership":
case "document:delete":
return isOwner;
case "document:archive":
return isOwner || isEditor;
}
}
편집자는 내용 수정과 보관은 할 수 있지만 소유권 이전이나 삭제는 할 수 없다.
권한을 비즈니스 행동의 언어로 표현하면 역할이 같더라도 행동마다 다른 위험과 책임을 반영할 수 있다.
사용자와 문서의 관계가 같아도 문서 상태에 따라 허용되는 행동은 달라질 수 있다.
type DocumentStatus =
| "draft"
| "in_review"
| "approved"
| "archived";
편집자는 초안 문서를 수정할 수 있지만 승인된 문서를 바로 변경할 수 없다고 가정해 보자.
function canUpdateContent({
actor,
document,
access,
}: {
actor: CurrentUser;
document: Document;
access: DocumentMembership | null;
}) {
const canEdit =
document.ownerId === actor.id ||
access?.accessLevel === "editor";
return (
canEdit &&
document.status === "draft"
);
}
이 코드는 사용자 관계와 문서 상태를 함께 검사한다.
승인된 문서는 별도의 변경 요청 절차를 거쳐야 할 수 있다.
if (document.status === "approved") {
return changeRequestService.create({
documentId: document.id,
requestedBy: currentUser.id,
proposedContent,
});
}
같은 사용자가 같은 데이터에 접근하더라도 현재 상태에 따라 직접 수정 대신 변경 요청만 허용된다.
시간과 보안 조건도 맥락이 될 수 있다.
const canTransferOwnership =
isOwner &&
session.recentlyAuthenticated &&
!document.isUnderLegalHold;
소유권 이전은 최근 재인증이 필요하고, 법적 보존 상태의 문서는 이전할 수 없다는 규칙이다.
권한은 사용자에게 영구적으로 붙은 스티커가 아니다. 행동이 일어나는 시점의 자원 상태와 맥락에 따라 달라지는 판단이다.
프론트엔드는 허용되지 않은 행동을 숨겨 사용자가 실행할 수 있는 기능을 명확하게 보여줄 수 있다.
{permissions.canDelete && (
<button onClick={deleteDocument}>
문서 삭제
</button>
)}
이 코드는 삭제 권한이 없는 사용자에게 버튼을 보여주지 않는다.
하지만 사용자는 브라우저 개발자 도구나 직접 만든 요청으로 API를 호출할 수 있다.
DELETE /api/documents/8ef73a6d-865c-47d7-82c9-b422fc8866d2
따라서 서버에서 동일한 권한을 반드시 다시 확인해야 한다.
async function deleteDocument(
documentId: string,
currentUser: CurrentUser
) {
const document =
await documentRepository.findById(documentId);
if (!document) {
throw new DocumentNotFoundError();
}
await authorizationService.assertCan({
actor: currentUser,
action: "document:delete",
resource: document,
});
await documentRepository.delete(document.id);
}
프론트엔드 권한은 화면을 이해하기 쉽게 만든다. 서버 권한은 실제 데이터 변경을 통제한다.
두 위치에서 같은 판단이 필요하다고 해서 정책을 서로 다른 방식으로 복사하면 규칙이 어긋날 수 있다. 서버를 최종 권한 판단의 기준으로 두고, 프론트엔드에는 필요한 기능 정보를 제한적으로 제공하는 방식을 사용할 수 있다.
type DocumentCapabilities = {
canEdit: boolean;
canShare: boolean;
canDelete: boolean;
};
이 값은 화면 표현을 돕는 계산 결과다. 영구적인 Source of Truth는 아니다. 실제 요청이 들어오면 서버는 최신 데이터로 다시 판단해야 한다.
새로운 행동이 추가되었는데 권한 코드가 이를 알지 못하는 상황을 생각해 보자.
function canPerform(
action: string
) {
if (action === "document:read") {
return true;
}
return true;
}
알 수 없는 행동을 기본 허용하면 새 기능이 추가될 때 권한 검사가 빠져도 실행될 수 있다.
기본적으로 거부하는 편이 안전하다.
function canPerformDocumentAction(
input: AuthorizationInput
) {
switch (input.action) {
case "document:read":
return canReadDocument(input);
case "document:update":
return canUpdateDocument(input);
case "document:delete":
return canDeleteDocument(input);
default:
return false;
}
}
명시적으로 허용한 행동만 실행된다.
TypeScript의 유니온 타입과 완전성 검사를 사용하면 새 행동을 추가했을 때 빠진 분기를 발견하기 쉬워진다.
function assertNever(value: never): never {
throw new Error(
`Unhandled authorization action: ${value}`
);
}
기본 거부는 모든 사용자를 의심한다는 의미가 아니다. 정의되지 않은 상황에서는 데이터 보호를 우선한다는 설계 원칙이다.
외부 협력자가 특정 프로젝트의 문서를 검토해야 한다고 생각해 보자.
간단한 방법은 조직 구성원 역할을 부여하는 것이다.
await membershipRepository.create({
userId: reviewerId,
workspaceId,
role: "member",
});
하지만 member 역할에 문서 생성, 구성원 조회, 여러 프로젝트 접근 권한이 포함되어 있다면 검토에 필요하지 않은 권한까지 제공한다.
프로젝트나 문서 범위에 제한된 접근을 부여할 수 있다.
await documentMembershipRepository.create({
documentId,
userId: reviewerId,
accessLevel: "viewer",
expiresAt: reviewDeadline,
});
이 권한은 하나의 문서 조회에만 사용되고 검토 기한 이후 만료된다.
최소 권한을 설계할 때는 다음 범위를 함께 살펴봐야 한다.
최소 권한은 역할 이름을 많이 만드는 작업이 아니다. 업무에 필요한 행동·데이터·시간 범위를 넘지 않도록 신뢰를 제한하는 일이다.
액세스 토큰에 조직 역할을 포함할 수 있다.
type AccessTokenClaims = {
subject: string;
workspaceRole: "admin" | "member";
expiresAt: number;
};
크리스가 관리자에서 일반 구성원으로 변경되어도 기존 토큰에는 만료 전까지 admin이 남아 있을 수 있다.
10:00 관리자 토큰 발급
10:05 일반 구성원으로 역할 변경
10:10 기존 토큰은 여전히 admin 주장 포함
10:15 토큰 만료
토큰의 역할은 발급 시점의 스냅숏이다. 현재 권한의 Source of Truth가 아닐 수 있다.
권한 변경을 즉시 반영해야 하는 행동은 현재 멤버십을 다시 조회할 수 있다.
const membership =
await membershipRepository.findActive({
workspaceId: document.workspaceId,
userId: currentUser.id,
});
await authorizationService.assertCan({
actor: currentUser,
action: "document:transfer_ownership",
resource: document,
membership,
});
캐시를 사용할 때도 같은 문제가 생긴다.
const cacheKey =
`permissions:${workspaceId}:${currentUser.id}`;
권한이 변경되면 캐시를 언제 무효화할지 정해야 한다.
| 방식 | 장점 | 주의점 |
|---|---|---|
| 요청마다 현재 상태 조회 | 변경을 빠르게 반영 | 조회 비용 증가 |
| 짧은 캐시 | 반복 조회 비용 감소 | 짧은 지연 동안 오래된 권한 사용 |
| 권한 버전 확인 | 변경 감지 가능 | 버전 관리와 조회 필요 |
| 짧은 토큰 만료 | 구현이 비교적 단순 | 만료 전까지 기존 주장 유지 |
| 중요 행동만 재조회 | 비용과 위험을 구분 | 행동별 정책 관리 필요 |
어떤 방식을 선택하든 권한 변경이 언제 효력을 갖는지 명시해야 한다.
라우터에서 한 번 검사했다고 해서 이후 모든 코드가 안전한 것은 아니다.
router.delete(
"/documents/:documentId",
requireWorkspaceAdmin,
deleteDocumentHandler
);
이 경로는 워크스페이스 관리자만 통과시킨다. 그러나 같은 삭제 함수를 다른 작업이나 내부 API에서 호출할 수 있다.
await documentService.delete(documentId);
서비스 계층이 호출자의 권한 맥락을 전혀 받지 않으면 우회 경로가 생기기 쉽다.
await documentService.delete({
documentId,
actor: currentUser,
});
서비스는 실제 데이터를 불러온 뒤 행동을 검사한다.
async function deleteDocument({
documentId,
actor,
}: {
documentId: string;
actor: CurrentUser;
}) {
const document =
await documentRepository.findById(documentId);
if (!document) {
throw new DocumentNotFoundError();
}
await authorizationService.assertCan({
actor,
action: "document:delete",
resource: document,
});
await documentRepository.delete(document.id);
}
경로 수준 검사는 불필요한 요청을 일찍 거절하는 데 도움을 준다. 자원 수준 검사는 실제 대상과 사용자의 관계를 확인한다.
중요한 변경 작업은 데이터 변경 지점에 가까운 곳에서 권한 규칙이 빠지지 않도록 설계해야 한다.
문서 접근 권한을 부여하거나 제거하는 행동은 서비스의 보안 상태를 바꾼다.
await documentMembershipRepository.create({
documentId,
userId: invitedUserId,
accessLevel: "editor",
});
이 결과만 저장하면 누가 언제 편집 권한을 부여했는지 알기 어렵다.
감사 이벤트를 함께 남길 수 있다.
await auditLog.record({
actorId: currentUser.id,
action: "document_access_granted",
resourceType: "document",
resourceId: documentId,
targetUserId: invitedUserId,
metadata: {
accessLevel: "editor",
},
occurredAt: new Date(),
});
감사 기록은 다음 질문에 답하는 데 사용된다.
감사 로그에 문서 본문이나 인증 토큰 같은 민감한 값을 그대로 남겨서는 안 된다. 조사에 필요한 식별자와 변경 내용을 최소한으로 기록해야 한다.
권한 결정과 감사 기록은 서로 다른 책임이다. 권한 검사는 행동을 허용할지 결정하고, 감사 기록은 중요한 결정과 변경을 나중에 추적할 수 있게 한다.
문서 수정 흐름을 정리하면 다음과 같다.
flowchart TD
A[요청과 인증 정보] --> B[사용자 인증]
B --> C[조직과 문서 조회]
C --> D[주체·행동·대상·상태로 권한 판단]
D -->|허용| E[비즈니스 규칙 검증과 변경]
D -->|거부| F[정보 노출을 제한한 오류]
각 단계는 별도의 책임을 가진다.
인증, 권한, 입력 검증, 비즈니스 규칙은 서로 연결되지만 같은 검사가 아니다.
예를 들어 사용자가 편집 권한을 가지고 있어도 빈 제목으로 문서를 저장할 수 있다는 뜻은 아니다.
const UpdateDocumentSchema = z.object({
title: z.string().trim().min(1).max(120),
content: z.string().max(100_000),
});
const input = UpdateDocumentSchema.parse(
request.body
);
입력 형식을 검증한 뒤 권한과 자원 상태를 확인하고, 그다음 변경을 수행해야 한다.
write 권한으로 묶지 않았는가?canEdit 값이 오래되어도 서버가 안전하게 거부하는가?인증은 사용자의 신원을 확인할 뿐 특정 문서에 대한 권리를 부여하지 않는다.
소유권, 조직 범위, 공유 관계를 함께 확인해야 한다.
사용자는 API를 직접 호출할 수 있다.
화면 제어와 별개로 서버가 실제 행동을 검사해야 한다.
같은 관리자가 모든 조직과 모든 비공개 데이터에 접근할 수 있는 것은 아니다.
역할이 유효한 범위와 자원 관계를 함께 확인해야 한다.
사용자는 다른 조직의 ID를 전달할 수 있다.
형식을 검증하고 현재 사용자의 활성 멤버십으로 조직 범위를 확인해야 한다.
목록, 검색, 개수, 내보내기를 통해 비공개 데이터의 존재나 내용이 노출될 수 있다.
모든 데이터 접근 경로에 같은 정책을 적용해야 한다.
write 권한 하나에 모든 변경 행동을 넣는다본문 수정과 문서 삭제, 외부 공유, 소유권 이전은 위험 수준이 다르다.
실제 비즈니스 행동에 맞춰 권한을 나누어야 한다.
토큰 주장은 발급 시점의 스냅숏일 수 있다.
권한 변경 반영 시점과 현재 상태 재조회 정책을 정해야 한다.
새 기능에 권한 분기가 빠졌을 때 의도하지 않은 접근이 발생한다.
명시적으로 허용되지 않은 행동은 기본적으로 거부해야 한다.
프로그래밍을 처음 배울 때는 로그인한 사용자만 특정 페이지에 들어갈 수 있게 하는 것으로 권한의 기본 역할을 이해할 수 있다.
if (!currentUser) {
throw new UnauthorizedError();
}
입문 단계에서는 충분한 설명이다.
하지만 실제 서비스에서 권한을 설계할 때는 로그인 여부보다 구체적인 관계와 범위가 중요하다.
권한은 로그인 여부를 확인하는 것이 아니다.
권한은 확인된 사용자가 특정 데이터에 특정 행동을 수행할 수 있는지, 소유권·역할·조직·자원 상태·요청 맥락을 바탕으로 결정하는 규칙이다.