권한은 로그인 여부를 확인하는 것이 아니다

vx_developer·1일 전

개발하다가

목록 보기
40/41
post-thumbnail

프로그래밍을 처음 배울 때 권한은 보통 “로그인한 사용자만 특정 기능을 사용할 수 있게 하는 것”이라고 배운다.

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;

소유권 이전은 최근 재인증이 필요하고, 법적 보존 상태의 문서는 이전할 수 없다는 규칙이다.

권한은 사용자에게 영구적으로 붙은 스티커가 아니다. 행동이 일어나는 시점의 자원 상태와 맥락에 따라 달라지는 판단이다.


프론트엔드의 버튼 숨김은 사용자 경험이지 보안 경계가 아니다

프론트엔드는 허용되지 않은 행동을 숨겨 사용자가 실행할 수 있는 기능을 명확하게 보여줄 수 있다.

{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
);

입력 형식을 검증한 뒤 권한과 자원 상태를 확인하고, 그다음 변경을 수행해야 한다.


권한을 설계하기 전에 물어봐야 할 질문

누가 행동하는가

  1. 현재 사용자는 어떤 인증 과정으로 확인되었는가?
  2. 사용자 외에 서비스 계정이나 자동화 작업도 행동 주체가 될 수 있는가?
  3. 해당 사용자의 조직 멤버십은 현재 활성 상태인가?
  4. 요청 본문의 사용자 ID를 현재 사용자로 신뢰하고 있지 않은가?
  5. 민감한 행동에 최근 재인증이 필요한가?

무엇에 대한 권한인가

  1. 권한의 대상은 워크스페이스, 프로젝트, 문서, 댓글 중 무엇인가?
  2. 해당 데이터는 어느 조직에 속하는가?
  3. 사용자는 소유자, 공유 사용자, 참가자 중 어떤 관계인가?
  4. 부모 자원의 권한이 자식 자원에 어떻게 적용되는가?
  5. 삭제되거나 보관된 데이터에도 같은 규칙을 적용하는가?

어떤 행동을 허용하는가

  1. 읽기, 수정, 공유, 삭제를 서로 다른 행동으로 구분했는가?
  2. 내용 수정과 소유권 이전처럼 위험이 다른 작업을 하나의 write 권한으로 묶지 않았는가?
  3. 일괄 내보내기나 복제처럼 화면에 잘 드러나지 않는 행동도 포함했는가?
  4. 행동마다 필요한 역할과 관계가 명확한가?
  5. 정의되지 않은 행동은 기본적으로 거부되는가?

조직과 범위가 명확한가

  1. 권한은 어느 워크스페이스나 프로젝트 안에서 유효한가?
  2. 데이터 조회 조건에 조직 범위가 포함되는가?
  3. 다른 조직의 ID를 전달해 데이터에 접근할 수 없는가?
  4. 조직 관리자 권한을 전역 관리자 권한처럼 취급하지 않는가?
  5. 목록, 검색, 통계, 내보내기에 같은 범위가 적용되는가?

자원 상태와 맥락을 고려했는가

  1. 초안, 승인, 보관 상태에 따라 허용 행동이 달라지는가?
  2. 시간 제한이 있는 공유 권한은 언제 만료되는가?
  3. 법적 보존이나 잠금 상태가 변경을 제한하는가?
  4. 민감한 행동에 추가 인증이나 승인 절차가 필요한가?
  5. 권한 판단에 사용하는 데이터가 최신 상태인가?

최소 권한과 철회를 설계했는가

  1. 사용자가 업무에 필요한 행동만 수행할 수 있는가?
  2. 접근 가능한 데이터 범위가 필요한 수준으로 제한되는가?
  3. 임시 권한에는 만료 시각이 있는가?
  4. 권한을 부여한 사람과 철회할 수 있는 사람이 명확한가?
  5. 역할이나 공유 관계가 변경되면 기존 접근이 언제 사라지는가?

클라이언트와 서버의 책임을 구분했는가

  1. 프론트엔드의 버튼 숨김을 보안 검사로 사용하고 있지 않은가?
  2. 서버가 모든 중요한 행동을 다시 검사하는가?
  3. 화면에 전달된 canEdit 값이 오래되어도 서버가 안전하게 거부하는가?
  4. 도메인 객체의 민감한 필드가 응답에 그대로 노출되지 않는가?
  5. 권한 오류가 다른 데이터의 존재를 불필요하게 알려주지 않는가?

운영 중 추적할 수 있는가

  1. 권한 부여와 철회가 감사 로그에 남는가?
  2. 거부된 민감한 행동을 필요한 수준으로 기록하는가?
  3. 로그에 토큰이나 문서 본문 같은 민감한 정보가 포함되지 않는가?
  4. 역할 변경 후 캐시와 토큰이 어떻게 처리되는가?
  5. 특정 사용자의 현재 접근 범위를 조사할 수 있는가?

흔한 실수는 권한을 로그인 뒤에 붙이는 불리언으로 보는 것이다

로그인한 사용자라면 모든 데이터를 조회할 수 있게 한다

인증은 사용자의 신원을 확인할 뿐 특정 문서에 대한 권리를 부여하지 않는다.

소유권, 조직 범위, 공유 관계를 함께 확인해야 한다.

프론트엔드에서 버튼만 숨긴다

사용자는 API를 직접 호출할 수 있다.

화면 제어와 별개로 서버가 실제 행동을 검사해야 한다.

역할 이름 하나로 모든 권한을 결정한다

같은 관리자가 모든 조직과 모든 비공개 데이터에 접근할 수 있는 것은 아니다.

역할이 유효한 범위와 자원 관계를 함께 확인해야 한다.

요청으로 받은 조직 ID를 그대로 신뢰한다

사용자는 다른 조직의 ID를 전달할 수 있다.

형식을 검증하고 현재 사용자의 활성 멤버십으로 조직 범위를 확인해야 한다.

상세 API만 보호한다

목록, 검색, 개수, 내보내기를 통해 비공개 데이터의 존재나 내용이 노출될 수 있다.

모든 데이터 접근 경로에 같은 정책을 적용해야 한다.

write 권한 하나에 모든 변경 행동을 넣는다

본문 수정과 문서 삭제, 외부 공유, 소유권 이전은 위험 수준이 다르다.

실제 비즈니스 행동에 맞춰 권한을 나누어야 한다.

토큰의 역할을 영구적인 사실로 취급한다

토큰 주장은 발급 시점의 스냅숏일 수 있다.

권한 변경 반영 시점과 현재 상태 재조회 정책을 정해야 한다.

알 수 없는 행동을 기본 허용한다

새 기능에 권한 분기가 빠졌을 때 의도하지 않은 접근이 발생한다.

명시적으로 허용되지 않은 행동은 기본적으로 거부해야 한다.


권한의 핵심은 주체와 행동, 데이터의 관계를 결정하는 데 있다

프로그래밍을 처음 배울 때는 로그인한 사용자만 특정 페이지에 들어갈 수 있게 하는 것으로 권한의 기본 역할을 이해할 수 있다.

if (!currentUser) {
  throw new UnauthorizedError();
}

입문 단계에서는 충분한 설명이다.

하지만 실제 서비스에서 권한을 설계할 때는 로그인 여부보다 구체적인 관계와 범위가 중요하다.

  • 요청한 사용자는 누구인가?
  • 어느 조직 안에서 행동하는가?
  • 어떤 데이터에 접근하려 하는가?
  • 해당 데이터와 사용자는 어떤 관계인가?
  • 읽기, 수정, 공유, 삭제 중 어떤 행동을 요청했는가?
  • 자원의 현재 상태에서 그 행동이 허용되는가?
  • 필요한 범위를 넘어서는 권한을 제공하고 있지 않은가?
  • 목록과 상세, 검색과 내보내기에 같은 정책이 적용되는가?
  • 프론트엔드와 별개로 서버가 최종 판단을 수행하는가?
  • 권한 변경을 즉시 반영하고 나중에 추적할 수 있는가?

권한은 로그인 여부를 확인하는 것이 아니다.

권한은 확인된 사용자가 특정 데이터에 특정 행동을 수행할 수 있는지, 소유권·역할·조직·자원 상태·요청 맥락을 바탕으로 결정하는 규칙이다.

profile
Vision eXperience Developer

0개의 댓글