Unknownpgr

RBAC 구현

2021-12-02 17:45:23 | Korean

이 글은 Role-Based Access Control (1992)를 참고하여 작성하였습니다.

저는 지금 제가 속한 동아리의 홈페이지 개선 작업을 하고 있습니다. 기존의 홈페이지는 PHP 언어로 작성된 XE기반이었습니다. XE는 버전 1이 1999년에 발표되었고 2009년에 유지보수가 중단되었습니다. 즉, 엄청 오래된 시스템입니다.

저는 관리자 페이지에 들어가면 warning이 백만 개가 뜨는 이 상황을 참지 못하고 (거의 20년만에) 홈페이지를 Node.js 기반의 시스템으로 이전하기로 결심했습니다. 그러다 권한 시스템을 구현해야 하는 문제에 부딛혔습니다. 동아리 홈페이지가 단순한 게시판이라면 그렇게 크게 고민하지 않았겠지만, 동아리 홈페이지에는 대회 신청 기능을 포함하여 수많은 기능들이 들어 있습니다. 저는 이 기능들을 모두 깔끔하게 제어할 수 있는, 그러면서도 모듈로 따로 분리되어 유저 시스템이나 게시판 시스템을 비롯한 다른 시스템들에 대한 종속성이 없는 권한 시스템을 개발하고 싶었습니다.

그런데 저는 평소에 AWS나 쿠버네티스를 다루면서 이러한 시스템들이 RBAC를 사용한다는 것을 알고 있었으며, 따라서 RBAC가 AWS와 같이 복잡한 시스템에 적용하기 적합함을 알고 있었습니다. 그래서 이를 공부하여 동아리 홈페이지에 적용하기로 결정했습니다. 그리고 실제로 구현을 해 보니 RBAC를 구현하는 것이 생각보다 재미있는 과정이어서, 제가 공부한 내용과 그 구현 과정을 정리해두고자 합니다.

Role-Based Access Control (RBAC)이란?

RBAC란 1992년 David F. Ferraiolo and D. Richard Kuhn 이 Role-Based Access Control 논문에서 제시한, 접근 제어(Access Control) 방법의 하나입니다. 그 이전에는 Mandatory Access Control(MAC), Discretionary Access Controls (DAC)가 일반적으로 군사, 혹은 정부에서 쓰기 적합한 접근 제어 체계로 여겨졌습니다. 그러나 위 논문에서는 DAC를 위한 이론적 기반이 명확하지 않으며 상업적, 혹은 정부를 위한 조직에서 사용하기에 부적합하다고 주장하며 새로운 방식의 비임의적(non-discretionary) 접근방식인 Role-Based Access Control (RBAC)를 제안했습니다.

따라서 RBAC를 이해하기 위해서는 기존의 DAC가 어떤 문제점을 가지고 있는지 알아볼 필요가 있습니다. DAC는 Unix filesystem과 비슷하게 모든 대상에 소유자 (Owner)를 부여한 후 이 소유자가 해당 오브젝트를 마음대로 조작할 수 있는 방식입니다. DAC는 일반적으로 임의 접근 제어라 번역되지만, 이런 맥락에서 저는 이를 재량 기반 접근 제어라 번역하고 싶습니다. 왜냐하면 오브젝트에 대한 모든 권한을 소유자의 재량에 맡기기 때문입니다. 예컨대 어떤 소유자가 다른 소유자에게 소유를 이전할 수 있으며 소유한 오브젝트의 접근 권한도 마음대로 변경할 수 있습니다.

그런데 많은 조직에서는 실제 유저가 '접근 권한에 대한 정보'를 '소유'하지 않습니다. 즉 오브젝트에 접근하는 것이 가능할지라도 그 오브젝트에 대한 접근 권한 자체를 남에게 넘기는 것은 불가능합니다. 실제로 '접근 권한'을 관리하는 것은 시스템, 혹은 허가받은 관리자입니다.

이때 일반적으로 접근 제한은 (유저의 오브젝트의 소유 여부와 다르게) 유저가 조직 내에서 속한 역할에 의해 결정됩니다. 예를 들어 병원의 경우 의사, 간호사, 치료사, 약사 등의 역할이 있을 것이고, 은행의 경우 출납원, 대출 담당자, 회계사 등의 역할이 있을 것입니다. 이러한 역할은 군사 체계에도 적용될 수 있습니다. 예를 들어 표적 분석가, 상황 분석가, 교통 분속가 등이 전술 시스템에서 일반적인 역할입니다.

...이까지는 거의 논문을 번역한 것이었고, 이 이후부터 본격적인 설명이 나옵니다만 그대로 옮기면 이해하기 어려운 글이 될 것 같아 제 마음대로 쓰겠습니다.

위 문단에서 저자가 하고 싶었던 말은 이것입니다. 기존 DAC에서는 '소유자 기반' 의 접근 제어가 이루어졌고, 소유권을 타인에게 이전하는 것 등이 가능하므로 권한 제어가 너무 허술하다는 문제가 있습니다. 예를 들어 어떤 중요한 직책을 가진 사람이 잘못하여 기밀 파일의 소유권을 다른 사람에게 넘긴다면 큰 문제가 발생할 것입니다. 그러나 이러한 접근 방법과 다르게 조직 내의 역할을 고려하여 일반적으로 같은 역할을 가진 사용자들은 같은 권한을 가지고 있다는 것에 착안,

  1. 역할을 정의하고 (e.g. 관리자, 일반 사용자, 동아리 정회원 등)
  2. 권한을 정의한 후 (e.g. 관리자 페이지 접근, 정회원 게시판 읽기, 자유게시판 읽기 등)
  3. 각 역할에 권한을 할당하고
  4. 각 역할에 유저를 할당하면

이러한 문제가 깔끔하게 해결됩니다. 이와 같이 유저와 권한을 연결하는 것을 역할 관계로 해석한 것이 역할 기반 접근 제어입니다.

RBAC에 대한 개인적 해석

위 논문에서는 DAC의 문제점을 해결하는 하나의 다른 방식으로써 RBAC를 제안했습니다. 그러나 저는 RBAC를 이상적인 권한 시스템에 대한 대안으로 해석했습니다.

이상적인 권한 시스템은 모든 유저에 대한 모든 권한을 설정할 수 있는 시스템입니다. 그러므로 권한이 N개 있고 유저가 M명이 있을 경우 최대 N×MN\times M개의 릴레이션이 필요합니다.

그런데 이것을 관리자가 모두 관리하는 것은 거의 불가능합니다. 왜냐하면 관리해야 할 권한 정보가 너무나 많기 때문입니다. 예를 들어 유저가 1000명이고 관리해야 할 권한이 20개인 경우만 생각하더라도 설정해야 할 권한은 2만 개나 됩니다. 물론 그런 현실적인 문제 말고 공간복잡도만 보더라도 O(NM)O(NM) 에 해당하므로 너무 큽니다.

이때 일반적인 시스템이라면 모든 유저가 서로 다른 권한을 가지지는 않을 것입니다. 그러므로 같은 권한을 가진 유저들을 그룹으로 묶어 관리하면 필요한 릴레이션을 줄일 수 있습니다. 예컨대 같은 권한을 가지는 유저들을 묶어 보았더니 K개의 그룹이 나왔다고 가정해봅시다. 그러면

  1. 각 유저가 속한 그룹이 무엇인지, 즉 유저-그룹의 관계와
  2. 각 그룹이 어떤 권한을 가지는지,즉 그룹-권한의 관계만

저장하면 됩니다. 그러므로 N×K+K×M=(N+M)×KN\times K+K\times M=(N+M)\times K개의 릴레이션만이 필요합니다. 아까의 예시에서 그룹이 4개정도 나왔다고 가정해보면 (20+1000)×4=4080(20+1000)\times 4=4080개의 릴레이션만이 필요함 알 수 있습니다. 아까의 2만 개에 비하면 개수가 훨씬 줄었습니다. 심지어 이는 유저가 한 번에 여러 개의 그룹에 속할 수도 있다고 가정한 것입니다. 만약 유저가 속할 수 있는 그룹을 하나로 제한하면 N×K+MN\times K + M개의 릴레이션만이 필요하며, 이전의 예시로 계산해보면 1080개의 릴레이션이면 충분합니다.

물론 DAC도 이상적인 권한 시스템에 대한 대안으로 해석할 수 있습니다. 이 경우 권한을 유저(NN)의 리소스(MM)에 대한 행동(KK)로 해석한 것으로, N×M×KN\times M\times K개의 릴레이션을 모두 관리할 수가 없으니 모든 리소스에 소유자를 할당한 후, 해당 소유자가 해당 리소스에 대한 모든 행위를 수행할 수 있는 것으로 해석하는 것입니다. 그러면 소유자-리소스 관계 MM개만 저장하면 됩니다. 그러나 이 경우 어떤 리소스의 소유자에 대해 특정 행위만을 골라 제한하는 것이 불가능하다는 단점이 있습니다.

RBAC 모델의 구성 및 수학적 표현

RBAC 모델에서는 아래와 같은 5개의 주요한 구성 요소가 있습니다.

이라고 부르며, 보통 약어로 PP, SS, RR이라 씁니다.

그리고

이라고 부르며 보통 약어로 PAPA, SASA라 씁니다.

그리고 Transaction (T), Role Hierarchy (RH) 등이 있지만 이는 지금은 다루지 않겠습니다.

그리고 이들은 모두 집합으로 표현할 수 있습니다.

구현

위 시스템은 보는 바와 같이 굉장히 간단합니다. 그러나 개념적인 구조와 실제 구현은 거리가 좀 멀었습니다.

먼저 시스템 구현에 앞서 저는 아래와 같은 목표를 설정했습니다.

이는 제가 관리하는 서비스가 동아리 홈페이지 서비스였기 때문입니다. 동아리 홈페이지 서비스는 저 혼자 관리하는 것이 아니라 미래에 다른 후배들이 관리하게 될 것이고, 그 중에서는 컴퓨터 전공이 아닌 학생도 많이 있을 것입니다. 그런데 동접자가 100명도 나오지 않는 서비스임에도 Redis같은 기술을 도입하면 관리 포인트가 늘어날 것이고, 백업도 힘들 것입니다. (DB 전체를 백업하는 스크립트는 이미 있어 간단히 백업이 가능한 상황입니다.) 또한 권한 설정 등을 위해 소스코드를 수정하는 것은 그 자체로 좋지 못한 구현임이 분명합니다. 종속성 제거나 모듈화 등도 같은 맥락입니다.

그런데 이러한 조건들을 설정한 후에 구현을 하려고 하니 다음과 같은 여러 문제가 발생했습니다.

권한들의 목록을 어떻게 가져오는가?

위 시스템에서는 어쨌든 다섯 개의 집합이 필요합니다. P,S,R,PA,SAP, S, R, PA, SA 입니다. 그런데 SS는 그냥 기존의 유저 모델을 그대로 가져온다고 치고, RR은 그냥 컬럼 하나짜리 (Role에 대한 설명을 추가한다면 두 개짜리) 테이블입니다. PAPASASAP,R,SP,R,S가 있으면 간단하게 컬럼 두 개짜리 테이블 두 개로 표현할 수 있습니다. 그런데 결정적인 문제는 집합 PP를 어떻게 가져오냐는 것이었습니다.

먼저 집합 PP가 다른 집합과 다르게 데이터베이스에 저장될 수 없음을 금방 깨달았습니다. 왜냐하면 만약 그렇게 된다면 소스코드를 수정하여 새로운 API를 추가하거나 삭제할 때마다 데이터베이스를 직접 건드려서 권한을 조정해줘야 하기 때문입니다. 이는 정말로 비효율적이고 오류가 발생하기 쉬운 구조입니다. 그래서 PP는 서버가 실행된 이후 런타임에 동적으로 조회 시마다 생성하는 방식을 택하기로 했습니다.

그리고 나서 처음 생각한 것은 API와 권한을 1:1 대응시키는 것이었습니다. 그러면 API들의 리스트를 가져오면 곧 이것이 권한의 리스트가 되고, 이것은 Koa의 route를 가져오는 기능을 사용하면 쉽게 해결할 수 있습니다. 즉, 서버가 시작될 때 자기 자신의 API의 리스트를 가져온 후 이것을 PP라고 두는 것입니다.

그런데 동아리 홈페이지를 보면 어떤 게시판에는 모든 유저가 접근 가능하고 어떤 게시판에는 정회원만 접근할 수 있습니다. 즉, 같은 게시판 API를 호출하더라도 권한이 서로 다를 수 있습니다. 그러므로 이 방법은 사용할 수 없습니다. 이는 정말로 해결하기 어려운 문제였습니다. 그래서 다른 권한 구현 라이브러리들을 참고하였고, Casbin 라이브러리를 참고하여 도움을 얻을 수 있었습니다. 바로 권한을 '행위 (Actions)'와 '대상 (Objects)'으로 나눈 후 이것의 곱집합을 권한으로 사용한다는 아이디어였습니다. 예컨대 게시판의 권한을 예로 들자면 아래와 같습니다.

- 행위 = ['글 목록 읽기', '글 읽기', '글 쓰기']
- 대상 = ['정회원 게시판', '자유게시판']
- 권한 = 대상 × 행위 = [
  ('정회원 게시판', '글 목록 읽기'), ('정회원 게시판', '글 읽기'), ('정회원 게시판', '글 쓰기'),
  ('자유게시판', '글 목록 읽기'), ('자유게시판', '글 읽기'), ('자유게시판', '글 쓰기')
  ]

Casbin 라이브러리에서는 이를 특수한 형식의 파일로 구현했지만, 저는 이 방법이 마음에 들지 않았습니다. 왜냐하면 관리자가 카테고리를 추가하거나 삭제할 경우 해당 파일도 마찬가지로 수정해주어야 하는데 이는 매우 비효율적이기 때문입니다. 그래서 저는 RBAC 시스템이 '행위'의 목록과 '대상'의 목록을 받아 이들의 곱집합을 자동으로 생성하도록 구성했습니다.

물론 경우에 따라 '대상'이 필요 없는 경우도 있습니다. 예를 들어 (관리자 역할이 사용할 수 있는) '유저 삭제 가능' 기능은 그냥 유저 삭제가 가능하거나 불가능한거나 둘 중 하나일 뿐, 특정 유저만을 삭제할 수 있도록 상세한 권한을 줄 필요는 없습니다. 이런 경우는 '대상' 목록을 null로 주면 그냥 '행위'의 목록을 그대로 권한으로 사용하도록 구현했습니다.

그런데 시스템을 이렇게 구성할 경우 사소한 문제점이 발생합니다. 바로 의미 없는 권한이 생길 수 있다는 점입니다. 예를 들어서 쪽지 보내기 권한과 게시판 권한을 아래와 같이 한 번에 표현한다고 가정해봅시다.

- 행위 = ['쪽지 보내기', '글 읽기', '글 쓰기']
- 대상 = ['자유게시판', '정회원 게시판']

그러면 실제로는 아무 의미가 없는 자유 게시판 쪽지 보내기(?) 등의 권한이 만들어지게 됩니다. 그러므로 모든 행위와 모든 대상의 곱집합을 구하는 것이 아니라 특정 행위들과 특정 대상들의 곱집합들만이 가능하게 제한할 수 있도록 permission들을 모듈로 다시 분리했습니다. 즉, 권한은 (Module, Action, Object) 의 튜플입니다.

어떤 사람들은 모든 연산을 'CRUD' 로 통일하여 RESTful하게 만들면 되지 않겠느냐고 생각할 수도 있습니다. 그런데 실제로 해 보면 상당히 이상한 문제가 많이 발생합니다. 예를 들어 '게시판의 존재를 알 수 있는 권한', 즉 홈페이지에 접속했을 경우 해당 게시판을 게시판 리스트에서 볼 수 있는 권한이 있습니다. 이 권한은 물론 'Read'에 속할 것입니다. 그런데 '게시판에 속한 글들을 읽을 수 있는 권한'은 또 다른 권한인데, 이 또한 CRUD로 표현하자니 Read에 해당합니다. 또는 관리자가 게시판을 추가할 수 있는 권한이 있을 텐데, 이는 CRUD에서 Create에 해당합니다. 그런데 일반 유저가 게시판 내부에 글을 작성할 수 있는 권한 역시 Create에 해당합니다. 즉, CRUD로 표현하게 되면 여러 권한들이 겹쳐서 유일하게 구분되지 않는 경우가 발생할 수도 있습니다. 그 외에도 모든 리소스가 CRUD 4가지를 모두 사용하는 것은 아님을 비롯하여 다양한 문제가 있습니다.

물론 API 구조를 매우 RESTful하게 짜면 이를 해결할 수도 있을 것입니다. 그러나 REST에 너무 집착하게 되면 일반성을 상당히 잃게 됩니다. 예컨대 로그인 / 로그아웃을 POST /sessionDELETE /session 과 같이 표현할 수 있지만, 차라리 RESTful하지 않게 POST /login , DELETE(or POST or GET) /logout 으로 사용하는 것과 마찬가지입니다.

그래서 저는 아래와 같이 권한 모듈을 구현했습니다.

const PostPermission = RBAC.getRBACModule('post', ['read', 'delete', 'update']);
const MenuPermission = RBAC.getRBACModule('menu',
                                          ['list', 'write'], // Actions
                                          ['free-board', 'member-board'] // Objects
                                         );

이때 위 예시의 CategoryPermission처럼 카테고리들을 리스트로 관리한다면 파일로 관리하는 것과 아무 차이가 없습니다. 즉 관리자가 카테고리를 추가할 때마다 저 리스트에 카테고리를 추가해야 합니다. 그러므로 아래와 같이 카테고리 목록을 데이터베이스에서 가져오도록 구현했습니다.

const MenuPermission = RBAC.getRBACModule('menu', ['list', 'write'], async knex => {
  const menus = await knex.select('menu_pk', 'name').from('menu');
  return menus.map(x => ({ id: x.menu_pk, description: x.name }));
});

또한 여기서 object는 실제 object 전체가 아니라 데이터베이스에서 기본 키를 의미합니다. 그런데 object의 기본 키만을 보여준다면 관리자가 권한을 설정하기 어려울 것입니다. 그래서 위와 같이 문자열의 리스트 대신 {id, description} 의 리스트를 공급하도록 하여 관리자가 볼 때에는 id와 description이 함께 보이도록 구현했습니다.

별로 중요한 것은 아니지만, 실제로는 objects로 Object[], Promise<Object[]>, function => Promise<Object[]> 를 입력받을 수 있도록 구현했습니다.

그리고 위 구현에서 관리자가 서버 실행 중에 카테고리를 변경하는 것을 반영하기 위해 해당 쿼리가 매 메뉴 조회마다 발생한다고 생각할 수도 있습니다. 그러나 실제로 권한 검사 때 조회되는 것은 PA이지 P가 아닙니다. 그러므로 위 메뉴 리스트 조회는 관리자 페이지에서 관리자가 권한을 설정하기 위해 권한 목록을 불러오는 경우에만 조회됩니다.

그리고 위 예시에서는 object 목록을 공급할 때 권한 모듈 내부에서 공급된 knex connection을 사용합니다. 그러나 이는 제가 권한 모델과 유저 모델을 같은 데이터베이스 위에 두었으므로 connection을 공유하면 편리해서 그렇게 한 것뿐, 해당 knex connection을 무시하고 다른 데이터베이스, 혹은 mongo나 filesystem 등 아예 RDB가 아닌 곳에서 조회를 수행한 후 목록을 반환하더라도 형식만 잘 맞으면 아무런 문제가 없습니다.

그 권한을 어떻게 검사하는가?

다음으로 고민해야 하는 문제는 저 권한들의 검사 방법이었습니다. 제일 간단하게는 그냥 PA들의 리스트를 Koa 라우터에서 조회할 수 있도록 하면 되겠지만 이렇게 하면 권한을 검사하는 똑같은 if문이 매 라우터마다 들어가게 됩니다. 저는 비슷한 코드가 중복되는 것을 극도로 꺼리므로 이는 용납가능한 방법이 아니었습니다.

이때 가장 좋은 방법은 권한 검사를 하는 미들웨어들을 RBAC 모듈 내에서 생성하도록 한 후, 그 미들웨어에서 권한 검사를 수행하는 것이었습니다. 다만 이 아이디어를 떠올리자마자 바로 불가능함을 깨달았습니다. 왜냐하면 유저 정보야 auth 미들웨어를 앞단에 둔다면 ctx 파라매터를 통해 가져올 수 있다 치더라도, object가 관계된 경우 라우터에서 어떤 object에 접근하려는지를 알아야 하는데 이 object는 query로 공급될 수도, parameter로 공급될 수도, post body에 포함될 수도 있으며, 심지어 정확한 오브젝트 기본 키가 쿼리에 포함된 것이 아니라 오브젝트 질의를 위한 조건만 있을 수도 있기 때문입니다. 따라서 api 구현을 모르는 미들웨어 내부에서는 어떤 오브젝트에 접근하는지 알 방법이 없습니다.

이를 고민하다 다음과 같은 아이디어를 떠올렸습니다. 먼저 각 action에 대한 미들웨어를 생성하도록 구현합니다. 유저 정보는 앞서 언급한 것처럼 쉽게 알 수 있습니다. 이때 Object가 없는, action만으로 이루어진 permission module에서는 module, action이 결정되므로 permission이 유일하게 결정되고 권한 검사를 수행할 수 있습니다. 그래서 권한 검사를 미들웨어 내에서 수행한 후 권한이 없는 경우 401 unauthorized를 반환합니다.

그리고 object가 있는 경우에는 ctx에 checkPermssion 함수를 삽입, 라우터 내에서 ctx.checkPermission(object) 와 같은 형식으로 검사를 할 수 있도록 구현했...었습니다.

그런데 대상이 여러 개면?

그런데 위 checkPermission 함수를 주입하는 방법에는 치명적인 문제가 있었습니다. 바로 object가 여러 개인 경우였습니다. 예컨대 게시판 리스트를 가져오는 경우 앞서 말한 바와 같이 어떤 게시판은 조회가 가능하고 어떤 게시판은 조회가 안 되어야 하는데, 그러면 게시판 목록을 가져온 후 checkPermission 함수를 매 게시판에 대해 실행하여 권한 검사를 해 주어야 합니다. 이는 매우매우 비효율적인 구현이었습니다.

여기에서도 머리를 한참 싸매다가, 나름 괜찮은 생각을 해 냈습니다. 바로 한 개의 오브젝트를 체크하는 함수를 삽입하는 것이 아니라 그냥 허용되는 오브젝트들을 전부 가져올 수 있는 함수를 삽입하는 것입니다. 그러면 라우터 내에서 허용되는 오브젝트들을 가져온 후 SQL에서 IN 연산을 통하여 쉽게 필터링을 할 수 있습니다.

위 문단에서 '허용되는 오브젝트들의 리스트' 가 아니라 '허용되는 오브젝트들의 리스트를 가져오는 함수' 를 삽입한 이유는 불필요한 DB 질의를 막기 위해서입니다. 나중에 설명하겠지만 RBAC만으로 권한 컨트롤을 할 수 없는 경우도 있습니다. 그러면 ABAC등 추가적인 권한 시스템을 도입해야 합니다. 이 경우 오브젝트 권한 검사를 하기 전에 이미 접근 불가능함이 결정되는 경우가 있습니다. 그렇다면 굳이 오브젝트에 대한 권한 검사를 할 필요가 없으므로 라우터 내에서 오브젝트 권한 검사를 선택적으로 수행할 수 있도록 구성한 것입니다.

그리고 허용되는 모든 오브젝트를 가져오는 것이 언듯 비효율적인 것처럼 보이지만 오히려 효율적인 이유는 DB 조회 회수가 줄어들기 때문입니다. 이전의 방법에서 DB 조회가 N+1번 일어났다면 현재의 방법에서는 2번만 일어납니다. DB 조회는 레이턴시가 크기 때문에 무조건 조회가 적을수록 좋고, 따라서 현재 방법이 더 효율적입니다.

아래는 위 내용을 모두 적용한, object가 있는 경우의 라우터입니다.

const MenuPermission = RBAC.getRBACModule('menu', ['get'], async knex => {
  const menus = await knex.select('menu_pk', 'name').from('menu');
  return menus.map(x => ({ id: x.menu_pk, name: x.name }));
});

router.get('/', MenuPermission.middlewares.get, async (ctx) => {
  const allowedObjects = await ctx.getAllowedObjects();
  // allowedObjects are actually list of primary keys.
  const query = ctx.knex.select([
    'menu_pk',
    'parent_pk',
    'name',
    'url'
  ])
    .from('menu')
    .whereIn('menu_pk', allowedObjects)
    .orderBy('order', 'desc');
  // 중략
  ctx.body = result;
});

아래는 오브젝트가 없는 경우의 라우터입니다.

const SomePermission = RBAC.getRBACModule('some', ['list', 'read', 'delete']);

router.get('/some/router',
  SomePermission.middlewares.list,
  async ctx => {
  	const data = ctx.knex.select().from('some_table')
    ctx.body = data;
  });

위 예시들에서는 Permission.middlewares.get 과 같이 미들웨어 이름이 길어서 가독성이 좋지 않습니다. 이는 예시를 위한 것이고, 새로운 변수에 미들웨어를 할당하여 간단히 짧게 줄일 수 있습니다.

User Model과 분리는?

앞의 구현을 보면 미들웨어 내부에서 유저 정보를 이미 알고 있다고 가정했습니다. 그런데 사실은 이렇게 하면 유저 모델에 종속성이 생기게 됩니다. 미들웨어 내부에서 유저 정보가 어떤 변수에 어떤 이름으로 저장되어있을지 결정하는 것은 유저 모델인데, 이것에 따라 권한 시스템의 소스코드가 바뀌어야 하기 때문입니다. 따라서 저는 이런 종속성을 해결하기 위해 RBAC 모듈 자체를 초기화할 때 Koa의 ctx 오브젝트로부터 유저 정보, 즉 RBAC에서 subject에 해당하는 정보를 추출하는 함수를 파라매터로 받도록 하였습니다.

// ../config/rbacConfig.js
const RBAC = require("../libs/rbac");
const ENV = require('../env.json');

function getSubject(ctx) {
  if(!ctx.session) return -1; // -1 means users without login.
  if(!ctx.session.user) return -1;
  return ctx.session.user.user_pk;
}

const rbac = new RBAC(ENV.db, getSubject);

module.exports = rbac;

실제로 RBAC 모듈을 사용할 때에는 rbac 모듈을 바로 import하는 것이 아니라 이 config파일을 import하여 사용합니다. 이렇게 함으로써 유저 모델과 권한 모델을 완전히 분리할 수 있습니다.

즉, rbac 모듈(../libs/rbac)에서 export하는 것은 class이고, config 파일에서 export하는 것은 class의 instance입니다.

즉, 이 권한 모델은 당장 라이브러리화하여 우리 동아리 홈페이지뿐만이 아니라 다른 서비스에 적용하는 것이 가능하다는 의미입니다.

'소유자' 개념은?

여태까지는 RBAC를 다루었지만 사실 모든 경우에 RBAC가 사용가능한 것은 아닙니다. 왜냐하면 어떤 게시글을 삭제하는 권한이 있다면 이 권한은 (관리자를 제외하면) 오직 그 게시글을 작성한 사람에게만 허용되어야 할 텐데, 이는 'object와 subject의 관계'에 따라 권한이 달라지기 때문입니다. 그런데 RBAC에서는 오직 object와 role, permission과 role의 N:M 관계만 존재할 뿐 object와 subject의 직접적인 관계는 존재하지 않습니다. 그러므로 이러한 관계에 기반한 권한 부여는 원리적으로 불가능하며, 따라서 이런 권한 체크는 Attribute-Based Access Control (ABAC) 등을 도입하여 해결해야 합니다.

실제로는 유저 한 명만을 포함하는 role들을 유저 수만큼 생성하면 되기는 합니다. 그런데 그럴 경우 앞서 언급한 '이상적인 권한 시스템'과 같은 이유로 권한 개수가 폭증, 관리가 불가능하게 됩니다.

그래서 이 문제를 오랫동안 고민했습니다. 그렇지만 사실상 위와 같은 삭제 API 몇 개를 제외하면 ABAC가 필요한 부분은 크게 많지 않다고 판단, 별도의 권한 시스템의 도입 없이 게시판 시스템 소스코드 단계에서 구현하기로 결정했습니다. 만약 이런 구현이 더 많이 필요해지게 된다면 물론 그때는 별도의 ABAC시스템을 구현할 것입니다.

결론

RBAC 정책 모델을 Koa 기반으로 구현해보았습니다. 위 구현은 모듈화가 잘 되어 있기는 하지만 사실 Koa 라이브러리에 아주 약간의 종속성이 있습니다. 그러나 Express 등을 지원하도록 확장하는 것이 매우 간단하고, 심지어 (DB를 분리하고 API형태로 사용할 수 있도록) 하나의 독립된 서비스로 만드는 것까지 가능하다는 것 등을 고려하면 실질적인 종속성은 없다고 보아도 무방하다고 생각합니다.

Knex는 내부적으로만 사용하므로 종속성이 아닙니다. 위에서 언급한 종속성이란 '제가 디자인한 이 권한 라이브러리를 쓰기 위해 라이브러리를 사용하는 쪽에서 제약되는 것'을 말합니다.


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -