노머스의 권한관리 시스템, ReBAC 도입기

yeon
  • #ReBAC
  • #RBAC
  • #Permission
  • #Role
  • #권한관리

안녕하세요. 노머스 백엔드팀에서 fromm 서비스를 개발하는 김연태입니다.

들어가며

작년 하반기 fromm 내부와 fromm에 입점된 소속사들이 사용하는 내부시스템의 개편작업을 진행하였습니다. fromm 서비스에는 몇백개의 소속사가 입점되어 있고, 각 소속사에는 여러 부서들이 있으며 해당 부서에는 다시 여러 아티스트들과 매니저들이 소속되어 있습니다. 이런 상황에서 소속사간의 정보를 서로 볼 수 없거나, 부서간의 정보를 서로 볼 수 없는 당연한 경우 외에도 아티스트의 부서간 이동 혹은 소속사의 이적, 매니저의 부서이동 및 입퇴사 등 발생하는 다양한 이벤트를 일관되게 처리할 수 있는 권한관리 시스템의 필요성을 느꼈습니다. 이런 복잡한 권한관리 시스템을 처음에는 RBAC 로 구현하려 했지만 어느정도 한계를 느끼고 ReBAC로 구현을 시도했는데요, 이 글에서는 RBAC와 ReBAC가 무엇이고 어떤 특징을 가지는지, 어떤 문제를 어떻게 해결하려고 ReBAC를 사용하게 되었는지 예시와 코드를 가지고 알아보도록 하겠습니다.

RBAC(Role-Based Access Control)

RBAC(Role-Based Access Control)는 사용자에게 직접 권한을 부여하는 대신, 역할(Role)이라는 중간 계층을 통해 권한을 관리하는 접근 제어 모델입니다.

User → Role → Permission

권한관리 시스템에서 가장 흔히 사용되는 모델이라고 할 수 있어서 fromm 의 권한관리 시스템에서도 RBAC를 통한 접근제어를 하려고 했었습니다. RBAC에 대한 내용은 NIST(미국 국립표준기술연구소)에서 발행한 The NIST Model for Role-Based Access Control 논문에서 상세히 살펴볼 수 있습니다. 여기서 밝힌 RBAC의 4가지 레벨과 특징 및 한계점에 대해 알아보도록 하겠습니다.

Level 1: Flat RBAC

특징

  • 가장 기본적인 형태의 RBAC
  • 사용자-역할 간 N:M 관계를 가짐
  • 역할-권한 간 N:M 관계를 가짐
  • 세션에서 역할을 활성화 함

Flat_RBAC

구현 예시

const managerRoles = {
    'manager-001': ['department_viewer'],
    'manager-002': ['department_admin'],
    'manager-003': ['department_viewer', 'arti_viewer']
};

function hasPermission(managerId: string, permission: string): boolean {
    const roles = managerRoles[managerId] || [];
    return roles.some(role => rolePermissions[role]?.includes(permission));
}

한계: 역할 폭발 (Role Explosion)

시스템이 복잡해지면 필요한 역할의 수가 기하급수적으로 증가합니다.

필요한 시나리오:
- 소속사 A의 관리자
- 소속사 A의 조회자
- 소속사 B의 관리자
- 소속사 B의 조회자
- 부서 1의 관리자
- 부서 1의 조회자
...

역할 수 = 소속사 수 × 부서 수 × 권한 종류 × ...

실제 내부시스템에서 소속사 100개 × 부서 5개 × 권한 4종류 = 2,000개의 역할 이 필요하게 됩니다. 내부시스템에서 현재보다 작은 값으로 가정한게 이정도 역할의 개수이다 보니 실제 다른곳에 확장해서 쓰게되면 기하급수적으로 많은 역할의 개수가 늘어날 것으로 예상해볼 수 있습니다.

Level 2: Hierarchical RBAC

특징

  • 역할 간 계층 구조를 도입
  • 상위 역할이 하위 역할의 권한을 상속함
  • 역할 관리 복잡도가 감소됨

Hierarchical_RBAC

구현 예시

// Hierarchical RBAC: 역할 계층 정의
const roleHierarchy: Record<string, string[]> = {
    'super_admin': ['agency_admin', 'artist_admin', 'account_admin'],
    'agency_admin': ['department_admin'],
    'artist_admin': ['artist_viewer'],
    'department_admin': ['department_viewer']
};

function getEffectiveRoles(role: string): string[] {
    const inherited = roleHierarchy[role] || [];
    return [role, ...inherited.flatMap(r => getEffectiveRoles(r))];
}

한계: 계층 구조가 경직됨

문제 1: 교차 권한 표현 어려움

소속사의 조직장 A 가 부서1 과 부서3의 부서관리 어드민 권한을 모두 가지고 있어야 한다고 가정해 보겠습니다. 이때 부서2는 제외하겠습니다.

계층구조로는 다음과 같이 시도해볼 수 있는데 부서간의 계층은 평등하므로 계층구조로 표현이 되지 않는것을 확인할 수 있습니다.

계층 구조로는 표현 불가:
- Agency Admin
    - Department 1 Admin ✓
    - Department 2 Admin ✗
    - Department 3 Admin ✓

문제 2: 동적 관계 반영 불가

이번엔 소속사 내부에서 매니저가 담당부서를 변경하는 경우를 가정해 보겠습니다. 이때 매니저가 가지고 있는 Department 1 과 관련된 권한을 모두 끊어주어야 하고 새롭게 옮겨가는 Department 2 의 권한을 새로 할당해 줘야 합니다. 이런 로직은 서비스의 비즈니스로직에 포함될 수밖에 없습니다.

즉 관계 테이블과 역할이 동기화되지 않으므로 동적인 관계 반영이 불가능합니다.

Level 3: Constrained RBAC

특징

  • SoD(Separation of Duties) - 직무 분리라는 제약이 추가됩니다.
  • SSD - 정적 직무 분리 : 사용자에게 상충되는 역할을 동시에 할당할 수 없습니다.
  • DSD - 동적 직무 분리: 세션에서 상충되는 역할을 동시에 활성화할 수 없습니다.
Constrained RBAC
SSD: 정적 직무 분리
- 결재 요청자 ⊗ 결재 승인자
DSD: 동적 직무 분리
- 같은 세션에서 충돌 역할 활성화 금지

구현 예시

// Constrained RBAC: 직무 분리 규칙
const mutuallyExclusiveRoles = [
    ['account_admin', 'department_admin'],     // 계정 관리자 ≠ 부서 관리자 (예시)
    ['something_requester', 'something_approver']  // 요청자 ≠ 승인자
];

function canAssignRole(userId: string, newRole: string): boolean {
    const currentRoles = getUserRoles(userId);

    for (const exclusiveSet of mutuallyExclusiveRoles) {
        if (exclusiveSet.includes(newRole)) {
            const hasConflict = exclusiveSet.some(
                role => role !== newRole && currentRoles.includes(role)
            );
            if (hasConflict) return false;
        }
    }
    return true;
}

한계: 복잡한 제약 규칙 관리

문제 1: 제약 규칙 폭발

예외적인 사항이 발생했을 때의 제약은 서비스 로직에 포함될 수밖에 없습니다. 예를들면 다음과 같은 제약이 있을 수 있습니다.

  • 각 소속사별로 다른 SoD 규칙 적용
  • 특정 금액 이상 정산은 추가 승인 필요
  • 휴가 중인 매니저의 권한 위임

이러한 경우 각 서비스 로직을 어떻게 개발하느냐에 따라 관리포인트가 많아져 예상하지 못한 휴먼에러를 낼 가능성이 높아집니다.

문제 2: 컨텍스트 기반 제약 불가

fromm 과 소속사 간의 정산이 필요한 상황이라고 가정을 해보겠습니다. 이때 특정 금액이 넘어갈 때는 추가 승인이 필요하다고 했을 때 RBAC의 권한으로는 상세한 제약조건의 구현이 불가능합니다. 따라서 아래와 같이 서비스 로직으로 표현될 수밖에 없습니다.


async function approveSettlement(settlementId: string, approverId: string) {
    const settlement = await getSettlement(settlementId);

    if (settlement.amount >= 1_000_000_000) {
        // RBAC 외부의 추가 검증 로직
        if (!hasHighValueApprovalPermission(approverId)) {
            throw new Error('고액 정산 승인 권한 없음');
        }
    }
    // ...
}

Level 4: Symmetric RBAC

특징

  • 권한-역할 리뷰 기능이 추가됩니다.

  • 양방향 조회 가능:

    • 정방향: “이 역할에 어떤 권한이 있는가?”
    • 역방향: “이 권한을 가진 역할은 무엇인가?”
  • 권한 감사 및 분석을 할 수 있습니다.

    Symmetric RBAC
    정방향 조회 :
    Role:AccountAdmin → [계정정보변경, 계정생성, 계정삭제]
    역방향 조회 :
    Permission:계정생성 → [AccountAdmin, SuperAdmin]

구현 예시

// Symmetric RBAC: 양방향 조회
class SymmetricRBAC {
    private roleToPermissions: Map<string, Set<string>>;
    private permissionToRoles: Map<string, Set<string>>;  // 역방향 인덱스

    // 정방향: 역할 → 권한
    getPermissionsByRole(role: string): string[] {
        return Array.from(this.roleToPermissions.get(role) || []);
    }

    // 역방향: 권한 → 역할
    getRolesByPermission(permission: string): string[] {
        return Array.from(this.permissionToRoles.get(permission) || []);
    }

    // 감사: "계정 조회 권한을 가진 모든 사용자"
    async auditPermission(permission: string): Promise<AuditResult[]> {
        const roles = this.getRolesByPermission(permission);
        const users = await this.getUsersByRoles(roles);
        return users.map(u => ({
            userId: u.id,
            userName: u.name,
            roles: u.roles,
            grantedVia: u.roles.filter(r => roles.includes(r))
        }));
    }
}

한계: 관계 기반 권한 표현 불가

누가 이 권한을 가지는가? 에 대한 정보는 알 수 있지만 누가 이 리소스에 대한 권한을 가지는가? 는 알 수 없습니다. 따라서 리소스의 위치가 변경되는 경우 해당 리소스에 대한 권한을 가지고 있는 사람의 권한을 전부 끊어주기 쉽지 않습니다.

예를들어 아티스트 A를 관리하는 부서가 소속사 내부에서 변경되었다고 가정해 보겠습니다.

아티스트 A를 관리할 수 있는 사용자에 대해서 권한 변경이 필요합니다.

이때 Symmetric RBAC로 가능한 것은 “아티스트 A 관리권한을 가진 모든 사용자” 라는 전역적인 권한 서칭입니다. 하지만 “아티스트 A를 담당하는 부서의 매니저 중 아티스트 A 관리 권한을 가진 사용자” 라는 특정 리소스의 관계기반 권한은 Symmetric RBAC 로 찾을 수 없습니다.

따라서 특정 부서의 특정 아티스트 관리 권한을 찾아서 변경하는 것은 서비스 로직에 녹일 수밖에 없습니다.

ReBAC (Relationship-Based Access Control)

ReBAC란?

ReBAC는 객체 간의 관계를 기반으로 접근 권한을 결정하는 모델입니다. 2019년 Google이 발표한 Zanzibar- Google’s Consistent, Global Authorization System 논문을 통해 자세히 소개되었으며, Google 에서는 Google Drive, YouTube, Google Cloud 등 모든 구글의 시스템에서 Zanzibar 시스템을 통해 권한인증을 하고 있으며 수 ms 안에 처리된다고 하고 있습니다.

RBAC:  User → Role → Permission
ReBAC: User → Relationship → Object → Permission

핵심 개념

아래에서 표현되는 모든 방식은 Zanzibar- Google’s Consistent, Global Authorization System 논문의 내용을 차용하였습니다.

Object (객체)

권한의 대상이 되는 리소스입니다.

object_type:object_id

예시:
- agency:AG001          (소속사)
- department:DEPT001    (부서)
- arti:ARTI001          (아티스트)

Relation (관계)

객체 간의 연결을 정의합니다.

예시:
- owner    (소유자)
- admin    (관리자)
- member   (멤버)
- viewer   (조회자)
- parent   (상위 객체)

Tuple (관계 튜플)

실제 관계 인스턴스를 표현합니다.

object#relation@subject

예시:
- department:DEPT001#member@manager:MGR001
  → "매니저 MGR001은 부서 DEPT001의 멤버이다"

- arti:ARTI001#managed_by@department:DEPT001
  → "아티스트 ARTI001은 부서 DEPT001에 의해 관리된다"

- agency:AG001#admin@manager:MGR002
  → "매니저 MGR002는 소속사 AG001의 관리자이다"

ReBAC의 권한 확인 방식

ReBAC는 관계 그래프를 탐색하여 권한을 동적으로 확인합니다.

매니저 M이 아티스트 A의 정산을 볼 수 있는가?

graph

권한 도출:
1. manager:M은 department:D의 member
2. arti:A는 department:D에 의해 managed_by
3. department:D의 member는 해당 부서 아티스트의 구독 상태를 view 가능
4. 따라서 manager:M은 arti:A의 구독 상태를 view 가능 ✓

RBAC 문제의 ReBAC 해결

RBAC 문제ReBAC 해결 방식
역할 폭발관계로 권한 동적 도출, 역할 최소화
경직된 계층유연한 관계 그래프
동적 관계관계 변경 시 권한 자동 반영
리소스 특정 불가객체 단위 관계 정의

Google 의 Zanzibar 아키텍쳐

Zanzibar_Architecture

Zanzibar serving cluster

  • ACL server 의 클러스터로 Check, Read, Expand, Write 요청을 받습니다.
  • 이때 각 요청은 클러스터 내 임의의 서버에서 처리되고 해당서버는 필요에 따라 작업을 클러스터의 다른 서버로 분산시킬 수 있습니다.
  • 최종 Response 는 최초 서버에서 결과를 수집해 클라이언트에 리턴합니다.

Spanner DB

  • ACL 과 Metadata 를 Spanner DB에 저장합니다.
  • Spanner DB 는 다음과 같이 구성됩니다.
    • 각 클라이언트 네임스페이스별 관계 튜플을 저장하는 DB N개
    • 모든 네임스페이스 구성을 보관하는 DB 1개
    • 모든 네임스페이스에서 공유되는 변경 로그 DB 1개

Watch Server

  • Watch Server 는 Watch 요청에 응답하는 특수한 서버입니다.
  • Change Log를 추적하며 네임스페이스 변경 사항을 거의 실시간으로 클라이언트에 제공합니다.

fromm 에서 ReBAC 사용하기 - postgreSQL

앞서 본 Google’s Zanzibar 아키텍쳐에서는 Spanner DB 와 Zookie 프로토콜을 사용해 ReBAC 를 구현했습니다. 이러한 아키텍쳐는 글로벌 가용성과 성능이 중시된 구글의 서비스 특성을 고려한 것으로 RBAC 의 한계를 극복하고 유연성과 정확성을 가지기 위한 fromm 의 서비스에서 이정도 아키텍쳐는 필요하지 않았습니다.

현재 fromm 에서 사용하고 있는 인프라의 구조를 최대한 그대로 활용하고자 했고, 이후 높은 가용성과 낮은 레이턴시를 요구하는 대규모 트래픽 처리 단계에서는 아키텍처 전환이 가능하다고 판단했습니다.

그 결과, 초기 도입 단계에서는 PostgreSQL을 활용한 ReBAC를 선택했습니다.

스키마 설계

관계 튜플 테이블

-- 기본 관계 튜플 테이블
CREATE TABLE relation_tuples (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Object (권한의 대상)
    object_type VARCHAR(64) NOT NULL,      -- 'department', 'arti', 'settlement'
    object_id VARCHAR(256) NOT NULL,       -- 'DEPT001', 'ARTI001'

    -- Relation (관계 종류)
    relation VARCHAR(64) NOT NULL,         -- 'owner', 'member', 'viewer', 'managed_by'

    -- Subject (권한을 가진 주체)
    subject_type VARCHAR(64) NOT NULL,     -- 'manager', 'department', 'agency'
    subject_id VARCHAR(256) NOT NULL,
    subject_relation VARCHAR(64),          -- userset: 'member', 'admin'

    -- 메타데이터
    created_at TIMESTAMPTZ DEFAULT NOW(),
    created_by VARCHAR(256),

    -- 복합 유니크 제약
    CONSTRAINT unique_tuple UNIQUE (
        object_type, object_id, relation,
        subject_type, subject_id, COALESCE(subject_relation, '')
    )
);

-- 인덱스 최적화
CREATE INDEX idx_tuples_object ON relation_tuples(object_type, object_id);
CREATE INDEX idx_tuples_subject ON relation_tuples(subject_type, subject_id);
CREATE INDEX idx_tuples_relation ON relation_tuples(object_type, relation);

-- 복합 인덱스 (권한 체크 쿼리용)
CREATE INDEX idx_tuples_check ON relation_tuples(
    object_type, object_id, relation, subject_type, subject_id
);

네임스페이스 설정 테이블

객체 타입별 관계 정의를 저장합니다.

-- 객체 타입과 관계 정의
CREATE TABLE namespace_configs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(64) UNIQUE NOT NULL,      -- 'department', 'arti', 'settlement'
    config JSONB NOT NULL,                 -- 관계 정의
    version INT DEFAULT 1,
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

네임스페이스 설정 예시:

{
  "name": "arti",
  "config": {
    "relations": {
      "managed_by": {
        "directly_related": ["department"]
      },
      "viewer": {
        "directly_related": ["manager"],
        "inherited": {
          "from": "managed_by",
          "relation": "member"
        }
      }
    }
  }
}

권한 체크 함수 (재귀 CTE)

PostgreSQL의 재귀 CTE를 사용하여 관계 그래프를 탐색합니다.

-- 핵심 권한 체크 함수
CREATE OR REPLACE FUNCTION check_permission(
    p_object_type VARCHAR,
    p_object_id VARCHAR,
    p_relation VARCHAR,
    p_subject_type VARCHAR,
    p_subject_id VARCHAR,
    p_max_depth INT DEFAULT 10
) RETURNS BOOLEAN AS $$
DECLARE
    v_result BOOLEAN;
BEGIN
    WITH RECURSIVE permission_graph AS (
        -- Base case: 직접 관계
        SELECT
            rt.object_type,
            rt.object_id,
            rt.relation,
            rt.subject_type,
            rt.subject_id,
            rt.subject_relation,
            1 as depth,
            ARRAY[rt.id] as path
        FROM relation_tuples rt
        WHERE rt.object_type = p_object_type
          AND rt.object_id = p_object_id
          AND rt.relation = p_relation

        UNION

        -- Recursive case 1: 그룹 멤버십 확장
        SELECT
            pg.object_type,
            pg.object_id,
            pg.relation,
            rt.subject_type,
            rt.subject_id,
            rt.subject_relation,
            pg.depth + 1,
            pg.path || rt.id
        FROM permission_graph pg
        JOIN relation_tuples rt ON (
            rt.object_type = pg.subject_type
            AND rt.object_id = pg.subject_id
            AND rt.relation = COALESCE(pg.subject_relation, 'member')
        )
        WHERE pg.subject_type IN ('department', 'agency', 'group', 'role', 'team')
          AND pg.depth < p_max_depth
          AND NOT rt.id = ANY(pg.path)  -- 사이클 방지

        UNION

        -- Recursive case 2: 상위 권한 상속 (owner → admin → member → viewer)
        SELECT
            pg.object_type,
            pg.object_id,
            p_relation as relation,
            pg.subject_type,
            pg.subject_id,
            pg.subject_relation,
            pg.depth + 1,
            pg.path
        FROM permission_graph pg
        WHERE pg.relation IN (
            SELECT implied_relation FROM get_implied_relations(p_object_type, p_relation)
        )
          AND pg.depth < p_max_depth
    )
    SELECT EXISTS (
        SELECT 1 FROM permission_graph
        WHERE subject_type = p_subject_type
          AND subject_id = p_subject_id
          AND subject_relation IS NULL
    ) INTO v_result;

    RETURN v_result;
END;
$$ LANGUAGE plpgsql;

관계 상속 정의 함수

-- 상위 권한 매핑
CREATE OR REPLACE FUNCTION get_implied_relations(
    p_object_type VARCHAR,
    p_relation VARCHAR
) RETURNS TABLE(implied_relation VARCHAR) AS $$
BEGIN
    -- 권한 계층: owner > admin > member > viewer
    RETURN QUERY
    SELECT unnest(
        CASE
            WHEN p_relation = 'viewer' THEN ARRAY['member', 'admin', 'owner']
            WHEN p_relation = 'member' THEN ARRAY['admin', 'owner']
            WHEN p_relation = 'admin' THEN ARRAY['owner']
            ELSE ARRAY[]::VARCHAR[]
        END
    );
END;
$$ LANGUAGE plpgsql IMMUTABLE;

내부시스템 튜플 예시

-- 소속사-부서 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
    ('department', 'DEPT001', 'parent', 'agency', 'AG001'),
    ('department', 'DEPT002', 'parent', 'agency', 'AG001');

-- 부서-매니저 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
    ('department', 'DEPT001', 'member', 'manager', 'MGR001'),
    ('department', 'DEPT001', 'admin', 'manager', 'MGR002'),
    ('department', 'DEPT002', 'member', 'manager', 'MGR001');

-- 아티스트-부서 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
    ('arti', 'ARTI001', 'managed_by', 'department', 'DEPT001'),
    ('arti', 'ARTI002', 'managed_by', 'department', 'DEPT001'),
    ('arti', 'ARTI003', 'managed_by', 'department', 'DEPT002');

-- 아티스트에 대한 부서 멤버 권한 (viewer 상속)
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id, subject_relation)
VALUES
    ('arti', 'ARTI001', 'viewer', 'department', 'DEPT001', 'member'),
    ('arti', 'ARTI002', 'viewer', 'department', 'DEPT001', 'member'),
    ('arti', 'ARTI003', 'viewer', 'department', 'DEPT002', 'member');

-- 소속사 관리자 권한 (전체 조회)
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
    ('agency', 'AG001', 'admin', 'manager', 'MGR003');

권한 체크 쿼리 실행

-- 매니저 MGR001이 아티스트 ARTI001을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR001');
-- 결과: true (DEPT001의 member이므로)

-- 매니저 MGR001이 아티스트 ARTI003을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR001');
-- 결과: true (DEPT002의 member이므로)

-- 매니저 MGR002가 아티스트 ARTI003을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR002');
-- 결과: false (DEPT001 admin이지만 DEPT002와 무관)

fromm 에서 ReBAC 사용하기 - nest.js

다음은 nest.js 를 사용하고 있는 fromm 서비스에서 ReBAC 를 적용하기 위한 코드 예시입니다.

ReBAC 서비스 구현

@Injectable()
export class ReBACService {
    constructor(
        @InjectRepository(RelationTuple)
        private readonly tupleRepo: Repository<RelationTuple>,
        private readonly dataSource: DataSource,
    ) {}

    /**
     * 관계 튜플 생성
     */
    async createTuple(params: {
        objectType: string;
        objectId: string;
        relation: string;
        subjectType: string;
        subjectId: string;
        subjectRelation?: string;
    }): Promise<RelationTuple> {
        const tuple = this.tupleRepo.create({
            object_type: params.objectType,
            object_id: params.objectId,
            relation: params.relation,
            subject_type: params.subjectType,
            subject_id: params.subjectId,
            subject_relation: params.subjectRelation,
        });
        return this.tupleRepo.save(tuple);
    }

    /**
     * 권한 체크 - PostgreSQL 함수 호출
     */
    async checkPermission(params: {
        objectType: string;
        objectId: string;
        relation: string;
        subjectType: string;
        subjectId: string;
    }): Promise<boolean> {
        const result = await this.dataSource.query(
            `SELECT check_permission($1, $2, $3, $4, $5) as has_permission`,
            [
                params.objectType,
                params.objectId,
                params.relation,
                params.subjectType,
                params.subjectId,
            ],
        );
        return result[0]?.has_permission ?? false;
    }

    /**
     * 특정 주체가 접근 가능한 객체 목록 조회
     */
    async listAccessibleObjects(params: {
        subjectType: string;
        subjectId: string;
        objectType: string;
        relation: string;
    }): Promise<string[]> {
        const result = await this.dataSource.query(
            `
            WITH RECURSIVE accessible AS (
                -- 직접 관계
                SELECT DISTINCT object_id
                FROM relation_tuples
                WHERE object_type = $3
                  AND relation = $4
                  AND subject_type = $1
                  AND subject_id = $2
                  AND subject_relation IS NULL

                UNION

                -- 그룹을 통한 간접 관계
                SELECT DISTINCT rt1.object_id
                FROM relation_tuples rt1
                JOIN relation_tuples rt2 ON (
                    rt1.subject_type = rt2.object_type
                    AND rt1.subject_id = rt2.object_id
                    AND rt2.relation = COALESCE(rt1.subject_relation, 'member')
                )
                WHERE rt1.object_type = $3
                  AND rt1.relation = $4
                  AND rt2.subject_type = $1
                  AND rt2.subject_id = $2
            )
            SELECT object_id FROM accessible
            `,
            [params.subjectType, params.subjectId, params.objectType, params.relation],
        );
        return result.map((r: { object_id: string }) => r.object_id);
    }
}

ReBAC Guard 구현

export interface ReBACCheck {
    objectType: string;
    objectIdParam: string;  // 요청에서 object_id를 가져올 파라미터명
    relation: string;
}

export const REBAC_CHECK_KEY = 'rebac_check';
export const ReBACPermission = (check: ReBACCheck) =>
    Reflect.metadata(REBAC_CHECK_KEY, check);

@Injectable()
export class ReBACGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly rebacService: ReBACService,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const check = this.reflector.get<ReBACCheck>(
            REBAC_CHECK_KEY,
            context.getHandler(),
        );

        if (!check) return true;

        const request = context.switchToHttp().getRequest();
        const user = request.user;

        if (!user?.managerId) {
            throw new ForbiddenException('인증 정보가 없습니다');
        }

        const objectId =
            request.params[check.objectIdParam] ||
            request.query[check.objectIdParam] ||
            request.body[check.objectIdParam];

        if (!objectId) {
            throw new ForbiddenException('대상 객체를 찾을 수 없습니다');
        }

        const hasPermission = await this.rebacService.checkPermission({
            objectType: check.objectType,
            objectId,
            relation: check.relation,
            subjectType: 'manager',
            subjectId: user.managerId,
        });

        if (!hasPermission) {
            throw new ForbiddenException('접근 권한이 없습니다');
        }

        return true;
    }
}

컨트롤러 적용 예시

@Controller('settlements')
@UseGuards(JwtAuthGuard, ReBACGuard)
export class SettlementController {
    constructor(private readonly settlementService: SettlementService) {}

    /**
     * 아티스트 정산 조회
     * - ReBAC: arti 객체에 대한 viewer 권한 필요
     */
    @Get('arti/:artiId')
    @ReBACPermission({
        objectType: 'arti',
        objectIdParam: 'artiId',
        relation: 'viewer',
    })
    async getArtiSettlement(@Param('artiId') artiId: string) {
        return this.settlementService.getByArtiId(artiId);
    }

    /**
     * 부서 정산 목록 조회
     * - ReBAC: department 객체에 대한 member 권한 필요
     */
    @Get('department/:deptId')
    @ReBACPermission({
        objectType: 'department',
        objectIdParam: 'deptId',
        relation: 'member',
    })
    async getDepartmentSettlements(@Param('deptId') deptId: string) {
        return this.settlementService.getByDepartmentId(deptId);
    }
}

관계 동기화 서비스

기존 엔티티 변경 시 ReBAC 튜플 자동 동기화:

@Injectable()
export class ReBACSyncService {
    constructor(private readonly rebacService: ReBACService) {}

    /**
     * 부서-매니저 관계 생성 시 튜플 동기화
     */
    @OnEvent('department.manager.added')
    async onManagerAddedToDepartment(payload: {
        departmentId: string;
        managerId: string;
        role: 'admin' | 'member';
    }) {
        // 부서 멤버십 튜플 생성
        await this.rebacService.createTuple({
            objectType: 'department',
            objectId: payload.departmentId,
            relation: payload.role,
            subjectType: 'manager',
            subjectId: payload.managerId,
        });
    }

    /**
     * 아티스트-부서 관계 생성 시 튜플 동기화
     */
    @OnEvent('department.arti.added')
    async onArtiAddedToDepartment(payload: {
        departmentId: string;
        artiId: string;
    }) {
        // 아티스트 관리 관계
        await this.rebacService.createTuple({
            objectType: 'arti',
            objectId: payload.artiId,
            relation: 'managed_by',
            subjectType: 'department',
            subjectId: payload.departmentId,
        });

        // 부서 멤버에게 아티스트 viewer 권한 부여
        await this.rebacService.createTuple({
            objectType: 'arti',
            objectId: payload.artiId,
            relation: 'viewer',
            subjectType: 'department',
            subjectId: payload.departmentId,
            subjectRelation: 'member',
        });
    }

    /**
     * 아티스트 그룹에 멤버 추가 시
     */
    @OnEvent('artigroup.member.added')
    async onArtiAddedToGroup(payload: {
        artiGroupId: string;
        artiId: string;
    }) {
        await this.rebacService.createTuple({
            objectType: 'arti',
            objectId: payload.artiId,
            relation: 'member_of',
            subjectType: 'arti_group',
            subjectId: payload.artiGroupId,
        });
    }
}

권한 체크 플로우 상세 분석

앞서 본 postgreSQL 쿼리와 nest.js 코드를 살펴보면 알 수 있듯 ReBAC 를 적용했을 때 권한체크는 postgreSQL 내부적으로 처리됩니다. 비즈니스 로직에 대한 코드가 시각적으로 잘 보이지 않는데, 권한체크를 어떻게 재귀적으로 진행하게 되는지 예시에 대한 흐름도를 통해 살펴보도록 하겠습니다.

권한 부여 흐름도

권한 부여 흐름도

생성된 Tuples:
- department:DEPT001#parent@agency:AG001
- department:DEPT002#parent@agency:AG001
- department:DEPT001#member@manager:MGR001
- department:DEPT001#admin@manager:MGR002
- department:DEPT002#member@manager:MGR001
- agency:AG001#admin@manager:MGR003
- arti:ARTI001#managed_by@department:DEPT001
- arti:ARTI002#managed_by@department:DEPT001
- arti:ARTI003#managed_by@department:DEPT002
- arti:ARTI001#viewer@department:DEPT001#member  (부서 멤버에게 viewer 부여)
- arti:ARTI002#viewer@department:DEPT001#member
- arti:ARTI003#viewer@department:DEPT002#member

권한 체크 플로우 - Case 1: 직접 관계

매니저 MGR002가 아티스트 ARTI001을 볼 수 있는가?

check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR002') 

권한 체크 흐름도_1

권한 체크 플로우 - Case 2: 간접 관계 (그룹 멤버십)

매니저 MGR001이 아티스트 ARTI001을 볼 수 있는가

check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR001')

권한 체크 흐름도_2

권한 체크 재귀 플로우 - Case 3: 권한 없음

매니저 MGR002가 아티스트 ARTI003을 볼 수 있는가?

check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR002')

권한 체크 흐름도_3

권한 체크 재귀 플로우 - Case 4: 소속사 관리자 (전체 권한)

매니저 MGR003(소속사 관리자)이 아티스트 ARTI003을 볼 수 있는가?

check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR003')

권한 체크 흐름도_3

다음으로는

위와 같이 postgreSQL + nest.js 로 fromm 서비스에 ReBAC 를 적용해 보았습니다. ReBAC 를 통해 복잡한 권한 구조를 가진 시스템에서도 확장성있는 권한관리가 가능하게 되었고, 각 리소스별로 세부적인 권한부여가 가능해졌습니다. 또한 관계가 동적으로 변경되는 경우에도 권한을 일관되게 가져갈 수 있다는 장점이 있었습니다.

하지만 fromm 에는 상세한 권한관리가 필요한 부분이 많이 남아 있습니다.

  • 노머스 내부개발자의 각 소속사의 기능관리 도움
  • 아티스트가 자신과 그룹의 채팅 및 채널의 관리
  • 팬이 구독하고 있는 채팅방을 구독기간에 따른 채팅 접근권한 관리

내부시스템 뿐만 아니라 트래픽이 많이 발생하는 아티스트와 팬의 앱에 권한관리를 확장하기 위해서는 성능관리가 필수적으로 이루어져야 합니다.

물론 Google 이 사용하는 Zanzibar 의 아키텍쳐를 그대로 활용할 수 있다면 성능적인 문제를 한번에 해결할 수 있지만, 이를 운영하기 위한 비용과 아키텍쳐의 복잡도 등을 고려하면 바로 적용하기엔 오버엔지니어링 일 수 있습니다. 따라서 fromm 백엔드팀에서는 ReBAC 성능 최적화를 위해 다음과 같은 방안들을 고려하고 있습니다.

  • ElastiCache Redis 캐싱
    • 동일 권한부여/체크가 많이 발생해 hit rate가 높다고 판단되는 경우 쓰기, 읽기 성능 향상
  • Materialized View 활용
    • postgreSQL 의 Materialized View 로 권한테이블을 조회해 읽기 성능 향상

성능개선을 할 정도로 서비스가 확장된다면 다시한번 상세한 이야기를 다뤄보도록 하겠습니다.

지금까지 fromm 내부시스템에 ReBAC 를 적용해 권한관리 시스템을 구축한 과정을 알아보았습니다.

여기까지 글을 관심있게 읽어주신 모든분께 감사드립니다🙇‍

당신의 예술이 세상을 바꾼다.

저희와 함께 서비스의 품질을 높이기 위해 고민하고 함께 성장해나갈 개발자를 찾습니다. 관심있으신분들은 우측상단 Recruit 버튼을 통해 언제든지 지원부탁드립니다😀

참고자료

The NIST Model for Role-Based Access Control

Zanzibar- Google’s Consistent, Global Authorization System

← 목록으로 돌아가기

Art Changes Life

노머스와 함께 엔터테크 산업을 혁신해나갈 멤버를 찾습니다.

채용 중인 공고 보기