본문으로 건너뛰기
블로그로 돌아가기

역할 이름만으로는 정해지지 않는 관리자 권한

관리자용 Todo 앱을 예로 역할 기반 권한에 속성과 관계 조건을 더하고, 같은 정책을 UI와 서버, SQL에서 함께 집행하는 방법을 정리했습니다.

백엔드보안CASLDrizzle ORMNext.js

Photo by WDnet Studio on StockSnap

라이브 서비스의 운영 화면이 필요해져서 관리자 앱을 조사하고 구현한 적이 있습니다. 첫 설계는 단순했어요. 계정에 role을 두어 일반 사용자와 관리자를 나누고, 관리자에게만 메뉴와 수정 버튼을 보여 주면 끝나는 구조였습니다. 화면에도 서버에도 user.role === 'admin' 한 줄이면 충분해 보였습니다.

운영 업무가 구체화되자 권한 요청이 하나씩 들어왔습니다. 고객 지원 담당자는 상태만 바꾸고 제목은 건드리지 못하게 해 달라거나, 같은 관리자라도 자신이 맡은 조직의 데이터만 보게 해 달라거나, 공개 데이터와 비공개 데이터의 삭제 규칙을 다르게 해 달라는 식이었습니다.

요청을 하나씩 반영하는 일 자체는 어렵지 않습니다. 목록 화면에 조건을 하나 더하고, 상세 화면에 또 하나 더하고, 서버 함수에도 같은 뜻이 되도록 조건을 옮겨 적습니다. 그러는 사이 화면과 서버는 같은 규칙을 각자 따로 해석합니다. 그때부터는 규칙을 하나 바꿀 때마다 조건이 어디에 흩어져 있는지부터 찾게 돼요.

이 단계의 권한은 "관리자인가"만으로 결정되지 않습니다. 누가, 무엇에, 어떤 행동을, 어떤 조건에서, 어느 필드까지 하는가를 함께 봐야 답이 나옵니다. 그래서 조건문을 화면마다 늘리는 대신, 이 다섯 가지를 한곳에 정책으로 적어 두고 버튼을 그리는 자리부터 실제로 행이 바뀌는 자리까지 같은 답을 가져다 썼습니다.

이 글은 관리자용 Todo 앱을 예로 그 구조를 따라갑니다. 구현에는 CASL을 쓰지만 중심은 라이브러리 사용법보다 역할 기반 접근 제어(RBAC)와 속성 기반 접근 제어(ABAC), 그리고 행 단위 인가와 필드 단위 인가입니다.

예제의 범위

Todo 예제는 권한 규칙을 UI·서버·SQL에 연결하는 부분에 집중합니다. 실제 운영에 필요한 감사 로그 저장소와 관리자 재인증, 승인 절차와 PostgreSQL RLS는 뒤에서 별도의 경계로 다룹니다.

같은 Todo인데 할 수 있는 일이 다르다

예제의 Todo에는 소유자 userId, 공개 여부 public, 완료 여부 complete가 있습니다. 역할이 둘뿐인 앱인데도 이 세 속성을 빼면 누가 무엇을 할 수 있는지 정확히 적을 수 없습니다. 앞의 다섯 가지를 그대로 표의 열 이름으로 놓아 봤어요.

누가무엇에어떤 행동을어떤 조건에서어느 필드까지
비로그인 사용자Todo읽기공개 Todo전체
일반 사용자Todo읽기·수정자신이 소유한 Todotitle, public, complete
일반 사용자Todo삭제자신이 소유한 비공개 Todo해당 없음
관리자Todo읽기조건 없음전체
관리자Todo수정다른 사용자의 공개 Todocomplete
관리자User읽기역할이 user인 계정목록에 쓰는 열

표에서 Todo 수정이 나오는 줄은 두 개입니다. 둘 다 행동 이름은 update로 같지만, 일반 사용자는 소유 관계 때문에 자기 Todo만 수정하고 관리자는 남의 공개 Todo에서 complete만 바꿉니다. 이름이 같은데 허용 범위가 갈리니, admin인지 비교하는 조건문으로는 이 차이가 표현되지 않아요.

그래서 예제가 답해야 할 질문은 세 가지가 됩니다.

  • 역할과 데이터 속성을 함께 쓰는 정책을 읽을 수 있는 형태로 적을 수 있는가
  • 화면에서 버튼을 숨기는 것과 별개로 서버가 같은 정책을 집행하는가
  • 허용되지 않은 행이 조회뿐 아니라 UPDATEDELETE 대상에서도 빠지는가

RBAC로 시작하고, 속성과 관계로 좁힌다

RBAC(Role-Based Access Control)는 사용자가 아니라 역할에 권한을 붙입니다. admin, support, finance 같은 역할에 권한 묶음을 할당해 두고 사용자에게는 역할만 주므로, 직무가 비교적 안정적인 조직에서 관리 비용이 낮습니다. 관리자가 모든 Todo를 읽는다는 규칙이 전형적인 RBAC예요.

role = admin → read Todo

ABAC(Attribute-Based Access Control)는 요청을 판단할 때 사용자와 대상, 행동, 환경의 속성을 함께 평가합니다. 같은 관리자라도 부서가 다르거나, 대상이 비공개이거나, 요청 시각이 승인 시간 밖이면 결과가 달라집니다. Todo 예제에서 그렇게 평가되는 속성은 대상의 public과 사용자의 role입니다.

actor.role = admin
action = update
resource.type = Todo
resource.public = true
field = complete

"자신이 소유한 Todo"처럼 사용자와 대상 사이의 관계를 보는 규칙은 관계 기반 접근 제어(ReBAC)에 해당합니다. 대상의 속성값이 아니라 두 주체를 잇는 관계가 조건이 됩니다.

resource.userId = actor.id

실제 애플리케이션에서 위 모델들의 경계는 뚜렷하지 않습니다. 역할도 사용자의 속성이고, 소유 관계 역시 두 ID를 비교해야 하니까요. 그래서 설계를 가르는 기준은 이론적인 모델 이름이 아니라 규칙마다 무엇을 보는지입니다. 역할만 보면 끝나는 규칙인지, 요청 시점의 속성과 관계까지 봐야 하는 규칙인지를 구분해 두면 결정하기 편해집니다.

CASL의 subject는 요청자가 아니라 대상 리소스

보안 모델 문서에서 subject는 보통 행동을 요청하는 사용자나 프로세스를 가리킵니다. CASL의 subject는 반대로 Todo처럼 행동의 대상이 되는 리소스를 뜻하므로, 이 글에서는 요청자를 사용자로, CASL의 subject를 대상 또는 리소스로 풀어 씁니다.

정책을 action, subject, condition, field로 읽기

정책을 한곳에 모으려면 먼저 권한을 적을 문법이 필요합니다. 예제에서는 CASL로 정책 객체를 만듭니다. CASL은 RBAC나 ABAC 같은 이론의 이름이 아니라 JavaScript와 TypeScript에서 권한 규칙을 선언하고 검사하는 라이브러리이고, 이 라이브러리에서 정책 객체를 부르는 이름이 Ability입니다. 규칙은 네 부분으로 나뉩니다.

요소질문예시
action무엇을 하려는가read, create, update, delete
subject어떤 리소스인가Todo, User
condition어떤 행 또는 객체인가{ userId: user.id }, { public: true }
field어느 속성까지 허용하는가complete

앞의 표를 이 문법으로 옮겨 보면 핵심 규칙이 열 줄 남짓으로 정리돼요.

allow('read', 'Todo', { public: true });

if (user) {
  allow('read', 'Todo', { userId: user.id });
  allow('create', 'Todo', { userId: user.id });
  allow('update', 'Todo', { userId: user.id });
  allow('delete', 'Todo', { userId: user.id, public: false });

  if (user.role === 'admin') {
    allow('read', 'Todo');
    allow('read', 'User', { role: 'user' });
    allow('update', 'Todo', ['complete'], { public: true });
  }
}

read Todo처럼 조건이 없는 규칙은 모든 Todo를 뜻합니다. { userId: user.id }가 붙으면 대상이 자기 Todo로 좁아지고, ['complete']가 붙으면 바꿀 수 있는 필드까지 좁아집니다. 어느 규칙에도 걸리지 않은 요청은 그대로 거부됩니다. 정책에 적지 않은 행동이 자동으로 막히는 기본 거부(deny by default) 구조입니다.

모든 Todo 사각형 안에 공개 여부와 소유 관계를 뜻하는 두 원이 겹쳐 있고, 겹치는 자리마다 허용되는 행동이 다른 구조
역할은 바깥 범위를 정하고 속성과 관계는 그 안을 나눕니다. 두 원이 겹치는 자리에서 삭제만 빠지는 것처럼, 같은 행이라도 허용되는 행동이 달라집니다.

표의 한 줄이 코드의 한 줄로 남는다는 점이 CASL을 고른 이유였습니다.

물론 정책을 한곳에 모았다고 보안이 끝나지는 않습니다. 요청이 지나가는 경계마다 이 정책이 실제로 집행되어야 합니다.

같은 정책을 세 경계에서 집행하기

같은 정책 객체가 세 곳에서 쓰입니다. 화면이 버튼을 그릴 때, 서버가 요청을 받을 때, 쿼리가 행을 고를 때입니다.

현재 사용자와 대상 데이터가 권한 정책 하나로 모이고, 그 정책이 UI와 서버·DAL, SQL WHERE 세 경계로 갈라지는 구조
세 경계가 각자 판단하지 않고 같은 정책에서 답을 받아 갑니다. 정책 한 줄을 고치면 세 곳이 함께 바뀝니다.

UI는 가능한 행동을 설명한다

수정 권한이 없는 사용자에게는 수정 버튼을 그리지 않고, 완료 여부를 바꿀 수 없으면 체크박스를 비활성화합니다. 눌러도 거부될 버튼을 미리 감추면 사용자는 실패 메시지를 거치지 않고 규칙을 알게 됩니다.

다만 이 검사는 보안 경계가 아닙니다. 브라우저에서 감춘 버튼을 다시 표시하거나 서버 요청을 직접 만드는 데는 개발자 도구 하나면 충분하거든요. 화면의 권한 검사는 지금 무엇을 할 수 있는지 알려 주는 안내이고, 실제로 해도 되는지 결정하는 자리는 서버입니다.

서버는 객체와 필드를 다시 검사한다

Todo 수정 요청이 오면 서버는 현재 사용자와 저장된 Todo, 이번에 바꾸려는 데이터를 함께 봅니다. 관리자 규칙이 complete 하나만 허용하므로, 이 Todo를 수정할 수 있는지만 확인하면 검사가 절반만 끝난 셈입니다.

const canUpdate = Object.keys(data).every((field) =>
  ability.can('update', subject('Todo', existingTodo), field),
);

관리자가 공개 Todo에 { complete: true }를 보내면 통과하고, 같은 요청에 title이 섞이면 거부됩니다. 이것이 필드 단위 인가(field-level authorization)입니다. 리소스를 수정할 수 있다는 판단과 어느 필드까지 바꿀 수 있다는 판단은 서로 다른 질문이라, 한 번에 묻는 검사는 넓은 쪽으로 기울기 쉽습니다.

입력 타입도 권한과 별개로 좁혔습니다. 수정 함수가 테이블의 입력 타입을 그대로 받으면 내부 호출자가 userId나 생성 시각까지 넘길 수 있어서, 유스케이스에서 실제로 바뀌는 title, public, complete만 남겼어요. 인가는 허용 여부를 판단하고, 좁은 입력 타입은 애초에 불필요한 변경을 표현하기 어렵게 만듭니다.

SQL은 허용된 행만 다룬다

읽기 권한을 애플리케이션에서만 검사하면 코드는 자연스럽게 이런 모양이 됩니다.

const todos = await getAllTodos();
return todos.filter((todo) => ability.can('read', subject('Todo', todo)));

이 코드도 화면에 나가기 전에 걸러 주기는 합니다. 다만 DB와 애플리케이션 사이에는 볼 수 없는 데이터가 이미 건너온 뒤이고, 데이터가 늘어날수록 이 왕복 비용도 같이 늘어납니다. 나중에 추가된 조회 함수가 필터를 빠뜨리면 그 자리가 그대로 노출 경로가 되고요.

같은 판단을 쿼리로 내리면 거르는 자리가 DB 안으로 들어갑니다. 정책의 조건이 그대로 WHERE 절이 됩니다.

-- 비로그인 사용자의 Todo 조회
WHERE public = true

-- 로그인한 일반 사용자의 Todo 조회
WHERE public = true OR user_id = $1

-- 관리자의 사용자 목록 조회
WHERE role = 'user'

WHERE는 정책에서 꺼내 만듭니다. CASL 규칙의 조건 부분을 읽어 쓰는 쿼리 빌더의 조건식으로 옮기는 변환기 하나면 충분합니다. 예제에서는 Drizzle의 and, or, eq가 그 자리에 들어갑니다.

위쪽은 DB에서 모든 행을 가져와 앱에서 거르는 흐름, 아래쪽은 SQL 조건으로 허용된 행만 가져오는 흐름 비교
같은 결과가 화면에 나와도 필터를 거는 위치에 따라 경계를 넘는 데이터가 달라집니다.

이 글에서 말하는 행 단위 인가(row-level authorization)는 애플리케이션이 만드는 SQL의 WHERE에 권한 조건을 넣는 방식입니다. PostgreSQL이 정책을 직접 강제하는 Row-Level Security(RLS)와는 보호 계층이 다릅니다. 이 DAL을 우회해 DB에 직접 붙는 코드까지 막는 일은 RLS 같은 DB 계층 정책의 몫입니다.

조회뿐 아니라 UPDATE와 DELETE에도 조건 넣기

읽기 조건을 SQL로 내려도 변경 로직은 그대로 남아 있습니다. 권한을 확인한 다음 ID로 수정하는 코드는 이렇게 쓰기 쉽습니다.

const existingTodo = await findTodo(id);

if (!canUpdateTodo({ user, todo: existingTodo, data })) {
  throw new UnauthorizedError();
}

await db.update(todo).set(data).where(eq(todo.id, id));

이 코드의 문제는 검사한 시점과 변경한 시점이 벌어져 있다는 점입니다. 두 시점 사이에 소유자나 공개 상태가 바뀌어도 마지막 쿼리는 id만 맞으면 실행되니까요. 검사와 사용 사이의 상태 변화를 노리는 이 간격을 TOCTOU(time-of-check to time-of-use)라고 부릅니다. 지금 화면에 소유자를 바꾸는 기능이 없더라도, 관리자 기능이나 배치가 하나 붙으면 그 전제는 조용히 깨집니다.

권한 검사와 변경 쿼리 사이의 간격에서 상태가 바뀔 수 있고, id만 건 쿼리는 실행되지만 권한 조건을 함께 건 쿼리는 0행으로 거부되는 구조
검사와 변경 사이의 간격은 없앨 수 없지만, 변경 쿼리가 그 순간의 권한 조건을 함께 확인하면 간격 안의 변화가 결과를 뒤집지 못합니다.

최종 변경 쿼리에도 같은 권한 조건을 넣으면 그 간격이 좁아져요.

const [updatedTodo] = await db
  .update(todo)
  .set(data)
  .where(
    and(
      eq(todo.id, id),
      drizzleWhere('update', 'Todo', user, todo),
    ),
  )
  .returning();

if (!updatedTodo) throw new UnauthorizedError();

필드 검사는 이 요청이 무엇을 바꾸려는지 확인하고, SQL 조건은 지금 이 순간 어떤 행을 바꿀 수 있는지 확인합니다. DELETEid와 권한 조건을 함께 겁니다. 조건에 걸려 결과가 비었다면 권한이 없거나 대상이 이미 사라진 경우이므로, 그 결과를 성공으로 돌려주지 않습니다.

역할 조건문을 정책으로 바꾸면 달라지는 것

정책을 한곳에 모으고 세 경계에서 집행한 구조를 처음의 흩어진 역할 조건문과 나란히 놓으면 차이가 이렇게 드러납니다.

확인 항목흩어진 역할 조건문정책 중심 구조
정책 위치화면·서버의 역할 조건문정책 생성 함수 한곳
집행 경계UI 버튼 표시UI + 서버 객체·필드 검사 + SQL 조건
읽기 범위조회 후 메모리 필터 가능성DB에서 허용된 행만 조회
변경 범위권한 확인 후 WHERE id = ?id와 현재 권한 조건을 함께 적용
필드 권한리소스 전체 update관리자는 공개 Todo의 complete만 변경
회귀 확인수동 시나리오SQL 변환 규칙 자동 테스트

테스트도 정책과 같은 조건을 그대로 확인합니다. 일반 사용자의 수정 조건에는 user_id가, 삭제 조건에는 user_idpublic = false가 함께 들어가야 합니다. 관리자의 공개 Todo 수정에서는 public = true가, 사용자 목록 조회에서는 role = 'user'가 조건으로 나타납니다.

이 테스트가 운영 안전성을 보장하지는 않습니다. 다만 정책 한 줄을 고쳤을 때 UI와 서버, SQL이 서로 다른 해석을 갖게 되는 회귀는 빠르게 잡혀요.

아직 남은 경계

여기까지가 이 예제가 실제로 막아 주는 범위입니다. 그 바깥에는 어디부터 보호되지 않는지 적어 둘 항목이 다섯 가지 남습니다.

  • SQL 변환기의 지원 범위 — 지금은 eq, and, or만 번역하므로 날짜 비교나 포함 조건을 정책에 추가하면 변환기와 테스트도 함께 확장
  • 단위 테스트의 한계 — SQL 문자열 비교만으로는 부족하고, 실제 PostgreSQL에서 남의 행이 수정·삭제되지 않는지 확인하는 통합 테스트가 필요
  • DAL을 지나지 않는 경로 — 배치나 운영 스크립트가 같은 DB에 직접 붙으면 이 정책은 적용되지 않음
  • 정책 객체 생성 위치 — 작은 예제에서는 검사할 때마다 만들어도 무리가 없지만, 규모가 커지면 요청 단위로 한 번 만들어 넘기는 편이 유리
  • 역할 설계admin 하나로 시작해도 운영에서는 고객 지원, 콘텐츠 관리, 정산, 최고 관리자로 범위가 갈리고 정기적인 검토 대상이 됨

CASL은 정책을 적고 검사하는 도구이지 모든 접근 경로를 자동으로 막아 주는 보안 경계가 아닙니다. 규칙이 복잡해질수록 라이브러리가 무엇을 해 주는지 익히는 시간보다, 어떤 진입점이 빠졌고 어떤 조건이 번역되지 않는지 찾는 시간이 길어집니다.

운영 관리자 앱이라면 감사 로그까지

여기까지가 "이 요청을 허용할 것인가"의 영역이라면, 운영 관리자 앱에는 "누가 무엇을 왜 했는가"를 나중에 복원하는 영역이 하나 더 붙습니다.

관리자 앱이 라이브 DB를 바꾸는 것 자체는 이상한 일이 아닙니다. 주문 취소, 계정 정지, 환불 승인, 콘텐츠 숨김은 전부 운영 데이터의 변경이니까요. 다만 그 변경을 누가 했는지 남기려면 운영자가 공용 관리자 계정을 함께 쓰지 않고 개인 계정으로 로그인해야 합니다.

앱 서버가 DB에 붙을 때는 admin_app_production 같은 서비스 계정 하나를 씁니다. 이 계정은 환경별로 분리하고 런타임에 필요한 최소 권한만 갖게 하며, 스키마를 바꾸는 마이그레이션 계정은 따로 둡니다. DB 쪽에서는 모든 변경이 같은 접속 계정으로 보이지만 애플리케이션은 실제 작업자를 알고 있으니, 개인을 식별하는 몫은 감사 로그가 맡습니다.

type AuditEvent = {
  actorId: string;
  actorRole: string;
  action: string;
  targetType: string;
  targetId: string;
  before?: unknown;
  after?: unknown;
  reason?: string;
  occurredAt: string;
  requestId: string;
  outcome: 'succeeded' | 'denied' | 'failed';
};

감사 이벤트를 업무 DB에 저장한다면 데이터 변경과 같은 트랜잭션에서 기록해 둘의 결과가 어긋나지 않게 합니다. 로그 자체가 지워지거나 고쳐지는 상황까지 보려면 중앙 로그 저장소나 변경이 어려운 보관 계층으로 복제하는 방법이 따로 필요합니다. 남기는 값에도 기준이 있는데, 변경 전후를 통째로 넣는 대신 바뀐 필드의 diff만 남기면 개인정보와 비밀값이 로그로 새는 통로도 함께 줄어들어요.

대량 삭제, 권한 승격, 환불처럼 영향이 큰 작업에는 사유 입력만으로 부족합니다. 재인증과 2인 승인, 실행 전 대상 건수 확인, 되돌릴 수 있는 작업 설계가 권한 검사 다음에 오는 경계입니다.

마치며

user.role === 'admin' 한 줄이 부족했던 이유는 관리자가 하는 일이 많아서가 아니라, 그 비교가 대상 데이터를 전혀 보지 않기 때문이었어요. Todo 하나에도 소유자와 공개 여부가 붙어 있고, 거기에 행동과 필드가 곱해지면 역할 이름만으로 답할 수 있는 질문은 얼마 남지 않습니다.

그래서 이 구조에서 오래 남는 쪽은 CASL이라는 선택이 아니라 권한을 action, subject, condition, field로 말하는 방식입니다. 이 네 단어로 정책을 한곳에 적어 두면 화면은 가능한 행동을 설명하고, 서버는 객체와 필드를 검사하고, SQL은 허용된 행만 다룹니다. 세 곳의 판단이 같은 함수에서 나온다는 점이 흩어진 조건문과의 차이였어요.

운영으로 가져갈 때는 여기에 개인별 관리자 계정과 감사 로그, 위험 작업의 추가 승인, 직접 DB 접근 경로 통제가 이어집니다. 화면마다 흩어져 있던 조건문은 결국 표 한 장과 정책 생성 함수로 줄었고, 규칙을 바꿀 때 여는 파일도 그 함수 하나가 됐습니다.

참고 자료