levish
levish
이전으로

위키 권한을 숫자 하나로 끝낼 수 있을까?

단순한 권한 등급과 그래프 권한 실험을 거쳐 문서, 네임스페이스와 전역 범위의 ordered ACL을 만든 과정.

Sevenwiki — The Knowledge Infrastructure

August 31, 2026

개발SevenwikiAuthorizationPostgreSQL

처음에는 admin > moderator > user면 될 줄 알았습니다

초기 Sevenwiki의 문서 권한은 익숙한 등급제로 시작했습니다.

everyone < user < trusted < moderator < admin

문서에 필요한 최소 등급을 하나 저장하고, 현재 사용자의 등급이 같거나 높으면 허용합니다.

공개 문서를 누구나 읽게 하고, 중요한 문서는 trusted 이상만 편집하게 하는 정도라면 이 방식이 아주 편했습니다. 비교 한 번이면 끝나고, 관리 화면도 드롭다운 하나로 만들 수 있었습니다.

그런데 실제 위키에서 필요한 규칙은 한 줄로 세울 수 있는 등급표보다 훨씬 복잡했습니다.

  • 가입한 지 7일이 지난 사용자만 편집하게 하고 싶었습니다.
  • 특정 프로젝트의 ACL 그룹만 읽을 수 있는 문서도 필요했습니다.
  • 차단된 유동 IP는 막되, 같은 공용 IP를 사용하는 로그인 사용자까지 자동으로 막으면 안 됐습니다.
  • 문서에 별도 정책이 없으면 네임스페이스의 기본값을 사용해야 했습니다.
  • 읽기, 편집, 이동, 삭제와 ACL 변경 권한은 서로 달라야 했습니다.

등급을 계속 추가하면 이름만 길어졌습니다.

trusted-but-not-new-user, document-moderator, acl-manager 같은 상태가 생기기 시작하면 누가 누구보다 높은지조차 분명하지 않습니다. moderator가 특정 ACL 그룹보다 항상 강한 권한인지도 숫자로는 답하기 어렵습니다.

권한은 단순한 높낮이가 아니라 조건과 행동의 조합이었습니다.

그래프 권한이라면 해결되지 않을까?

이 문제를 보면서 SpiceDB 같은 관계 기반 권한 모델도 실험했습니다.

사용자와 그룹, 문서의 관계를 그래프로 표현하면 다음과 같은 규칙은 꽤 자연스럽습니다.

user
  └─ member of group
       └─ can_edit document

Rust에서 사용할 클라이언트가 마땅치 않아 spicedb-rs-client도 직접 만들어봤습니다. 권한을 관계로 표현하고 중앙에서 질의하는 방식 자체는 흥미로웠고, 조직형 서비스라면 잘 맞는 부분도 많았습니다.

하지만 V7의 문서 권한에는 관계만으로 끝나지 않는 요구가 있었습니다.

문서 규칙을 먼저 보고, 결론이 없을 때만 네임스페이스와 전역 정책으로 넘어가야 했습니다. 규칙의 순서도 사용자에게 보여주고 그대로 평가해야 했습니다. 정책 변경 이력을 리비전으로 남기고, 검색 후보를 줄이기 위한 로컬 값도 같은 트랜잭션에서 계산해야 했습니다.

무엇보다 Region의 일반 요청이 중앙 권한 서비스에 동기 의존하는 구조도 피하고 싶었습니다.

그래프 권한이 나쁘다기보다, 당시 V7이 필요로 한 정책의 모양과 운영 경계에 맞춰 직접적인 ACL 모델을 두는 편이 더 단순했습니다.

실험했던 코드는 최종 구조에 그대로 남지 않았지만, 어떤 권한을 관계로 볼 수 있고 어떤 권한은 순서가 있는 정책으로 봐야 하는지 생각하는 데는 많은 도움이 됐습니다.

결국 작업마다 별도의 ACL을 두었습니다

V7에서는 하나의 protection_level 대신 행동별 ACL을 사용합니다.

document:read
document:edit
document:move
document:delete
document:hide
document:revert
document:acl

문서를 읽을 수 있다고 삭제할 수 있는 것은 아닙니다. 편집 권한과 ACL을 바꿀 권한도 다릅니다.

정책은 좁은 범위부터 넓은 범위로 확인합니다.

document → namespace → global

아직 존재하지 않는 문서를 만들 때는 문서 범위가 없으니 namespace → global 순서로 봅니다. 게시판은 board → global처럼 자신에게 맞는 범위를 사용합니다.

각 규칙에는 조건과 결과가 있습니다.

조건
  - access_level
  - role
  - acl_group
  - user_age_days

결과
  - allow
  - deny
  - next_scope

같은 범위 안에서는 position 순서대로 규칙을 읽고, 처음으로 조건이 맞는 규칙이 결정을 내립니다. next_scope가 나오면 더 넓은 범위로 넘어갑니다.

관리 화면에 보이는 순서와 서버가 평가하는 순서를 같게 만들고 싶었습니다. 조건을 SQL 한 줄로 합쳐놓고 나중에 어떤 규칙 때문에 허용됐는지 설명하지 못하는 구조는 피했습니다.

규칙이 없는 것과 조건이 안 맞는 것은 달랐습니다

ACL을 만들며 의외로 오래 고민한 부분입니다.

어떤 문서에 편집 규칙이 아예 없다면 네임스페이스 기본 정책을 사용하면 됩니다. 하지만 편집 규칙을 여러 개 설정해두었는데 현재 사용자가 어느 조건에도 맞지 않는다면 어떻게 해야 할까요?

V7에서는 둘을 다르게 처리합니다.

해당 action의 규칙이 아예 없음
  → 다음 scope로 이동

규칙은 있지만 아무 조건도 맞지 않음
  → deny

모든 scope를 끝까지 확인했는데 결론이 없음
  → deny

정책을 명시적으로 만든 순간 그 목록을 닫힌 목록으로 봅니다. 운영자가 특정 사용자들만 허용하려고 규칙을 만들었는데, 조건이 맞지 않는 사용자가 암묵적으로 상위 정책에서 허용되는 상황을 막기 위해서입니다.

next_scope는 상위 정책으로 넘기고 싶다는 의도를 규칙 안에 직접 적게 합니다.

이런 작은 기본값 하나가 ACL의 실제 의미를 크게 바꿨습니다.

차단과 관리자 복구는 규칙보다 앞에 뒀습니다

모든 예외를 ACL 행으로 표현하려고도 해봤지만 오히려 규칙이 복잡해졌습니다.

차단된 사용자와 유동 IP의 참여 작업은 일반 ACL을 보기 전에 먼저 거부합니다. 로그인 사용자와 익명 IP의 상태를 따로 계산해, 학교나 카페처럼 여러 사람이 쓰는 공용 IP가 차단됐다고 로그인 사용자 모두를 막지는 않습니다.

반대로 차단되지 않은 admin은 ACL보다 먼저 허용합니다.

잘못 만든 deny all 정책 때문에 관리자까지 ACL을 고칠 수 없게 되면 복구하기 매우 어렵습니다. 그래서 관리자에게는 명시적인 anti-lockout 경로를 둡니다.

이 예외를 각 조건 검사에 섞지 않고 AclChain::evaluate의 시작점에서 한 번만 처리합니다.

예를 들어 role 조건 안에서만 관리자 예외를 처리하면, 오히려 admin 역할을 대상으로 한 deny 규칙에 관리자가 먼저 걸리는 이상한 결과가 생길 수 있습니다.

권한 엔진에서는 어떤 예외가 있는지만큼 언제 적용되는지도 중요했습니다.

ACL도 문서처럼 리비전으로 남겼습니다

현재 규칙 행만 덮어쓰면 누가 어떤 정책을 바꿨는지 알 수 없습니다. 잘못된 설정을 이전 상태로 돌리기도 어렵습니다.

V7의 ACL 변경은 (대상 범위, action)의 규칙 목록 전체를 새 리비전으로 저장합니다.

클라이언트는 자신이 보고 있던 expected_revision_id를 함께 보냅니다. 관리 화면을 연 뒤 다른 관리자가 정책을 먼저 바꿨다면, 오래된 화면의 저장 요청이 최신 정책을 조용히 덮어쓰지 못합니다.

문서 읽기 ACL을 바꾸는 경우에는 문서 메타데이터와 정책을 정해진 순서로 잠급니다. 새 ACL 리비전을 저장한 뒤, 검색 후보에 사용하는 effective_read_level도 같은 트랜잭션에서 다시 계산합니다.

ACL 그룹을 참조하는 규칙이라면 해당 그룹도 잠급니다. 그룹 삭제와 ACL 수정이 동시에 일어나도 존재하지 않는 그룹을 가리키는 규칙이 커밋되지 않게 하기 위해서입니다.

문서 본문에 리비전이 필요했던 것처럼 정책에도 과거와 동시성 제어가 필요했습니다.

ACL을 바꿀 권한으로 자기 권한을 키우면 안 됐습니다

문서 ACL을 수정할 수 있는 사용자가 새 규칙으로 자신에게 더 강한 권한을 주면 어떻게 될까요?

예를 들어 문서의 편집 ACL만 바꿀 수 있는 moderator가 자신에게 삭제 권한까지 허용하는 규칙을 추가할 수 있다면 자기 권한 확대가 됩니다.

V7은 문서 ACL 변경에서 두 가지를 확인합니다.

첫째, admin이 아닌 사용자는 자신의 등급보다 높은 접근 등급이나 역할을 요구하는 규칙을 만들 수 없습니다.

둘째, 새로 바꾸려는 action을 네임스페이스와 전역 정책이 현재 사용자에게 이미 허용하는지 확인합니다. 문서 범위의 allow를 이용해 상위 정책에서 막아둔 삭제나 ACL 변경을 우회하지 못하게 합니다.

이 검사와 실제 정책 저장은 같은 트랜잭션 안에서 이루어집니다. 권한을 확인한 직후 상위 정책이 바뀌는 경쟁 조건을 막기 위해서입니다.

ACL 편집 권한은 다른 권한을 마음대로 만드는 만능 권한이 아니었습니다.

검색 색인에 모든 권한을 넣을 수는 없었습니다

문서 읽기 ACL이 access level 하나라면 Meilisearch에서도 비교하기 쉽습니다.

하지만 그룹, 역할, 계정 나이, 만료 시각이 들어가면 검색 문서 하나만으로 모든 사용자의 권한을 계산할 수 없습니다.

V7은 effective_read_level을 검색 후보를 줄이는 값으로만 사용합니다.

읽기 규칙이 접근 등급만 사용한다면 허용되는 가장 낮은 등급을 계산합니다. 특정 그룹이나 계정 나이처럼 개인별 조건이 하나라도 들어가면 Everyone으로 낮춥니다.

후자의 경우 검색 후보가 많아질 수 있습니다. 그래도 실제로 볼 수 있는 문서를 검색 단계에서 잘못 버리는 것보다는 낫습니다.

Meilisearch에서 넓게 후보 조회
             ↓
Region DB에서 현재 ACL과 숨김 상태 확인
             ↓
실제 응답

effective_read_level만 보고 문서를 보여주지는 않습니다. 검색, 최근 변경, 기여 목록, 역링크와 SSE는 응답 직전에 정확한 ACL을 다시 평가합니다.

검색을 빠르게 하는 값과 문서를 공개해도 된다는 결론을 같은 것으로 취급하지 않았습니다.

단순한 등급표보다 훨씬 귀찮아졌습니다

Ordered ACL은 처음의 드롭다운 하나보다 확실히 복잡합니다.

운영자는 규칙의 순서와 next_scope, 조건이 하나도 맞지 않을 때의 거부, 만료 시각과 상위 정책을 이해해야 합니다. 정책을 잘못 바꿨을 때를 대비해 리비전과 복구 경로도 필요합니다.

서버도 검색 결과와 최근 변경 같은 여러 경로에서 정확한 권한을 다시 계산해야 합니다. ACL 그룹이나 정책이 자주 바뀐다면 캐시 무효화와 색인 갱신 비용도 생깁니다.

그럼에도 직접 ACL을 만든 이유는 권한 규칙이 각 handler의 if문으로 흩어지는 모습을 이미 봤기 때문입니다.

문서 읽기와 편집, 삭제, ACL 변경, 검색 노출이 서로 다른 규칙을 해석하면 언젠가 한 경로에서 반드시 새게 됩니다.

현재 V7의 ACL이 모든 서비스에 통용되는 정답이라고 생각하지는 않습니다. 다만 위키의 문서·네임스페이스·전역 정책과 리비전, 로컬 검색 구조를 함께 다루기에는 지금의 형태가 가장 설명 가능했습니다.

다음 글에서는 이 ACL을 문서 편집 요청 안에서 실제로 어떻게 다시 확인하는지, 그리고 R2와 PostgreSQL, 검색과 알림까지 얽힌 저장 버튼 하나를 어디까지 트랜잭션으로 묶었는지 이야기해보겠습니다.

시리즈 08 / 12
© 2026 levish