Loading the catalog…
Loading the catalog…
프로그래밍을 처음 배울 때 권한은 보통 “로그인한 사용자만 특정 기능을 사용할 수 있게 하는 것”이라고 배운다. if (!currentUser) { throw new UnauthorizedError(); } return documentRepository.findById(documentId); 로그인하지 않은 요청을 거부한 뒤 문서를 반환한다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 여부만으로 결정할 수 없는 문제가 생긴다. 로그인한 사용자가 다른 사람의 문서를 읽어도 되는가? 문서를 읽을 수 있는 사용자가 수정하거나 삭제해도 되는가? 같은 조직의 구성원이라면 모든 프로젝트에 접근할 수 있는가? 관리자는 사용자의 비공개 문서까지 볼 수 있는가? 문서 소유자가 바뀌거나 공유가 취소되면 기존 권한은 언제 사라지는가? 브라우저에서 버튼을 숨기면 허용되지 않은 행동을 막을 수 있는가? 목록과 상세 API가 같은 접근 규칙을 적용하는가? 권한은 로그인 여부를 확인하는 기능이 아니다. 권한은 확인된 사용자가 특정 데이터에 특정 행동을 수행할 수 있는지, 소유권·역할·조직·자원 상태·요청 맥락을 바탕으로 결정하는 규칙이다. 로그인은 사용자를 확인하지만 행동을 허용하지는 않는다 크리스가 팀 협업 문서 서비스를 개발한다고 생각해 보자. 사용자는 로그인한 뒤 문서를 만들고, 다른 사람과 공유하고, 댓글을 작성할 수 있다. 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는 막혔지만 검색 결과에서 제목이 노출된다. 전체 개수를 통해 비공개 문서의 존재를 추측할 수 있다. 내보내기 API가 화면보다 더 많은 데이터를 반환한다. 권한 정책은 버튼과 상세 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, } : {}), }; } 이 코드는 문서의 기본 내용만 반환하고, 별도의 권한이 있을 때만 내부 검토 기록을 포함한다. 권한에는 여러 수준이 존재할 수 있다. 수준 예시 자원 수준 문서 자체를 읽을 수 있는가 행동 수준 읽기, 수정, 공유, 삭제 중 무엇을 할 수 있는가 필드 수준 내부 메모나 작성자 이메일을 볼 수 있는가 범위 수준 자신의 문서, 프로젝트 문서, 조직 전체 문서 중 어디까지인가 “문서 읽기 가능”이라는 하나의 불리언으로 모든 데이터 노출 규칙을 표현하기 어려울 수 있다. 권한은 CRUD가 아니라 비즈니스 행동으로 표현하는 편이 낫다 권한 이름을 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; 소유권 이전은 최근 재인증이 필요하고, 법적 보존 상태의 문서는 이전할 수 없다는 규칙이다. 권한은 사용자에게 영구적으로 붙은 스티커가 아니다. 행동이 일어나는 시점의 자원 상태와 맥락에 따라 달라지는 판단이다. 프론트엔드의 버튼 숨김은 사용자 경험이지 보안 경계가 아니다 프론트엔드는 허용되지 않은 행동을
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
권한은 로그인 여부를 확인하는 것이 아니다. 프로그래밍을 처음 배울 때 권한은 보통 “로그인한 사용자만 특정 기능을 사용할 수 있게 하는 것”이라고 배운다. if (!currentUser) { throw new UnauthorizedError(); } return documentRepository.findById(documentId); 로그인하지 않은 요청을 거부한 뒤 문서를 반환한다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 여부만으로 결정할 수 없는 문제가 생긴다. 로그인한 사용자가 다른 사람의 문서를 읽어도 되는가? 문서를 읽을 수 있는 사용자가 수정하거나 삭제해도 되는가? 같은 조직의 구성원이라면 모든 프로젝트에 접근할 수 있는가? 관리자는 사용자의 비공개…
Open source