카테고리 없음

Neo4j RBAC 구현 — Aura 환경에서 제약과 애플리케이션 우회

임마누엘한 2026. 4. 29. 10:21

1. Neo4j란 무엇인가

Neo4j는 세계에서 가장 널리 쓰이는 그래프 데이터베이스(Graph Database) 다. 관계형 데이터베이스(RDB)가 행과 열로 데이터를 저장한다면, 그래프 DB는 노드(Node)관계(Relationship) 로 데이터를 저장한다.

이번 글에서의 샘플 데이터 (Customer - Order - Product 구조)

왜 그래프 DB인가?

쇼핑몰을 예로 들면:

  • RDB는 Customer, Order, Product 테이블을 만들고 외래키로 연결한다.
  • Neo4j는 (Customer)-[:PURCHASED]->(Order)-[:CONTAINS]->(Product) 처럼 관계 자체를 1급 시민으로 다룬다.

이로 인해 다음과 같은 질문에 압도적으로 빠르게 답할 수 있다:

  • "이 고객과 비슷한 구매 패턴을 가진 다른 고객은?"
  • "전화번호를 공유하는 의심 계정 그룹은?"
  • "3단계 이내로 연결된 추천 상품은?"

Neo4j의 핵심 구성 요소

요소 설명

노드(Node) 엔티티 (Customer, Product, Order 등)
관계(Relationship) 노드 간 연결, 방향과 타입을 가짐
레이블(Label) 노드의 분류 (:Customer, :Product)
속성(Property) 노드/관계에 붙는 key-value (name: 'Alice')
Cypher Neo4j 전용 쿼리 언어 (SQL과 비슷한 역할)

Edition 구분

  • Community Edition: 무료, 단일 DB, RBAC 미지원
  • Enterprise Edition: 유료, 다중 DB, 세밀한 RBAC 지원
  • Aura: Neo4j가 직접 운영하는 클라우드 호스팅 (Enterprise 기능 포함)

이번 실습은 Aura(Enterprise) 환경에서 진행했다.


2. RBAC이란 무엇인가

RBAC(Role-Based Access Control, 역할 기반 접근 제어) 는 사용자에게 권한을 직접 부여하지 않고 역할(Role)을 매개로 권한을 관리하는 보안 모델이다.

사용자(User)  →  역할(Role)  →  권한(Permission)  →  리소스(Resource)

왜 RBAC인가?

직접 권한 부여 방식의 문제:

  • 직원 100명에게 각각 권한 설정 → 관리 지옥
  • 부서 이동 시마다 일일이 수정해야 함
  • 누가 어떤 권한을 갖는지 추적 어려움

RBAC의 장점:

  • 역할 단위로 묶어서 관리 → 일괄 변경 가능
  • 신규 입사자에게 역할만 할당하면 끝
  • 감사(audit) 추적이 명확

Neo4j에서의 RBAC 범위

Neo4j Enterprise RBAC는 매우 세밀한 단위까지 제어 가능하다:

  • 데이터베이스 레벨: 어떤 DB에 접속할 수 있는가
  • 레이블 레벨: 어떤 종류의 노드를 볼 수 있는가 (Customer만 OK, EmailAddress는 차단)
  • 관계 타입 레벨: 어떤 관계를 따라갈 수 있는가
  • 속성 레벨: 노드의 어떤 필드만 노출할 것인가 (name은 OK, ssn은 차단)

이번 실습의 목적은 다음을 확인하는 것이었다:

  1. RBAC을 실제로 적용할 수 있는지
  2. 그래프/노드 단위 메타데이터를 어떻게 설정하는지
  3. Role별로 노드 접근을 제한해 검색 범위를 조절할 수 있는지

3. 실습 환경 및 인스턴스 정보

데이터 셋 - https://neo4j.com/developer/demos/cx-demo/ 에 대해 위 이미지와 같이 dump에 있는 항목을 드래그 앤 드롭 하면 된다.
Schema

항목 값

인스턴스 URI neo4j+s://76303afe.databases.neo4j.io
Neo4j 버전 5.27-aura (Enterprise)
드라이버 Python neo4j 패키지
데이터셋 E-commerce (실제 거래 + 그래프 분석 결과)

연결 코드

from neo4j import GraphDatabase
import json

URI  = 'neo4j+s://76303afe.databases.neo4j.io'
AUTH = ('neo4j', '****')  # 환경변수 NEO4J_PASSWORD 권장

driver = GraphDatabase.driver(URI, auth=AUTH)
driver.verify_connectivity()
print('연결 성공')
연결 성공

💡 Tip: 비밀번호는 코드에 하드코딩하지 말고 os.environ.get('NEO4J_PASSWORD')로 가져오는 습관을 들이자. Aura는 비밀번호를 한 번만 보여주므로 안전한 곳에 보관 필수.


4. 데이터 현황 파악

본격적인 RBAC 실습 전에 어떤 데이터가 있는지 파악해야 한다. Neo4j는 메타정보 조회용 프로시저를 기본 제공한다.

전체 노드/관계/레이블 조회

with driver.session() as s:
    labels = [r['label'] for r in s.run('CALL db.labels()').data()]
    rel_types = [r['relationshipType'] for r in s.run('CALL db.relationshipTypes()').data()]
    node_count = s.run('MATCH (n) RETURN count(n) as c').single()['c']
    rel_count  = s.run('MATCH ()-[r]->() RETURN count(r) as c').single()['c']

    print(f'노드 총계  : {node_count:,}')
    print(f'관계 총계  : {rel_count:,}')
    print(f'레이블     : {labels}')
    print(f'관계 타입  : {rel_types}')
노드 총계  : 449,848
관계 총계  : 1,192,605
레이블     : ['Order', 'Product', 'Customer', 'Country', 'OrderLineItem', 'Return',
              'EmailAddress', 'Phone', 'Identifier', 'ValidCommunity', 'Outlier']
관계 타입  : ['PURCHASED', 'ORDERED', 'LOCATION', 'LINE_ITEM', 'PRODUCT',
              'FIRST_ORDER', 'LAST_ORDER', 'NEXT', 'ASSOC_EMAIL', 'ASSOC_PHONE',
              'ASSOC_ID', 'SHARED_PII', 'SIMILAR_PURCHASE_TO']

약 45만 노드, 119만 관계의 실전급 데이터셋이다. 단순 거래 데이터(Order, Product)뿐 아니라 PII 노드(EmailAddress, Phone, Identifier)그래프 분석 결과(ValidCommunity, Outlier) 까지 포함되어 있어 RBAC 실험 대상으로 더할 나위 없이 좋다.

레이블별 노드 수

with driver.session() as s:
    for label in labels:
        cnt = s.run(f'MATCH (n:{label}) RETURN count(n) as c').single()['c']
        print(f'  {label:<20} {cnt:>8,}')
  Order                  22,190
  Product                 3,684
  Customer                4,372
  Country                    37
  OrderLineItem         406,829
  Return                  3,654
  EmailAddress            4,213
  Phone                   4,235
  Identifier              4,288
  ValidCommunity          1,101
  Outlier                     6

OrderLineItem이 40만 건으로 가장 많다. 뒤에서 이 대용량 노드에 메타데이터를 일괄 적용할 때 메모리 이슈가 발생하는데, 그 해결법도 다룬다.

RBAC 관점의 데이터 분류

이 데이터셋은 RBAC 실습에 이상적이다:

분류 레이블 민감도

공개 가능 Product, Country public
내부용 Customer, Order, OrderLineItem, Return internal
PII (개인정보) EmailAddress, Phone, Identifier confidential
분석 결과 ValidCommunity, Outlier restricted

이 분류를 기반으로 Role별 접근 제한을 설계한다.


5. Neo4j의 기본 Role 구조 이해하기

Neo4j Enterprise는 5개의 기본 내장 Role을 제공한다.

with driver.session() as s:
    roles = s.run('SHOW ROLES').data()

print('=== 현재 Role 목록 ===')
for r in roles:
    print(' ', r['role'])
=== 현재 Role 목록 ===
  PUBLIC
  architect
  console_admin_pro_76303afe
  console_member_76303afe
  console_viewer_76303afe
  data_importer_76303afe
  editor
  graphql_app_read_only_76303afe
  graphql_app_read_write_76303afe
  publisher
  reader

기본 Role 권한 요약

Role 읽기 쓰기 스키마 변경 사용자/Role 관리

PUBLIC 기본 권한
reader
editor ✅(기존 노드만)
publisher ✅(신규 포함)
architect
admin

Aura가 추가로 제공하는 Role

console_*, graphql_app_*, data_importer_* 같이 인스턴스 ID(76303afe)가 붙은 Role은 Aura 플랫폼이 콘솔과 GraphQL 통합용으로 자동 생성한 것이다. 사용자가 직접 만든 게 아니다.

💡 포인트: 기본 Role은 "읽기 vs 쓰기" 같이 굵은 단위의 구분만 가능하다. 레이블별/속성별 같은 세밀한 제어를 하려면 커스텀 Role을 만들어야 한다. 그게 바로 다음 섹션의 주제다.


6. RBAC — 노드 레이블별 접근 제어 (이론)

Neo4j Enterprise의 Fine-Grained Access Control(FGAC, 세밀한 접근 제어) 를 사용하면 특정 Role에 특정 레이블의 노드만 읽기/쓰기 허용이 가능하다.

6-1. 커스텀 Role 생성 — 이론적 Cypher

아래는 우리가 하고 싶었던 작업이다. (Aura 제약은 다음 섹션에서 다룬다.)

-- 1) 커스텀 Role 생성
CREATE ROLE analyst_role IF NOT EXISTS;
CREATE ROLE pii_restricted_role IF NOT EXISTS;

-- 2) analyst_role: 거래 관련 레이블만 읽기 허용
GRANT MATCH {*} ON GRAPH neo4j NODES Customer TO analyst_role;
GRANT MATCH {*} ON GRAPH neo4j NODES Order    TO analyst_role;
GRANT MATCH {*} ON GRAPH neo4j NODES Product  TO analyst_role;

-- 3) pii_restricted_role: PII 노드 명시적 차단
DENY  MATCH {*} ON GRAPH neo4j NODES EmailAddress TO pii_restricted_role;
DENY  MATCH {*} ON GRAPH neo4j NODES Phone        TO pii_restricted_role;
DENY  MATCH {*} ON GRAPH neo4j NODES Identifier   TO pii_restricted_role;

-- 4) 속성 수준 제어: Customer에서 customerId, segmentId만 노출
GRANT MATCH {customerId, segmentId} ON GRAPH neo4j NODES Customer TO analyst_role;

 

6-2. GRANT vs DENY 우선순위

Neo4j는 DENY가 GRANT보다 우선한다. 이는 보안 원칙(fail-safe defaults)에 따른 설계다.

GRANT  MATCH {*} ON GRAPH neo4j TO some_role;            -- 모든 노드 허용
DENY   MATCH {*} ON GRAPH neo4j NODES EmailAddress;       -- EmailAddress만 차단
→ 결과: EmailAddress 제외한 모든 노드 접근 가능

6-3. 권한 부여 가능한 액션

액션 의미

TRAVERSE 노드/관계의 존재만 확인 (속성 못 봄)
READ {props} 지정한 속성 읽기
MATCH {props} TRAVERSE + READ 합친 것
WRITE 생성/수정/삭제
CREATE, DELETE, SET PROPERTY 세부 쓰기 액션

6-4. 사용자 생성 및 Role 할당

-- 사용자 생성
CREATE USER analyst1 SET PASSWORD 'temp1234' CHANGE NOT REQUIRED;

-- Role 할당
GRANT ROLE analyst_role TO analyst1;

-- 확인
SHOW USER analyst1 PRIVILEGES;

자, 이론은 깔끔하다. 그런데 Aura에서 실행하면…


7. Aura에서 마주한 RBAC 제약

위 Cypher를 Aura에서 그대로 실행하면 다음 오류가 발생한다.

Neo.ClientError.Security.Forbidden 
  — Permission has not been granted for CREATE ROLE
Neo.ClientError.Security.Forbidden 
  — Permission has not been granted for ASSIGN PRIVILEGE

왜 안 될까? — 권한 진단

neo4j 유저가 실제로 어떤 권한을 갖고 있는지 확인해 보자.

def try_admin_command(cypher, database='system'):
    try:
        with driver.session(database=database) as s:
            result = s.run(cypher).data()
            return result
    except Exception as e:
        return f'오류: {e}'

print('=== neo4j 유저 현재 권한 ===')
privs = try_admin_command('SHOW USER neo4j PRIVILEGES AS COMMANDS')
if isinstance(privs, list):
    for p in privs:
        print(' ', p.get('command', p))
else:
    print(privs)
=== neo4j 유저 현재 권한 ===
  DENY  EXECUTE PROCEDURE aura.* ON DBMS TO $role
  DENY  EXECUTE PROCEDURE gds.internal.* ON DBMS TO $role
  GRANT IMMUTABLE ACCESS ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE ASSIGN ROLE ON DBMS TO $role
  GRANT IMMUTABLE CONSTRAINT MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE DATABASE MANAGEMENT ON DBMS TO $role
  GRANT IMMUTABLE EXECUTE PROCEDURE * ON DBMS TO $role
  GRANT IMMUTABLE INDEX MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE LOAD ON ALL DATA TO $role
  GRANT IMMUTABLE MATCH {*} ON GRAPH `neo4j` NODE * TO $role
  GRANT IMMUTABLE MATCH {*} ON GRAPH `neo4j` RELATIONSHIP * TO $role
  GRANT IMMUTABLE NAME MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE REMOVE ROLE ON DBMS TO $role
  GRANT IMMUTABLE SHOW ROLE ON DBMS TO $role
  GRANT IMMUTABLE TRANSACTION MANAGEMENT (*) ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE USER MANAGEMENT ON DBMS TO $role
  GRANT IMMUTABLE WRITE ON GRAPH `neo4j` TO $role
  GRANT LOAD ON ALL DATA TO $role

→ ROLE MANAGEMENT / ASSIGN PRIVILEGE 권한 없음 확인
→ Aura는 Cypher로 커스텀 Role 생성 불가

핵심 발견

권한 보유 여부

USER MANAGEMENT
ASSIGN ROLE
SHOW ROLE
REMOVE ROLE
ROLE MANAGEMENT
ASSIGN PRIVILEGE

USER MANAGEMENT는 있는데 ROLE MANAGEMENT가 없다. 그래서:

  • 유저는 만들 수 있음
  • 기존 Role에 유저를 할당할 수도 있음
  • 하지만 새 Role을 만들거나 권한을 부여하는 것은 불가

왜 이런 제약이?

Aura는 Managed Service다. Neo4j 측이 시스템 안정성을 위해 system DB의 권한 변경을 Aura Console(웹 UI) 레벨에서만 허용한다. Cypher로 직접 건드리는 것은 막아둔 것.

print('=== Aura RBAC 제약 요약 ===')
print('  CREATE ROLE : Forbidden (ROLE MANAGEMENT 권한 없음)')
print('  GRANT/DENY  : Forbidden (ASSIGN PRIVILEGE 권한 없음)')
=== Aura RBAC 제약 요약 ===
  CREATE ROLE : Forbidden (ROLE MANAGEMENT 권한 없음)
  GRANT/DENY  : Forbidden (ASSIGN PRIVILEGE 권한 없음)

해결 방향 3가지

  1. Aura Console에서 GUI로 사용자/Role 관리: 가장 정석. 하지만 커스텀 Role 생성은 여전히 제한적.
  2. 기본 내장 Role(reader, editor, publisher, architect)을 활용: 굵은 단위 구분만 가능.
  3. 애플리케이션 레이어 RBAC: 앱 코드에서 Role별 접근 레이블을 제한 → 이번 실습의 메인 우회법.

💡 교훈: 클라우드 매니지드 서비스는 편하지만 자유도가 낮다. RBAC 같은 시스템 레벨 제어가 필요하면 Self-hosted Enterprise를 고려해야 한다. 또는 셀프호스팅과 Aura를 혼용하는 하이브리드 전략도 가능하다.


8. 그래프 단위 메타데이터 관리

Neo4j는 그래프(database) 레벨 메타데이터를 네이티브로 지원하지 않는다. "이 그래프는 누가 소유하고, 어떤 도메인이며, 민감도는 어떻게 되는가?" 같은 정보를 저장할 공식 장소가 없다.

대신 두 가지 패턴으로 해결한다.

패턴 A — Metadata 노드 (권장)

특수 레이블 노드를 만들어 그래프 자체의 메타정보를 저장한다.

(:GraphMetadata {
    name: 'ecommerce',
    owner: 'team-a',
    sensitivity: 'high',
    created: date()
})

패턴 B — 노드/관계 속성에 태그

각 노드에 직접 메타정보를 박는다. (다음 섹션에서 사용)

(:Customer {
    customerId: '17850',
    _access_level: 'confidential',
    _department: 'sales'
})

_ 접두사를 붙이는 이유: 비즈니스 속성과 메타데이터 속성을 명확히 구분하기 위해서.

패턴 A 실습

with driver.session() as s:
    s.run("""
        MERGE (m:GraphMetadata {name: 'ecommerce-graph'})
        SET m.owner       = 'data-team',
            m.sensitivity = 'internal',
            m.domain      = 'retail',
            m.created     = date(),
            m.version     = '1.0'
    """)

    meta = s.run('MATCH (m:GraphMetadata) RETURN m').data()
    print('GraphMetadata 노드:')
    for m in meta:
        print(dict(m['m']))
GraphMetadata 노드:
{
  'owner': 'data-team',
  'created': neo4j.time.Date(2026, 4, 28),
  'domain': 'retail',
  'name': 'ecommerce-graph',
  'sensitivity': 'internal',
  'version': '1.0'
}

💡 MERGE vs CREATE: MERGE는 "있으면 매치, 없으면 생성"이다. 메타데이터 같이 단일성이 보장되어야 하는 노드에는 CREATE 대신 MERGE를 쓰자. 안 그러면 실행할 때마다 중복 생성된다.


9. 노드 단위 접근 레벨 태깅 (대용량 처리)

이제 각 노드에 _access_level 속성을 붙여서 Role별 필터링에 활용할 준비를 한다.

접근 레벨 매핑 설계

ACCESS_LEVEL_MAP = {
    'Customer'      : 'internal',
    'Order'         : 'internal',
    'Product'       : 'public',
    'OrderLineItem' : 'internal',
    'Country'       : 'public',
    'Return'        : 'internal',
    'EmailAddress'  : 'confidential',  # PII
    'Phone'         : 'confidential',  # PII
    'Identifier'    : 'confidential',  # PII
    'ValidCommunity': 'internal',
    'Outlier'       : 'restricted',
}

4단계 분류 기준:

  • public: 누구나 볼 수 있음 (상품 카탈로그, 국가)
  • internal: 회사 내부용 (주문, 고객)
  • confidential: 개인정보 (이메일, 전화번호, 식별자)
  • restricted: 분석가/관리자만 (이상치 분석 결과)

⚠️ 대용량 처리 시 메모리 이슈

순진하게 다음과 같이 작성하면 큰일난다:

-- ❌ 위험: OrderLineItem 40만 건을 단일 트랜잭션으로
MATCH (n:OrderLineItem)
SET n._access_level = 'internal'

40만 건을 하나의 트랜잭션으로 묶으면 메모리 초과로 실패한다. CALL { } IN TRANSACTIONS OF 1000 ROWS 배치 처리가 필요하다.

배치 처리 코드

with driver.session() as s:
    for label, level in ACCESS_LEVEL_MAP.items():
        summary = s.run(
            f"""
            MATCH (n:{label})
            CALL (n) {{
                SET n._access_level = $level
            }} IN TRANSACTIONS OF 1000 ROWS
            """,
            level=level
        ).consume()
        updated = summary.counters.properties_set
        print(f'  {label:<20} → {level:<15} ({updated:,} 속성 SET)')
  Customer             → internal        (4,372 속성 SET)
  Order                → internal        (22,190 속성 SET)
  Product              → public          (3,684 속성 SET)
  OrderLineItem        → internal        (406,829 속성 SET)
  Country              → public          (37 속성 SET)
  Return               → internal        (3,654 속성 SET)
  EmailAddress         → confidential    (4,213 속성 SET)
  Phone                → confidential    (4,235 속성 SET)
  Identifier           → confidential    (4,288 속성 SET)
  ValidCommunity       → internal        (1,101 속성 SET)
  Outlier              → restricted      (6 속성 SET)

총 45만 건의 노드에 무사히 메타데이터가 박혔다.

핵심 포인트

  • CALL (n) { ... } IN TRANSACTIONS: Neo4j 5.x의 권장 문법. 변수 스코프((n))를 명시한다.
  • OF 1000 ROWS: 1000개씩 끊어서 트랜잭션 커밋. 메모리 안전.
  • summary.counters.properties_set: 실제로 몇 개 속성이 변경됐는지 확인.

💡 참고: Neo4j 4.x 이전 버전에서는 CALL { ... } IN TRANSACTIONS 대신 APOC 라이브러리의 apoc.periodic.iterate를 사용했었다. 5.x부터는 네이티브 지원되니 APOC 의존성을 줄일 수 있다.


10. Role별 검색 범위 조절 — 애플리케이션 레이어 패턴

Aura에서 커스텀 Role을 못 만든다면, 앱 레이어에서 Role별 쿼리 범위를 동적으로 제한하는 방식으로 우회한다.

설계 — Role별 접근 레이블 매핑

ROLE_ACCESS = {
    'analyst_role'        : ['Customer', 'Order', 'Product', 'OrderLineItem', 'Country'],
    'pii_restricted_role' : ['Customer', 'Order', 'Product', 'OrderLineItem', 'Country',
                              'Return', 'ValidCommunity', 'Outlier'],
    'reader'              : ['Product', 'Country'],
}
  • analyst_role: 거래 분석용. 주문, 고객, 상품 OK. PII 차단.
  • pii_restricted_role: PII만 빼고 다 OK. 분석가용.
  • reader: 가장 제한적. 공개 정보만.

Role별 검색 함수

def search_by_role(driver, role: str, keyword: str):
    """주어진 Role이 접근 가능한 레이블 범위 안에서만 키워드 검색"""
    allowed_labels = ROLE_ACCESS.get(role, [])
    if not allowed_labels:
        return {'error': f'알 수 없는 Role: {role}'}

    results = {}
    with driver.session() as s:
        for label in allowed_labels:
            rows = s.run(
                f"""
                MATCH (n:{label})
                WHERE any(key IN keys(n)
                      WHERE NOT key STARTS WITH '_'
                        AND NOT key = 'embedding'
                        AND toString(n[key]) CONTAINS $keyword)
                RETURN n LIMIT 3
                """,
                keyword=keyword
            ).data()
            if rows:
                results[label] = [
                    {k: v for k, v in dict(r['n']).items()
                     if k not in ('embedding', '_access_level')}
                    for r in rows
                ]
    return results

코드 해부

  1. allowed_labels 가드: Role에 매핑된 레이블만 순회. 다른 레이블은 쿼리 자체가 만들어지지 않음.
  2. keys(n) + any(): 노드의 모든 속성 중 하나라도 키워드를 포함하면 매치.
  3. NOT key STARTS WITH '_': 메타데이터 속성(_access_level)은 검색 대상에서 제외.
  4. NOT key = 'embedding': 벡터 임베딩 속성처럼 큰 값은 검색 대상에서 제외.
  5. 결과 후처리: 응답에서도 _access_level, embedding을 제거해서 클라이언트로 안 새도록 함.

실험 1 — analyst_role로 'RED' 검색

result = search_by_role(driver, 'analyst_role', 'RED')
print(json.dumps(result, default=str, indent=2, ensure_ascii=False))
{
  "Product": [
    {
      "description": "RED WOOLLY HOTTIE WHITE HEART.",
      "stockCode": "84029E"
    },
    {
      "description": "HAND WARMER RED RETROSPOT",
      "stockCode": "22632"
    },
    {
      "description": "RED COAT RACK PARIS FASHION",
      "stockCode": "22913"
    }
  ]
}

analyst_role에 EmailAddress, Phone, Identifier가 매핑되어 있지 않으므로, 'RED'라는 키워드가 PII 노드에 들어있어도 결과에 절대 포함되지 않는다. 이게 우리가 원하던 "PII 차단" 효과다.

실험 2 — reader로 'United' 검색 (좁은 범위)

result = search_by_role(driver, 'reader', 'United')
print(json.dumps(result, default=str, indent=2, ensure_ascii=False))
{
  "Country": [
    {
      "countryName": "United Kingdom"
    },
    {
      "countryName": "United Arab Emirates"
    }
  ]
}

reader는 Product, Country만 볼 수 있으므로 다른 곳에 'United'가 있어도 안 나온다.

이 패턴의 한계

  • 앱 코드 신뢰가 전제: DB가 강제하는 게 아니라 앱이 잘 짜여 있어야 함. 우회 가능.
  • 여러 클라이언트가 같은 DB 붙으면 일관성 깨짐: Python 앱은 잘 제한해도 BI 도구가 직접 접속하면 무력.
  • 권한 변경 시 코드 배포 필요: DB 레벨 GRANT/DENY는 즉시 반영되지만 이건 코드 수정이 필요.

💡 현실적 조언: 진짜 보안이 중요하면 Self-hosted Enterprise로 가서 DB 레벨 RBAC을 쓰자. Aura에서는 이 패턴이 차선책이다. 프로덕션에서는 API Gateway나 백엔드 서비스 레이어에서 한 번 더 걸러주는 다중 방어가 안전하다.


11. Role별 PII 가시성 제어 — 고객 상세 조회 시나리오

앞 섹션의 search_by_role은 키워드 검색 범위를 Role별로 다르게 한 것이었다. 여기서는 한 단계 더 들어가, 개별 고객 상세 페이지를 조회할 때 같은 고객 정보라도 Role에 따라 일부 필드를 마스킹하는 시나리오를 구현한다.

이 패턴은 실제 서비스에서 가장 흔한 RBAC 요구사항이다:

"고객 정보 페이지를 누구나 열 수 있되, 이메일과 전화번호는 PII 권한이 있는 사람에게만 보여라."

시나리오 설계

유저Role접근 가능 _access_level
alice reader public
bob analyst_role public, internal
carol pii_admin public, internal, confidential, restricted

섹션 9에서 EmailAddress, Phone, Identifier 노드는 _access_level: 'confidential'로 일괄 태깅했음을 기억하자. 그 태깅이 여기서 결실을 맺는다.

Role → 접근 레벨 매핑

 
 
python
ROLE_ACCESS_LEVELS = {
    'reader':       ['public'],
    'analyst_role': ['public', 'internal'],
    'pii_admin':    ['public', 'internal', 'confidential', 'restricted'],
}

USERS = {
    'alice': {'role': 'reader'},
    'bob':   {'role': 'analyst_role'},
    'carol': {'role': 'pii_admin'},
}

앞 섹션의 ROLE_ACCESS는 레이블 단위 매핑이었다면, 이번 ROLE_ACCESS_LEVELS는 민감도 레벨 단위 매핑이다. 두 패턴을 상황에 맞게 섞어 쓸 수 있다.

고객 상세 조회 함수

 
 
python
def get_customer_view(driver, username: str, customer_id: str) -> dict:
    """Role에 따라 PII 필드를 마스킹한 고객 정보를 반환"""
    user    = USERS[username]
    allowed = ROLE_ACCESS_LEVELS[user['role']]
    view    = {'user': username, 'role': user['role']}

    with driver.session() as s:
        # 이메일 (confidential)
        if 'confidential' in allowed:
            row = s.run(
                "MATCH (c:Customer {customerId: $cid})-[:ASSOC_EMAIL]->(e:EmailAddress) "
                "RETURN e.emailAddress AS email",
                cid=customer_id
            ).single()
            view['email'] = row['email'] if row else None
        else:
            view['email'] = '*** (confidential 권한 필요)'

        # 전화번호 (confidential)
        if 'confidential' in allowed:
            row = s.run(
                "MATCH (c:Customer {customerId: $cid})-[:ASSOC_PHONE]->(p:Phone) "
                "RETURN p.phone AS phone",
                cid=customer_id
            ).single()
            view['phone'] = row['phone'] if row else None
        else:
            view['phone'] = '*** (confidential 권한 필요)'

    return view

코드 해부

  1. 권한 체크가 쿼리보다 먼저: if 'confidential' in allowed 가드 절이 통과하지 않으면 쿼리 자체가 실행되지 않는다. DB 부하를 줄이는 동시에 우발적 노출을 막는다.
  2. 마스킹 메시지의 친절함: None이 아니라 '*** (confidential 권한 필요)'라고 명시해서, 프론트엔드에서 "왜 안 보이는지" 사용자에게 설명할 수 있게 한다.
  3. 그래프 탐색 활용: (Customer)-[:ASSOC_EMAIL]->(EmailAddress) 패턴으로 PII가 별도 노드에 격리된 그래프 모델의 장점을 그대로 활용. 관계만 끊으면 한 번에 PII 차단 가능.

실행 결과 — 고객 ID 17850

 
python
for username in ['alice', 'bob', 'carol']:
    view = get_customer_view(driver, username, '17850')
    print(f"유저: {username:<8} | Role: {view['role']}")
    print(f"  email : {view['email']}")
    print(f"  phone : {view['phone']}")
    print()
 
유저: alice    | Role: reader
  email : *** (confidential 권한 필요)
  phone : *** (confidential 권한 필요)

유저: bob      | Role: analyst_role
  email : *** (confidential 권한 필요)
  phone : *** (confidential 권한 필요)

유저: carol    | Role: pii_admin
  email : willy.gallegos@msn.com
  phone : +44 0518 914745

 

동작 원리 요약

        alice (reader)              bob (analyst_role)            carol (pii_admin)
            │                              │                              │
            ▼                              ▼                              ▼
   allowed = ['public']        allowed = ['public',          allowed = ['public',
                                          'internal']                    'internal',
                                                                         'confidential',
                                                                         'restricted']
            │                              │                              │
            ▼                              ▼                              ▼
   'confidential' ∉ allowed    'confidential' ∉ allowed       'confidential' ∈ allowed
            │                              │                              │
            ▼                              ▼                              ▼
       마스킹 ***                       마스킹 ***                    실제 PII 조회 ✅

reader와 analyst_role은 이메일·전화번호 쿼리 자체가 실행되지 않는다. DB까지 가지 않으니 로그에도 흔적이 남지 않고, 우회할 여지도 없다.

두 가지 우회 패턴 비교

패턴적용 단위예시장점
레이블 단위 제한 (섹션 10) 검색/리스트 조회 "PII 노드는 검색 결과에 등장 안 함" 광범위 차단에 유리
민감도 레벨 단위 마스킹 (섹션 11) 상세 조회 "고객은 보이되 이메일은 마스킹" 부분 공개에 유리

실무에서는 두 패턴을 함께 사용한다:

  • 목록/검색 화면: 레이블 단위 제한으로 PII 노드 자체를 숨김
  • 상세 화면: 레벨 단위 마스킹으로 일부 필드만 공개

이 패턴이 Aura 제약을 우회하는 이유

Neo4j 네이티브 RBAC였다면 다음과 같이 했을 것이다:

 
cypher
DENY MATCH {*} ON GRAPH neo4j NODES EmailAddress TO reader;
DENY MATCH {*} ON GRAPH neo4j NODES Phone TO reader;

하지만 Aura에서는 이 명령이 막혀 있다(섹션 7 참고). 대신 앱 레이어의 분기문이 똑같은 효과를 낸다:

  • 네이티브: DB가 쿼리 결과에서 제거 (강제력 ↑)
  • 앱 레이어: 앱이 쿼리 자체를 안 보냄 (유연성 ↑, 우회 위험 ↑)

💡 베스트 프랙티스: 앱 레이어 RBAC을 쓸 때는 DB 접속 계정도 최소 권한으로 분리하는 것이 안전하다. 예를 들어 reader 유저용 앱 인스턴스는 Neo4j 내장 reader Role 계정으로 접속시키고, pii_admin용은 별도 계정을 쓰는 식. 그러면 앱 코드의 분기문이 뚫려도 DB 레벨에서 한 번 더 걸린다.


12. RBAC 가능 여부 종합 정리

기능 가능 여부 방법

Role별 레이블 읽기 제한 GRANT/DENY MATCH ON NODES <Label>
Role별 속성 노출 제한 GRANT MATCH {prop1, prop2} ON NODES
관계 타입별 접근 제한 GRANT MATCH ON RELATIONSHIPS <TYPE>
데이터베이스(그래프) 단위 접근 제한 GRANT ACCESS ON DATABASE
그래프 레벨 메타데이터 (네이티브) 별도 메타데이터 노드 패턴 사용
노드 단위 행(row) 레벨 보안 ⚠️ 속성 태깅 + 애플리케이션 필터링
동적 Role 기반 쿼리 범위 조절 애플리케이션 레이어 패턴 (search_by_role)
Aura에서 커스텀 Role 생성 Console GUI 또는 Self-hosted 사용

핵심 결론

Neo4j Enterprise RBAC는 레이블 / 관계 타입 / 속성 단위까지 세밀하게 제어 가능하다.
다만 행(row) 레벨 보안은 네이티브 미지원이므로 속성 태깅 + 애플리케이션 필터링 조합으로 구현한다.
Aura 환경에서는 커스텀 Role 생성이 막혀 있어 애플리케이션 레이어 RBAC이 현실적인 대안이다.


13. 마무리 — 배운 점

이번 실습에서 얻은 인사이트를 정리한다.

1. 문서대로 안 되는 게 정상이다

공식 문서에는 CREATE ROLE이 멀쩡히 적혀 있다. 그런데 Aura에서 시도하면 막혔다. 클라우드 매니지드 서비스는 보안을 위해 일부 기능을 의도적으로 제한한다. 항상 실제 환경에서 테스트해 봐야 한다.

2. 권한 진단부터 시작하라

오류가 났을 때 단순히 "안 되네" 하고 끝내지 말고 SHOW USER neo4j PRIVILEGES AS COMMANDS로 현재 가진 권한을 확인하자. 무엇이 막혀있는지 정확히 알면 우회법도 명확해진다.

3. 대용량 처리는 항상 배치로

40만 건에 한 번에 SET 하려다 메모리 터지는 건 흔한 실수다. CALL { } IN TRANSACTIONS OF N ROWS 패턴을 기본 옵션으로 들여놓자.

4. 메타데이터 속성에는 접두사

_access_level, _department 처럼 메타데이터에 _ 접두사를 붙이면 비즈니스 속성과 명확히 구분되고, 검색/응답 필터링 시 한 번에 걸러낼 수 있다.

5. RBAC는 다중 레이어로

DB 레벨 RBAC + 앱 레벨 필터링 + API Gateway. 어느 한 층이 뚫려도 다음 층이 막아주는 게 현실 보안이다. Aura처럼 한 층이 약하면 다른 층을 강화하면 된다.

 

참고 자료

이 글이 Neo4j RBAC을 처음 다루는 분들께 시행착오를 줄여주길 바랍니다. 질문은 댓글로 남겨주세요!