soobook
BACKEND

Zanzibar와 ReBAC

이 사용자가 이 문서를 읽을 수 있는가?

권한 시스템은 보통 코드 곳곳에 흩어진다.

if user.role == "admin", if doc.owner_id == user.id, 미들웨어의 권한 체크, 화면을 그릴 때의 버튼 노출 여부까지.

서비스가 커지면 이 분기들이 일관성을 잃는다. API는 막았는데 GraphQL은 열려 있고, 백오피스만 다른 규칙을 쓴다.

Zanzibar의 출발점은 단순하다.

권한을 코드가 아니라 데이터로 다룬다.

모든 권한 관계를 한 가지 형식의 레코드로 저장하고, “할 수 있는가”라는 질문을 그 데이터 위의 그래프 탐색으로 환원한다.

권한 로직을 하나의 시스템으로 모으면, 어느 진입점으로 들어오든 같은 답이 나온다.

ACL에서 RBAC, 그리고 ReBAC

권한 모델은 대략 세 세대를 거쳤다.

가장 원시적인 형태는 ACL(Access Control List)이다.

객체마다 “누가 무엇을 할 수 있는지” 목록을 붙인다. 직관적이지만 사용자가 늘면 목록이 폭발한다.

신입이 들어올 때마다 수백 개 문서의 ACL을 건드려야 한다.

RBAC(Role-Based Access Control)은 사람과 권한 사이에 역할(role)이라는 중간 계층을 넣었다.

사람에게 역할을 주고 역할에 권한을 묶는다. 신입은 역할만 받으면 된다.

대부분의 서비스가 여기서 멈춘다. 그런데 RBAC은 “전역 역할”에 강하고 “객체별 관계”에 약하다.

admin은 잘 표현하지만, “이 문서의 편집자”나 “이 프로젝트의 뷰어”처럼 특정 객체에 매인 권한을 다루려면 역할이 객체 수만큼 늘어난다.

ReBAC(Relationship-Based Access Control)은 권한을 객체와 주체 사이의 관계로 본다. 핵심 질문이 바뀐다.

 

권한 = 관계 그래프 위에 경로가 존재하는가\textbf{\text{권한 = 관계 그래프 위에 경로가 존재하는가}}

 

alicedoc:readme를 읽을 수 있는가? 라는 질문은, 관계 그래프에서 alice라는 노드에서 출발해 doc:readmeviewer 노드까지 닿는 경로가 있느냐로 환원된다.

직접 뷰어로 등록됐을 수도 있고, 뷰어 권한을 가진 팀의 멤버일 수도 있고, 상위 폴더의 편집자라서 자동으로 내려받았을 수도 있다. 경로의 모양은 다양하지만 질문은 하나다.

 

닿는가?\textbf{\text{닿는가?}}

 

RBAC을 버리는 게 아니다. ReBAC은 RBAC의 상위집합에 가깝다.

“전역 역할”은 ReBAC에서 루트 객체에 매단 관계로 표현하면 그만이다. 표현력이 더 넓을 뿐이다.

Relation Tuple

ReBAC의 모든 데이터는 단 하나의 형식으로 저장된다. Zanzibar는 이것을 relation tuple이라 부른다.

⟨object⟩#⟨relation⟩@⟨subject⟩

읽는 법은 “이 object에 대해 이 subject가 이 relation을 가진다”이다. 예를 들어,

doc:readme#viewer@user:alice

doc:readme 문서에 대해 user:aliceviewer 관계를 가진다. 즉 alice는 readme의 뷰어다.

권한 하나가 정확히 한 줄이다. 이 한 줄들의 집합이 시스템 전체의 권한 상태다.

여기까지는 ACL과 다를 게 없어 보인다. 진짜 표현력은 subject 자리에 다른 객체의 relation을 넣을 수 있다는 데서 나온다.

doc:readme#viewer@group:eng#member

group:engmember모든 사용자가 doc:readme의 뷰어다.

subject가 한 사람이 아니라 “어떤 객체의 어떤 관계를 만족하는 집합”이 된다. 이것을 userset이라 부른다.

@user:alice처럼 끝이 사람으로 끝나면 concrete subject, @group:eng#member처럼 다시 관계로 끝나면 userset이다.

userset 덕분에 권한이 그래프가 된다.

tuple 하나하나는 그래프의 간선(edge)이고, userset은 그 간선이 또 다른 노드의 부분그래프를 가리키게 만든다.

flowchart LR
    alice["user:alice"]
    bob["user:bob"]
    eng["group:eng#member"]
    folder["folder:docs#editor"]
    readme["doc:readme#viewer"]

    alice -->|member| eng
    bob -->|member| eng
    eng -->|viewer| readme
    folder -->|상속| readme

    classDef subj fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef obj fill:#3d2a4d,stroke:#9b6fb5,color:#fff
    class alice,bob subj
    class eng,folder,readme obj

alice가 readme를 읽을 수 있는 이유는, alice가 group:eng의 멤버이고 그 그룹이 readme의 뷰어이기 때문이다.

ACL이라면 alice와 bob을 readme에 일일이 등록했겠지만, 여기서는 그룹 멤버십 하나만 바꾸면 그룹이 가진 모든 권한이 따라 움직인다.

tuple을 저장하는 테이블은 의외로 단순하다.

(object_type, object_id, relation, subject_type, subject_id, subject_relation) 여섯 column이면 충분하다.

subject_relation이 비어 있으면 concrete subject, 채워져 있으면 userset이다.

조회 패턴이 두 방향(객체로부터 / 주체로부터)이므로 그에 맞춘 복합 인덱스를 깐다.

Schema: relation과 permission

tuple만으로는 “alice가 readme를 편집할 수 있는가”에 답하기 어렵다.

편집 권한은 보통 직접 부여하기보다 “편집자이거나 소유자이면 편집 가능” 같은 규칙으로 정의되기 때문이다.

그래서 Zanzibar 계열은 스키마를 둔다. SpiceDB는 이를 .zed 스키마 언어로 표현한다.

definition user {}

definition group {
    relation member: user | group#member
}

definition folder {
    relation parent: folder
    relation editor: user | group#member
    permission edit = editor + parent->edit
}

definition document {
    relation parent: folder
    relation owner: user
    relation viewer: user | group#member

    permission edit = owner + parent->edit
    permission view = viewer + edit
}

여기서 두 종류의 선언이 갈린다.

relation은 tuple로 직접 저장되는 관계다. viewer, owner, parent는 데이터베이스에 실제 행으로 들어간다.

누군가 “alice를 viewer로 추가”하면 tuple 한 줄이 생긴다.

permission은 저장되지 않는다. relation들을 조합해 계산되는 관계다.

view = viewer + edit은 “뷰어이거나, 편집할 수 있으면 볼 수 있다”는 규칙이다.
edit = owner + parent->edit은 문서의 owner 이거나, parent 의 edit 을 평가하라는 규칙이다. 상위 폴더를 편집할 수 있다면 그 안의 문서도 편집할 수 있다는 상속을 뜻한다.

permission은 행으로 존재하지 않고 Check 시점에 규칙을 펼쳐 평가한다.

Zanzibar 용어로는 이 규칙을 userset rewrite라 부른다.

이 부분이 ReBAC의 핵심 아이디어다. relation은 “사실”(누가 무엇이다), permission은 “정책”(어떤 사실들이 모이면 무엇을 할 수 있다).

사실은 데이터로 저장하고 정책은 스키마로 버전 관리한다. 정책을 바꾸고 싶으면 데이터를 마이그레이션하는 게 아니라 스키마를 배포한다.

Rewrite Rule

permission을 어떻게 계산하는지가 rewrite rule이다. 종류는 많지 않다.

Direct

this라고도 한다. 가장 단순한 규칙으로, “이 relation을 직접 가진 tuple이 있으면 허용”이다.

permission view = viewer라면, doc:x#viewer@user:alice tuple이 있는지만 확인한다.

단, viewer가 userset을 가리킬 수 있으므로 “직접”이라 해도 한 단계는 더 들어간다.

doc:x#viewer@group:eng#member가 있으면 alice가 group:eng의 멤버인지 재귀적으로 확인한다.

Union / Intersection / Exclusion

permission은 집합 연산으로 조합된다.

edit = owner + editor처럼 +로 묶으면 union이다.

둘 중 하나만 만족해도 허용. 평가할 때는 규칙들을 차례로 보다가 하나라도 Allow가 나오면 즉시 멈춘다.

approved & published처럼 &로 묶으면 intersection이다.

모두 만족해야 허용. “관리자이면서 2단계 인증을 통과한 경우”처럼 조건을 좁힐 때 쓴다.

viewer - banned처럼 -로 묶으면 exclusion이다. 앞은 만족하되 뒤는 만족하지 않아야 허용. 차단 목록을 표현한다.

대부분의 경우 union만으로도 대부분의 권한이 표현된다.

union은 “첫 Allow에서 멈춤”이지만, intersection은 모든 브랜치가 Allow여야 하고 exclusion은 부정 결과를 다뤄야 해서 더 복잡하다.

Tuple-to-Userset

가장 강력한 규칙이다. 화살표 표기(parent->edit)로 쓴다. 직역하면 “이 객체의 parent를 따라가서, 그 부모에서 edit을 평가하라”이다. 폴더-문서 상속이 정확히 이 패턴이다.

permission edit = owner + parent->edit

문서의 편집 권한은 “문서의 소유자이거나, 상위 폴더에서 편집할 수 있으면” 성립한다.

폴더에서 다시 edit을 평가하면 폴더의 소유자거나 또 그 상위 폴더에서 편집 가능한지를 본다. 한 번의 화살표가 계층 전체의 상속을 만든다.

flowchart TD
    root["folder:root"]
    docs["folder:docs"]
    readme["doc:readme"]
    alice["user:alice"]

    alice -->|owner| root
    docs -->|parent| root
    readme -->|parent| docs

    classDef subj fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef obj fill:#3d2a4d,stroke:#9b6fb5,color:#fff
    class alice subj
    class root,docs,readme obj

alice는 folder:root의 소유자다. readme를 편집할 수 있는가?

readme에는 owner가 없다. 그래서 parent를 따라 docs로 올라가 edit을 평가하고, docs에도 없으니 다시 parent를 따라 root로 올라간다.

root에서 alice가 owner이므로 edit이 성립한다. 화살표를 두 번 따라가 경로를 찾았다.

이때, 화살표 대상을 구분하는 게 중요하다.

tuple-to-userset의 via relation(parent)을 따라갈 때, 그 부모 tuple의 subject는 userset이 아니라 객체 모양이어야 한다.

반대로 direct relation을 펼칠 때 만나는 subject는 userset일 때만 재귀한다. 같은 그래프 순회지만 간선의 종류에 따라 진행 방향이 갈린다.

조금 더 풀어서 설명해보자면, 저장 형식은 똑같이 object#relation@subject이지만, relation이 무엇이냐에 따라 subject가 가리키는 게 달라진다는 것이다.

둘 중 하나이다.

  • 주체 관계 (viewer, owner, member)
  • 객체 관계 (parent)

주체 관계인 경우에는 subject 자리에 “누가 이 권한을 갖나”가 들어오고, 객체 관계인 경우에는 “이 객체가 어느 객체에 매달려 있나”가 들어온다.

그래서 진행 방향이 갈린다. 주체 관계의 subject가 concrete(user:alice)면 찾던 사람과 비교하고 끝이지만, userset(group:eng#member)이면 “그 집합에 속하나”를 또 풀어야 해서 재귀한다.

반면 객체 관계는 “저 객체로 점프해 다시 평가”하는 연산이라, 점프할 목적지가 단일 객체로 정해져야 한다.

userset은 객체가 아니라 집합이라 “어느 객체로 갈지”를 못 정한다.

그래서 객체 관계의 subject는 userset이 아니라 객체여야 한다.

Check: 그래프를 따라가기

권한 질문에 답하는 연산이 Check(object, permission, subject)다. 알고리즘은 본질적으로 그래프 탐색이다.

  1. permission을 rewrite rule로 펼친다.
  2. 각 규칙을 평가한다.
    • direct: 해당 relation의 tuple을 찾는다. concrete subject면 일치 여부를 보고, userset이면 그 userset을 향해 한 단계 더 Check한다.
    • tuple-to-userset: via relation으로 부모를 찾고, 부모에서 target permission을 다시 Check한다.
  3. union이면 하나라도 Allow면 즉시 Allow, intersection이면 전부 Allow여야, exclusion이면 앞은 Allow 뒤는 Deny여야 한다.

각 단계가 또 다른 Check를 부르므로 재귀다.

한 번의 질문이 여러 갈래로 갈라지며(fan-out) 그래프를 훑는다.

alice가 readme를 보는지 묻는 한 줄이, 그룹 멤버십과 폴더 상속을 동시에 펼치는 트리 탐색으로 번진다.

flowchart TD
    q["Check(doc:readme, view, alice)"]
    q --> v{"view = viewer + edit"}

    v -->|viewer| vw{"Direct tuple?<br/>doc:readme#viewer@alice"}
    vw -->|없음| us{"userset?<br/>doc:readme#viewer@group:eng#member"}
    us -->|"Check(group:eng, member, alice)"| mem{"alice가 eng 멤버?"}
    mem -->|예| a1(["Allow"])
    a1 --> done(["view = Allow<br/>union이므로 edit 평가 생략"])

    v -.->|edit| ed{"edit = owner + parent->edit"}
    ed -.->|owner| ow["doc:readme#owner@alice?"]
    ed -.->|"parent->edit"| ar["folder:docs로 점프<br/>Check(folder:docs, edit, alice)"]

    classDef chk fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef dec fill:#3d2a4d,stroke:#9b6fb5,color:#fff
    classDef ok fill:#1f3d2a,stroke:#4ade80,color:#fff
    class q,ow,ar chk
    class v,vw,us,mem,ed dec
    class a1,done ok

규모가 커지면 이 탐색이 분산된다. Zanzibar는 Check 요청을 여러 서버에 분산(fan-out)하되, 같은 부분 질문은 같은 서버로 보낸다.

consistent hashing으로 (object, permission, subject)를 라우팅 키로 삼으면, 동일한 서브 체크가 항상 같은 노드에 도착해 그 노드의 캐시를 재사용한다.

노드별 캐시가 독립적이어도 전체적으로는 하나의 캐시처럼 동작하는 착시를 만든다.

Cycle Detection

관계 그래프는 순환할 수 있다. 그룹 A가 그룹 B를 멤버로 갖고, B가 다시 A를 멤버로 가지면, 멤버십 Check가 A와 B를 무한히 오간다.

스키마가 의도적으로 막지 않는 한 데이터 차원에서 사이클은 언제든 생긴다.

대응은 두 갈래다.

하나는 visited set이다. 순회 중 방문한 (object, relation, subject)를 기록해두고, 다시 만나면 그 가지를 끊는다.

정확하지만 요청마다 방문 집합을 들고 다녀야 해서 메모리와 직렬화 비용이 붙는다.

분산 Check에서는 이 집합을 서버 간에 넘겨야 하는 부담도 있다.

다른 하나는 depth budget이다. Check에 재귀 예산(가령 50)을 주고, 한 단계 내려갈 때마다 1씩 깎는다.

0이 되면 더 내려가지 않고 Indeterminate(판단 불가)를 반환한다.

 

depth0    Indeterminate\text{depth} \le 0 \;\Rightarrow\; \textbf{Indeterminate}

 

사이클은 결국 예산을 소진시키므로 무한 루프 대신 깔끔하게 멈춘다.

정확히 사이클을 “감지”하는 건 아니지만, 무한 재귀를 막는다는 실용적 목표는 달성한다.

SpiceDB도 이 방식을 쓴다. 방문 집합을 들고 다니지 않아 분산 환경에서 단순하다는 게 결정적 장점이다.

정상적인 권한 그래프의 깊이는 수십을 넘지 않으므로, 충분히 큰 예산을 주면 정당한 경로가 잘려나갈 일은 없다.

Indeterminate를 별도 상태로 둔 게 중요하다. Allow도 Deny도 아닌 “예산 부족으로 판단 못 함”을 명시적으로 구분하면, 이 결과를 캐시에 넣지 않을 수 있다.

재시도하면 답이 달라질 수 있기 때문이다.

예를 들어 원래 Allow 인데 너무 높은 곳에서 체크를 시작해서 Indeterminate 된 경우에, 더 얕은 곳에서 시작하면 Allow 가 뜰 수도 있다.

union 평가 중 한 가지가 Indeterminate여도, 다른 가지에서 Allow가 나오면 전체는 Allow다. Indeterminate는 “확정 Deny”와 다르게 누적된다.

Consistency

권한 데이터에는 모순된 두 요구가 걸린다.

첫째, Check는 빨라야 한다. 모든 API 요청이 권한을 확인하므로 권한 시스템은 가장 Hot path다. 그래서 공격적으로 캐싱한다.

둘째, 권한은 정확해야 한다. 특히 권한을 회수했을 때, 회수가 즉시 반영되지 않으면 보안 사고다. 캐싱과 정확성이 정면충돌한다.

거창하게 말하지만 원래 다른 도메인에서도 우리가 왕왕 다루던 문제다.

New Enemy Problem

캐싱이 권한 회수와 충돌하는 대표적 시나리오가 New Enemy Problem이다.

이름 그대로, 권한을 회수당한 이 회수 이후에도 데이터를 보게 되는 문제다.

Zanzibar 논문은 두 가지 형태를 든다. 등장인물은 셋이다.

  • 권한을 관리하는 Alice
  • 방금 접근을 회수당한 Bob
  • 새 문서나 콘텐츠를 추가하는 Charlie.

주인공은 두 형태 모두 제거된 Bob이다.

ACL 변경들의 순서가 뒤집힌다.

  1. Alice가 Bob을 폴더의 ACL에서 뺀다.
  2. Alice가 Charlie에게 새 문서를 그 폴더로 옮기게 한다. 문서의 권한은 폴더 ACL을 상속한다(parent->view).
  3. Bob은 새 문서를 보면 안 된다. 그런데 권한 체크가 두 변경의 순서를 무시하면, “Bob이 아직 폴더 뷰어인” 스냅샷에서 문서를 평가해 Bob이 새 문서를 본다.
sequenceDiagram
    participant Alice
    participant Charlie
    participant Z as Authz System
    Note over Alice,Z: op1: Bob을 폴더에서 제거
    Alice->>Z: remove folder:X#35;viewer@bob
    Note over Charlie,Z: op2: 새 문서를 폴더로 (ACL 상속)
    Charlie->>Z: add doc:new#35;parent@folder:X
    Note over Z: op2를 op1 이전 스냅샷으로 평가하면<br/>Bob이 새 문서를 본다 (new enemy)

앞서 본 폴더 상속(tuple-to-userset)이 여기서 문제가 된다.

Bob 제거는 폴더 tuple 하나를 지우는 일이고, 문서 추가는 별개의 parent tuple을 만드는 일이다.

두 쓰기가 서로 다른 객체를 건드리므로, 인과 순서를 명시적으로 묶지 않으면 체크가 둘을 뒤섞어 본다.

여기서 헷갈리기 쉽다. op1과 op2는 서로 다른 tuple이라 충돌하지 않는다.

둘 다 저장소에 정상 반영되고, 최종 상태는 “Bob 빠짐 + 새 문서는 폴더 자식”으로 언제나 같다.

문제는 쓰기끼리가 아니라 읽기가 보는 스냅샷에서 생긴다. op2는 반영됐는데 op1은 아직인 그 틈에 Bob의 Check(op3)가 끼어들면, “새 문서는 있는데 Bob은 아직 폴더 뷰어”인, 현실에 존재한 적 없는 중간 상태를 읽는다.

op1이 op2를 덮어쓰는 write 충돌이 아니라, Check가 “나중 쓰기는 보면서 먼저 쓰기는 못 보는” 비일관 스냅샷을 읽는 read 문제다.

옛 ACL을 새 콘텐츠에 적용한다.

  1. Alice가 Bob을 문서의 ACL에서 뺀다.
  2. Alice가 Charlie에게 그 문서에 새 내용을 적게 한다.
  3. Bob은 새 내용을 보면 안 된다. 그런데 체크가 “Bob을 빼기 전”의 stale한 ACL로 평가되면, Bob이 새 내용을 읽는다.

첫 번째가 두 권한 변경 사이의 순서 문제라면, 두 번째는 권한 변경과 외부 콘텐츠 변경 사이의 순서 문제다.

핵심은 권한 시스템이 외부 사건(문서 내용 변경)과의 인과 순서까지 존중해야 한다는 것이다.

그냥 “최신을 읽으면” 되는 게 아니라, “적어도 내가 알고 있는 그 변경 이후”를 읽어야 한다.

Zookie

이 문제를 푸는 도구가 zookie다(consistency token, SpiceDB에서는 ZedToken).

쉽게 말해 버전 워터마크다.

권한을 바꾸는 쓰기가 일어나면, 시스템은 그 쓰기에 대응하는 버전(revision)을 담은 zookie를 돌려준다.

애플리케이션은 이 zookie를 권한과 함께 보호 대상 옆에 저장해둔다.

가령 문서를 저장할 때, 그 시점의 권한 zookie도 같이 적는다.

나중에 그 문서로 Check할 때 zookie를 함께 넘기면, 시스템은 “적어도 이 zookie 시점 이후의 데이터로 판단하라”는 하한선을 받는다.

이렇게 하면 캐시가 zookie보다 오래된 경우 캐시를 건너뛰고 최신을 읽는다.

sequenceDiagram
    participant App as Application
    participant Z as Authz System
    participant C as Cache

    Note over App,Z: 쓰기: 권한 변경
    App->>Z: write (tuple 변경)
    Z-->>App: zookie (rev=42)
    Note over App: 문서와 zookie(42)를 함께 저장

    Note over App,Z: 읽기: Check
    App->>Z: Check(doc, view, user) + zookie(42)
    Z->>C: 캐시 조회
    alt 캐시가 rev 42보다 오래됨
        C-->>Z: stale (건너뜀)
        Z->>Z: rev >= 42 데이터로 재평가
    else 캐시가 충분히 fresh
        C-->>Z: hit (rev >= 42)
    end
    Z-->>App: 결정 + 새 zookie

New Enemy의 두 형태 모두, “문서를 마지막으로 만진 시점의 권한 상태 이후”를 강제하므로 막힌다.

인과 순서가 zookie라는 토큰을 통해 데이터에 박제된다.

zookie는 불투명해야 한다. 클라이언트가 내부 버전 숫자를 직접 보거나 위조하면 안 된다.

구현 관점에서 깔끔한 방법은 버전에 서명을 붙이는 것이다.

HMAC(Hash-based Message Authentication Code)은 비밀 키와 메시지를 함께 해시해 만드는 짧은 서명이다.

revision에 서버만 아는 비밀 키로 HMAC을 붙여두는 방식으로 위조를 방지할 수 있다.

zookie = ⟨revision⟩.⟨HMAC(revision, secret)⟩

예를 들어 42.VQKv7pBX3... 형태다.

검증 시 HMAC을 다시 계산해 일치하는지 본다.

서명 비교는 타이밍 공격을 막기 위해 constant-time으로 한다.

키를 교체할 때를 대비해 현재 키와 직전 키를 모두 받아주면, 무중단 키 로테이션이 가능하다.

클라이언트 입장에서 zookie는 그저 다음 Check에 돌려줄 불투명 문자열일 뿐이다.

권한을 바꾸는 모든 쓰기를 하나의 트랜잭션 안에서 단조 증가하는 revision을 함께 할당하는 식으로 묶으면, revision 하나가 곧 하나의 스냅샷이 된다.

tuple을 추가하든 삭제하든 그 배치는 정확히 하나의 새 revision을 만들고, 특정 revision에서의 Check는 그 시점까지 반영된 tuple만 본다.

Consistency 레벨

모든 Check가 최신을 강제하면 캐시가 무의미해진다.

그래서 호출자가 얼마나 신선해야 하는지를 고를 수 있게 한다.

  • minimize latency: zookie를 무시하고 캐시를 최대한 활용한다.
  • at least as fresh: zookie를 존중한다. 캐시 항목이 zookie의 revision보다 오래됐으면 캐시를 버리고 새로 평가한다.
  • fully consistent: 캐시를 완전히 우회하고 최신 revision을 읽는다.

Leopard: 깊은 관계를 미리 펴두기

그룹이 그룹을 품는 중첩이 깊어지면, 멤버십 Check 하나가 수십 단계를 재귀해야 할 수 있다.

대형 Enterprise의 조직도처럼 “그룹 안의 그룹 안의 그룹”이 흔한 환경에서는 이 깊이가 지연으로 직결된다.

Zanzibar에서는 이를 Leopard 인덱스로 풀려고 했다.

자주 쓰이는 중첩 집합 관계(특히 group membership)를 미리 계산해 평탄화한 역방향 인덱스를 따로 둔다.

“이 사용자가 속한 모든 그룹을 (간접 포함까지) 펼친 집합”을 미리 만들어두면, 깊은 재귀가 인덱스 한 번 조회로 바뀐다.

권한 데이터의 변경을 구독해 이 인덱스를 비동기로 갱신한다.

Architecture

flowchart TD
    app["애플리케이션<br/>Check / Write"]
    api["권한 API<br/>(Check, Expand, Write)"]
    cache["분산 캐시<br/>consistent hashing 라우팅"]
    eval["평가 엔진<br/>rewrite rule 재귀 + depth budget"]
    zookie["Zookie 서명/검증<br/>revision ↔ HMAC"]
    store["tuple 저장소<br/>revision 단위 원자적 쓰기"]
    leopard["Leopard 인덱스<br/>평탄화"]

    app --> api
    api --> zookie
    api --> cache
    cache -->|miss / fully consistent| eval
    eval --> store
    eval -.->|깊은 멤버십| leopard
    store -.->|변경 구독| leopard

    classDef hot fill:#1e3a5f,stroke:#4a90d9,color:#fff
    class api,eval hot

Check 요청은 zookie로 신선도 하한을 받고, 캐시를 먼저 친다.

미스거나 fully consistent면 평가 엔진이 rewrite rule을 재귀로 펼치며 tuple 저장소를 읽는다.

쓰기는 revision 단위로 원자적으로 묶여 일관성의 토대를 만든다.

깊은 멤버십은 Leopard로 우회한다.

References