Skip to content

RBAC & 권한

Aset의 접근 통제는 두 계층으로 나뉘고 함께 읽어야 한다.

  • 오프체인(어드민 패널 / Lambda): 누가 버튼을 누르거나 API를 호출할 수 있는가. 패널 role이 넷이다. Super Admin · Admin · Operator · Fund Manager. withRole()admin_user_permissions가 강제한다.
  • 온체인(컨트랙트): 컨트랙트가 누구의 서명된 트랜잭션을 받는가. 온체인 role이 넷이다. Admin(거버넌스) · Pauser · Service Key · Yield Depositor. Admin과 Pauser는 오프라인 cold 하드웨어 키로 운영하고(컨트랙트 안의 멀티시그 구조는 유지된다), Service Key가 hot 자동화 키다.

🔑 비수탁 원칙(모든 것이 따르는 규칙)

Aset은 유저 자금을 움직이는 키를 절대 보유하지 않는다. 투자자가 자기 입금, 상환 요청, 수익 청구에 직접 서명하고, 파트너depositYieldfundRedemption에 서명한다. 상환 지급은 제한된 Service Key로 고정된 목적지(요청 시 LP가 잠긴 원래 요청자)에 자동 실행되므로, 어떤 키도 자금 방향을 바꿀 수 없다. 사람이 통제하는 키는 Admin(거버넌스)과 Pauser(긴급)뿐이고, 오프라인 cold 하드웨어 키(주 1개 + 백업 1개, 사내 보관)로 보유한다. 아래 키 보관 참조. 그 외에는 제한된 자동화 Service Key(임의 목적지로 자금을 옮길 수 없다)이거나 자가 보관이다. 승인은 이전이 아니라 비수탁 게이트다. → VASP 수탁 분류에 해당하지 않는다. 종합 판정과 6기준은 09a-custody 참조. 풀 모델 → 비수탁도 참조.

오프체인 패널 role

🔒 Super Admin: 플랫폼 소유자

  • 패널 /admin · 범위: 전체 · 인증: 이메일 + 비밀번호(백엔드에서만 생성, 플랫폼당 1명)
  • 로그인 경로는 목록에 없는 /super(비상용). /login에서 링크되지 않고 Super Admin이 직접 이동한다. 이메일·비밀번호 폼은 오직 /super에만 있고, 공개 /login은 Google OAuth만 보여 준다(비밀번호 입력란이 노출되지 않는다). 🔒 내부용이다. 앞으로 만들 사용자·B2B 문서에서 제외할 것(이 경로나 비밀번호 방식을 사용자에게 노출하지 말 것).
  • 전용 권한: Admin 초대·삭제, 플랫폼 설정, role 관리

👑 Admin: 플랫폼 전체 통제

  • 패널 /admin · 범위: 모든 풀과 펀드 · 인증: Google OAuth(Super Admin이 초대)
  • 거버넌스·lifecycle을 승인하고(Admin cold key로 온체인 실행), 상환을 승인하고, 풀을 설정하고, Operator와 FM을 초대한다

⚙️ Operator: 일상 운영

  • 패널 /admin · 범위: 모든 풀(페이지 단위 권한) · 인증: Google OAuth
  • 수익 분배 회계를 실행하고, 영향이 작은 풀 설정을 수정하고, 입금과 상환을 모니터링한다

📁 Fund Manager: 펀드 범위의 키 없는 승인자

  • 패널: 같은 어드민 앱을 role 범위로 제한해 쓴다. 별도 /fund-admin 경로는 없다. FM은 표준 어드민 패널에 로그인하고, 사이드바가 role 권한으로 필터링되며 모든 데이터가 서버 쪽에서 FM 자신의 펀드로 제한된다. · 범위: 자기 펀드만 · 인증: Google OAuth이고 지갑이나 키가 없다
  • 승인 권한(오프체인 게이트만): 수익 설정 구성, 펀드 구성원 관리, 펀드 범위 대시보드 열람. 상환 결정 권한은 전혀 없다. v3-82가 정산을 자동으로 만들었고(reserve가 충분하면 요청 시점에 완료되고, 부족하면 에스크로됐다가 파트너 자금이 들어오는 순간 완료된다), 남은 결정은 Return position뿐인데 v3-99가 그것을 ADMIN / SUPER_ADMIN에게만 남겼다. 주된 용도가 펀드가 끝내 채우지 않은 부족분을 닫는 것인데 FM이 바로 그 펀드이기 때문이다. FM에게 남는 것은 부족분 알림, 읽기 권한, 자기 지갑으로 fundRedemption에 서명하는 것이다. 상환의 출구 게이트 참조.
  • 페이지별 노출, 데이터 범위 규칙, 필요한 라우트 가드는 아래 Fund Manager: 패널 범위 제한 참조(백엔드 구현 스펙이다).

📁 FM 패널 role과 펀드의 지갑은 다르다

Fund Manager 패널 role은 키 없는 오프체인 승인자다. 펀드의 온체인 자금 이동 주체는 별개다. 파트너의 fund_wallet(Yield Depositor role을 가진 Gnosis Safe)이고, 자기 키로 depositYieldfundRedemption에 서명한다(자가 보관). 이 둘을 뭉갠 것이 v2.x의 실수였다. 같은 조직이 두 모자를 쓸 수는 있지만 권한은 나뉜다. 승인은 OAuth이고 키가 없다 / 자금 이동은 자기 지갑 서명이다.

⚙️ Operator의 페이지 단위 권한

Operator 권한은 페이지 단위이고 admin_user_permissions로 통제한다. 페이지 키는 9개다(admin-web/app/shared/lib/auth/types.tsALL_PAGE_KEYS). 'dashboard', 'deposits', 'redemptions', 'yield', 'pools', 'kyc', 'fund_managers', 'activity', 'notifications'. Super Admin과 Admin은 전체 접근이라 레코드가 필요 없다. Operator 기본값은 dashboard / deposits / redemptions이고 notifications는 Operator에게 막혀 있다(S-16). Fund Manager 기본값은 dashboard / deposits / redemptions / yield / pools / fund_managers / **activity**이고, 마지막 것은 감사 로그 라우트의 펀드 범위 운영 전용 뷰다(v3-42. 두 스트림 피드 자체는 v3-41). FM은 배정된 펀드의 데이터만 본다. kyc와 어드민 설정은 FM에서 제외된다.

온체인 role (4개)

단일 DEFAULT_ADMIN(키 보유자 × 시점 × 위험) 기준으로 넷으로 나눴다. 거버넌스와 긴급 조치는 사람이 통제하는 cold key로 돌고(컨트랙트 안에서 멀티시그가 가능하다. 키 보관 참조), 나머지는 제한된 자동화 키이거나 자가 보관이다. (벤치마크: Aave V3가 핵심 어드민 role 4개 안팎, Trail of Bits가 "4개 이상 + 타임락", OpenZeppelin이 "role이 너무 많으면 관리가 어렵다"고 하므로 4개가 적정선이다.)

Role책임보유 주체안전장치
Admin(거버넌스)파라미터(fund_wallet / reserve% / KYC / 락업) · lifecycle(impairment / wind-down) · 트레저리 주소 · role 관리Cold key(오프라인 HW, 주 1개 + 백업 1개, 사내 보관)7일 / 30일 타임락
Pauser(가디언)pause · is_emergency_frozen 설정과 해제Cold key(오프라인 HW)타임락 없음(즉시), 멈추는 것만 가능하고 자금 이동은 없다
Service Key(오라클 / 운영)updateNAV · settleYield(pro-rata 회계와 고정 목적지로의 수수료 항목) · approveRedemption 게이트Hot key. Aset 자동화(Lambda)NAV는 ≤1.0 상한과 24시간 하락 타임락, 그리고 갱신당 편차 상한과 서킷 브레이커가 불변 코어에 있어서(v3-32, 구현됨) 탈취된 hot key도 NAV를 그 경계 밖으로 못 옮긴다 · 수수료는 주소가 고정 · 전부 비수탁
Yield Depositor(파트너)depositYield · fundRedemption파트너의 fund_wallet(Safe)단일 목적 · 파트너 자가 보관이고 Aset의 role이 아니다

Treasurer role을 두지 않는 이유

수수료 인출은 따로 지킬 독립 행위가 아니다. v3-102 이후로 그것은 settleYield 안의 수수료 두 항목이고(더 이상 withdrawFees 함수가 없다), 투자자에게 크레딧하는 것과 같은 트랜잭션에서 일어나므로 단독으로 호출될 수 없다. 그 항목들은 Aset의 자기 수수료 수익을 파라미터가 아닌 고정 목적지(treasuryWallet과 풀별 fundFeeWallet)로 옮기고, 누적 수익을 상한으로 삼으므로 유저 원금이나 reserve에 닿을 수 없다. 이를 위해 별도의 사람 멀티시그를 두는 것은 실제 위험을 줄이지 못한 채 Admin 프로필을 복제하는 일이다. 그래서 수수료 인출은 Service Key에 두고, 트레저리와 fund_fee_wallet 주소 변경에는 Admin 키(멀티시그)를 요구한다. 다만 v3-69에 따라 이 수수료 목적지들에는 타임락이 없다(수수료가 보류 없이 자동 분배되므로 타임락은 낡은 주소에 수수료를 묶어 둘 뿐이다. 입금용 fund_wallet만 타임락을 유지한다). 최소 권한을 더 조이고 싶다면 값어치 있는 분리는 오라클(NAV 전용)과 운영이지 Treasurer가 아니다(NAV가 모든 지급액을 스케일하는 핵심이다).

🔴 잘못된 키를 지정한 role 부여: 분리 이후 만들어진 모든 풀 (v3-138)

배포 워커에서 고쳤고 배포되지 않았다. 기존 결함이며 이를 발견한 변경과는 무관하다

KMS 키 분리(2026-08-14) 전에는 adminoracle 신원이 같은 키였다. 그래서 배포 계정에 role을 주면 서명자에게도 우연히 부여됐다. 분리로 둘이 다른 주소가 됐는데 create-pool.ts는 계속 ORACLE_ROLEaccount.address, 즉 admin 서명자에게 주고 있었다. 그 뒤로 만들어진 풀이 없어서 이 결함은 잡히지 않고 배포된 채 기다리고 있었다.

위치무엇이 잘못됐나
create-pool.tsgrantRole(ORACLE_ROLE, account.address)가 oracle이 아니라 admin 서명자를 지정했다
handler-roles.ts호출 지점 9곳oracle로 서명하는데 그 주소에는 role이 없었다
PlatformPool.initialize:784PAUSER_ROLE이 배포 키인 _admin에게 갔고, 운영용 부여 목록에는 PAUSER가 아예 없었다

그 상태로 태어난 풀은 모든 oracle 경로에서 revert한다. NAV 갱신, 상환 승인과 거부, 수익 정산, 회차 스케줄, 트랜치 상각이 그렇고, PAUSER로 게이팅된 함수 5개 전부도 그렇다. pause, unpause, emergencyFreeze, unfreeze, tripCircuitBreaker다.

⚠️ 마감, wind-down, impairment는 깨진 적이 없다. setLifecycleStatusDEFAULT_ADMIN_ROLE이고 배포 키가 그것을 갖고 있다. 실제로 실패한 것은 그 셋이 에스컬레이션과 함께 실행하는 clearOnChainPause 구간이다(v3-92). unpause를 호출하기 때문이다.

해법: 각 role을 실제로 그것으로 서명하는 주소에 부여하고, 배포 워커에 oraclepauser 정책을 추가한다. 그다음 배포 키의 부트스트랩 PAUSER_ROLE을 회수한다. PlatformPool.initialize의 그 부여 위에 달린 주석이 이미 인계를 의도된 종착 상태로 서술하고 있었고, 이제 그 인계를 수행하는 코드를 가리킨다.

🔴 새 보유자에게 먼저 부여하고 그다음 옛 것을 회수한다. 순서가 중간에 죽으면 살아남아야 하는 것은 누군가는 멈출 수 있는 풀이다. 회수 먼저 하면 그 반대가 된다. 두 호출이 있는지만 확인하는 게 아니라 두 호출 인덱스를 비교하는 테스트가 이를 고정한다.

키 보관: cold와 hot 분리

🔑 cold / hot 키 모델 (2026-06-15 결정)

사람이 통제하는 온체인 role 둘, 즉 **Admin(거버넌스)**과 Pauser는 **오프라인 cold 하드웨어 키(주 1개 + 백업 1개)**로 보유하고 사내에 둔다(회사 밖으로 나가면 안 된다). Service Keyhot 키다(Aset 자동화 Lambda 키).

  • **cold 키(오프라인 HW, 사내)**는 Admin(파라미터 / lifecycle / 트레저리 주소 / role 관리)과 Pauser(pause / 긴급 동결)이고, 각각 별도의 Gnosis Safe 멀티시그로 보유한다(v3-37). Admin은 3-of-5, Pauser는 별도 2-of-3(빠른 동결을 위해 임계를 낮춘다). 타임락은 그대로다. Admin은 7일 / 30일, Pauser는 즉시(멈추는 것만 가능하고 자금 이동이 없다).
  • hot 키Service Key뿐이다(updateNAV / settleYield / 상환 게이트). 제한적이고 비수탁이다.
  • 멀티시그 채택(v3-37): 컨트랙트의 role 구조가 이미 멀티시그를 지원하므로 컨트랙트 변경이 필요 없다(각 role을 Safe에 부여하면 되고, hasRole은 EOA인지 Safe인지 신경 쓰지 않는다). 2026-06-23 보안 리뷰에 따라 Admin은 3-of-5 cold Safe, Pauser는 별도 2-of-3 cold Safe다(긴급 동결이 더 낮은 임계로 빠르게 되도록 분리했고, 동결이 멈추는 것만 가능하며 자동 만료되므로 안전하다, v3-28). 임계값은 잠정이다(비용과 서명자 가용성). 정말로 새로운 서명자 조합 등급("cold 1 + hot 1" 같은 것)이 필요하다면 새 role과 재배포가 필요한데, 그건 하지 않는다.
  • Yield Depositor는 그대로다. 파트너 fund_wallet의 자가 보관이고 Aset 키가 아니다.

✅ 비수탁 키 거버넌스: 결정되고 구현됨 (v3-27 / v3-28 / v3-30 / v3-31 / v3-32)

cold key 하나로 비수탁이 성립하려면 아래 거버넌스 항목들이 성립해야 한다. 전부 결정됐고 컨트랙트 부분은 2026-06-19에 구현됐다.

  1. (v3-27) money-path 불변. 유저 자금 경로(상환 지급 = request.investor, 요청 시 LP가 잠긴 검증된 보유자 · 수익 = 현재 홀더 · settleYield의 수수료 항목 = 누적 수익 상한 안에서 고정 목적지)는 어떤 업그레이드 범위에서도 제외된다. money-path 컨트랙트(PlatformPool / PlatformLPToken)는 업그레이드 불가 Clones이고(구현체 고정) _authorizeUpgrade도 목적지 setter도 없다. 멀티시그가 필요 없다.
  2. (v3-30) 업그레이드 가능한 조각은 KYC뿐. PlatformKYCSoulbound는 propose/execute 게이트가 있는 ERC1967 UUPS 프록시다(proposeUpgradeexecuteUpgrade/cancelUpgrade). _authorizeUpgrade는 그 경로만 받는다. ⚠️ 7일 UPGRADE_TIMELOCK은 2026-08-14에 제거됐다(v3-71). 이제 업그레이드가 트랜잭션 두 번 만에 효력을 갖고, KYC 컨트랙트는 모든 이탈 경로가 읽는 것이다(canRedeem). 컴플라이언스 로직은 풀 마이그레이션 없이 진화할 수 있고 money-path는 불변으로 남는다. 불변식은 KYC가 적격성만 게이팅한다는 것이다. 자금을 옮기거나 지급 목적지를 바꿀 수 없다.
  3. (v3-28) 긴급 동결은 기간이 정해져 있다. 즉시 동결은 유지하되 무기한 가둘 수 없다. 자본 유입은 동결 내내 막히지만 가치 유출(상환·청구)은 72시간 뒤 자동으로 풀리고(FREEZE_EXIT_WINDOW) 동결 전체가 7일 뒤 자동 만료된다(FREEZE_MAX_DURATION). 온체인에서 이를 연장할 방법이 없다. 예전에 여기 적혀 있던 FreezeExtend 3종은 제거됐다. 7일 동결에 7일 거버넌스 타임락을 걸면 동결이 이미 끝난 뒤에야 실행될 수 있는데, 그러면 아무 일도 안 하거나 소급해서 풀을 다시 잠그고 72시간 이탈 차단을 재시작하게 된다. 7일 너머로 올리려면 pause(자본 유입만)나 impairment를 쓴다. 둘 다 이탈을 열어 두고, 그것이 v3-28이 지키려는 성질이다. unfreeze는 복구가 빠르도록 PAUSER에 남는다.
  4. (v3-31) 무권한 이탈 폴백. 🔴 이 중 절반이 없어졌다(2026-08-31). 회차 풀: 그대로이고 여전히 온체인에 있다. 무권한 executeEpoch와 대리 청구가 있어서 정산된 주기는 Aset이 움직이든 말든 지급된다. 즉시 풀: claimRedemptionFallback이 제거됐다. 그것은 풀의 reserve에서 정산했는데 reserve가 입금 시점에 reserve_wallet으로 라우팅되므로 가용 잔액이 구조적으로 0이고 아무도 풀어 줄 수 없었다. 즉시 풀 홀더는 이제 reserve 지갑이 자기 요청에 자금을 대는 것에 의존하는데, 이는 컨트랙트가 아니라 운영상의 약속이다. approveRedemption도 같은 이유로 제거됐다.
  5. (v3-32) NAV 한계가 코어에 있고 role 관리 방식을 받아들임. updateNAV의 편차 상한과 서킷 브레이커가 이제 불변 코어에 있어서, 탈취된 hot ORACLE 키(또는 스스로 부여한 ORACLE role)도 NAV를 작은 경계 밖으로 못 옮긴다. 절도가 배제되고(불변 money-path) 이탈이 보장되므로(v3-31) 단일 role 관리 cold key와 타임락을 받아들인다. 멀티시그가 필수는 아니다.
    🔴 TODO(review, 2026-08-31): 이 논증의 두 번째 전제가 약해졌다. "이탈 보장"은 v3-31에 기대고 있었는데, 즉시 풀에는 이제 운영자 없이 동작하는 온체인 이탈이 없다. 그 근거로 단일 cold key가 여전히 받아들일 만한지는 문서 편집이 아니라 거버넌스 판단이다. (cold 멀티시그는 선택적 방어 겹치기로 남고 → v3-37에서 채택됐다. Admin 3-of-5, Pauser 별도 2-of-3.)

상세: 14-decisions v3-27 / v3-28 / v3-30 / v3-31 / v3-32 · 09a-custody · Notion의 Custody vs Non-Custody(기준 1번 단독 통제, 5번 이탈권).

오프체인과 온체인 매핑

행위마다 오프체인에서 누가 승인하는지, 온체인에서 어떻게 실행되는지, 누가 서명하는지, 사람이 통제하는 키가 관여하는지를 정리한다.

행위오프체인 승인자온체인 role서명자사람 키?
파라미터 · lifecycle · 트레저리 주소 변경Super Admin / AdminAdmin(거버넌스)Cold key(HW)🔑 cold key + 타임락
pause / 긴급 동결AdminPauserCold key(HW)🔑 cold key, 즉시
updateNAV(오라클 봇)Service KeyAset 키❌ 제한됨
settleYield(pro-rata 회계 + 수수료 항목)Admin / OperatorService KeyAset 키❌ 자금이 이미 풀에 있고 수수료 주소가 고정
상환 승인(게이트)FM / AdminService Key(게이트, FM id 포함)Aset 게이트 키❌ 자금 이동 없음
상환 지급Service Key(자동 실행)→ 요청자(잠긴 State-A 홀더)❌ 목적지 고정
depositYieldFM(패널 안에서)Yield DepositorFM의 fund_wallet(어드민 패널에서 클라이언트 서명, v3-53)❌ 자가 보관
fundRedemption(부족분)FM(패널 안에서)Yield DepositorFM의 fund_wallet(어드민 패널에서 클라이언트 서명, v3-53)❌ 자가 보관
수익 청구투자자❌ 자가 보관

🛄 FM이 자금 유입 함수 둘에 직접 서명한다 (v3-53)

컨트랙트가 파트너에게만 게이팅하는 두 함수, depositYieldfundRedemption(YIELD_DEPOSITOR_ROLE)은 어드민 패널 안에서 펀드매니저가 자기 지갑으로 직접 서명한다. Aset 키가 아니다. 모델 A로, FM이 연결한 지갑이 곧 풀의 온체인 fund_wallet이다(별도 운영 지갑도 없고 컨트랙트 변경도 없다). 이렇게 하면 위에서 말한 패널 role과 펀드 지갑의 구분이 유지된다. FM은 여전히 Google OAuth로 오프체인에서 승인하고 Aset이 보유한 키가 없으며, 온체인 서명은 FM이 자가 보관하는 fund_wallet(EOA나 Safe)에서 나온다.

  • 지갑 소유 증명(B3): FM이 SIWE로 서명 지갑 소유를 증명한다(POST /admin/wallet/nonce/admin/wallet/verify. 세션을 만들지 않아서 새 JWT가 없고 Google 세션도 건드리지 않는다). 증명된 지갑은 **admin_user_wallets**에 저장한다(FM 하나에 지갑 N개. 풀별 fund_wallet이 다를 수 있다). 권한의 기준은 언제나 온체인이고(msg.sender == pool.fund_wallet, 컨트랙트가 강제한다) 이 테이블은 서명 전 가드와 표시와 감사용 집합이다.
  • 서버 키 단계는 서버 키로 남는다. FM의 depositYield 다음에 ORACLE의 settleYield가 Aset Service Key로 실행되고(POST /yield-distributions/{id}/distribute), FM의 fundRedemption 다음에는 인덱서가 지급을 정산한다(RedemptionCompletedcomplete_redemption_atomic). 위의 상환 승인 게이트는 그대로다.

RESERVE_FUNDER_ROLEinitialize가 아니라 배포 시퀀스가 부여한다 (D4-a)

모든 입금의 reserve 몫은 reserve_wallet으로 풀을 떠나고, 그 지갑이 즉시 상환 부족분을 메운다. fundRedemption이 파트너의 YIELD_DEPOSITOR_ROLE더해 그 지갑도 받아들인다. 두 role은 서로의 함수 집합을 열어 주지 않는데, 그래서 reserve 지갑에 그냥 YIELD_DEPOSITOR_ROLE을 주지 않는다. 그러면 depositYield도 함께 열리고, executeFundWalletChange가 한 주소를 회수하고 다른 주소에 부여하는 방식으로 옮기는 role에 영구 보유자가 하나 더 생긴다.

EIP-170에서 따라 나오는 결과가 둘 있고 설계가 아니라 운영 문제다.

  • 부여가 initialize가 아니라 buildOperationalRoleCalls에 있다. 온체인에서 부여하면 345B 여유에 대해 +200B가 측정됐다. 그 호출 없이 만들어진 풀은 겉보기에 정상이고, 첫 즉시 이탈이 아무도 정산할 수 없는 PENDING_RESERVE에 머문다.
  • 이 role은 executeReserveWalletChange를 따라가지 않는다. 온체인에서 다시 가리키게 하면 +333B가 측정됐다. reserve 지갑을 바꾸면 새 지갑은 펀딩할 수 없고 옛 지갑은 여전히 할 수 있으며, 어드민이 부여를 옮길 때까지 그렇다. 그 단계는 지갑 변경 런북에 들어가야 한다. 실행 시점에 revert되는 것은 없고 다음 이탈에서 드러난다.

이것이 v3-53을 되돌리는 것은 아니다. 그 규칙은 플랫폼 키를 파트너의 자금 유입 role에서 떼어 놓는 것이고, 여기서 부여받는 쪽은 플랫폼 자신의 reserve 지갑이며 누구를 대신하지도 않는다.

  • 개발용 임시 조치가 없어졌다(2026-08-13). 풀 생성이 플랫폼 서명자에게 YIELD_DEPOSITOR_ROLE 사본을 하나 더 부여하고 있었고(create-pool.ts) 그것이 Aset이 파트너의 자금 유입 함수 둘에 서명할 수 있게 했다. 수탁이다. 그 부여가 제거됐고 서버 키 경로 둘도 함께 없어졌다. POST /redemption-requests/{id}/fund(삭제)와 POST /pools/{id}/epoch-fundingamount 분기다. initialize는 여전히 fund_wallet에 role을 부여하고 executeFundWalletChange가 지갑과 함께 옮기므로, 펀드별이나 FM별 부여 단계가 존재하지도 필요하지도 않다. 남은 경로는 record-fundingepoch-funding + fund_tx_hash이고 둘 다 검증하고 기록하는 방식이다. ⚠️ 그 날짜 이전에 배포된 풀은 여전히 온체인에 추가 부여를 갖고 있어서 풀마다 회수해야 한다. v3-53fm-wallet-signing-spec 참조. :::

권한 매트릭스(오프체인 패널)

각 패널 role이 승인할 수 있는 것이다. 이 중 어느 것도 패널 사용자가 지갑을 보유할 것을 요구하지 않는다. 온체인 실행은 위의 매핑을 따른다.

플랫폼과 설정

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
대시보드 열람자기 펀드(범위 제한, 읽기 전용)
감사 로그 열람(전체 컴플라이언스)
활동 열람(펀드 범위, 운영용)자기 펀드(범위 제한, 읽기 전용). v3-42
플랫폼 설정 구성
알림 구성
Admin 초대·삭제
Operator·FM 초대·삭제
어드민 유저 이름 수정(Admin 대상). v3-47은 2026-06-29에 개정돼 Admin이 다른 Admin의 이름 수정과 삭제를 할 수 있다
어드민 유저 이름 수정(Operator·FM 대상, v3-47)
CSV 내보내기: 운영 목록(입금 · 상환 · 수익 · KYC)
CSV 내보내기: 감사 로그(/activity-events/export)

감사 로그 내보내기는 다른 CSV 내보내기보다 좁다

위 행은 예전에 "CSV 내보내기(전체 데이터) — ✓ ✓ ✓"로 적혀 있었는데, 같은 문서의 감사 관련 서술 둘(§Fund Manager 패널 범위 제한의 "activity-events 내보내기는 ADMIN/SUPER_ADMIN 전용"과 읽기 범위 목록의 엔드포인트 주석)과 13-operations → 감사 로그 보존과 내보내기의 "CSV. 어드민 전용이다(Operator는 내보낼 수 없다)"와 모순됐다. Operator는 운영 목록 화면은 내보낼 수 있지만 감사 로그는 못 한다. 강제 지점은 activity-events.get.export.tswithRole('ADMIN','SUPER_ADMIN')이고, 그에 맞춰 admin-web의 내보내기 버튼도 Operator에게는 숨겨진다.

풀 관리

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
풀 생성·배포
풀 설정 수정(영향이 작고 즉시 반영)✓ (권한 부여 시)
파라미터 변경(fund_wallet/reserve%/KYC/락업) → Admin cold key + 타임락✓ (승인)
Impairment·wind-down → Admin cold key + 타임락✓ (승인)
Pause·긴급 동결 → Pauser✓ (승인)
NAV와 오라클 상태 열람자기 펀드

입금과 LP는 자동이다 (v3.0)

deposit()은 투자자가 서명하고 atomic하다. USDC가 들어오고 → PlatformLPToken이 LP를 민팅하고 → 10/90 reserve 분리가 일어나며, 파트너 잔여분은 **deposit() 끝부분에 인라인된 safeTransfer**로 나가면서 ReleasedToPartner를 발생시킨다. 전부 한 트랜잭션이다. (호출할 releaseToPartner() 함수는 없다. 그건 이벤트 이름이다. → 23-money-path) 어드민의 "LP 확인 / LP 민팅 / 입금 처리 / 지갑으로 릴리스" 같은 단계는 없다(v2.x의 FUND_ISSUED / PLATFORM_ISSUED / 에스크로 릴리스 조작은 제거됐다). Admin과 Operator는 입금 기록을 모니터링하고 풀별 KYC 게이팅을 설정할 뿐이다. 투자 라이프사이클 참조.

풀 업데이트·공지 (WO-6)

풀별 공지 피드다(pool_updates / pool_update_revisions. DB 스키마 참조). 운영자가 작성하는 큐레이션 피드이고 시스템 activity_events 로그와는 다르다. 전체 동작은 결정 v3-57에 있다. 분류가 셋이다. INFO(인앱 피드만), IMPORTANTMATERIAL_EVENT(발행 시 홀더에게 이메일도 보낸다). 작성자 라벨은 Aset 또는 Aset · {fund}다.

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
업데이트 열람✓ (권한 부여 시)자기 펀드
업데이트 작성(모든 분류)³✓ (권한 부여 시)자기 펀드
업데이트 수정✓ 전부✓ 전부✓ (권한 부여 시) 전부자기 글만
업데이트 삭제(소프트)⁴✓ 전부✓ 전부✓ (권한 부여 시) 전부자기 글만

³ 이 role들 중 누구든 작성 시 모든 홀더에게 이메일이 나가는 IMPORTANT / MATERIAL_EVENT를 발행할 수 있고, 별도의 어드민 검토 게이트가 없다(FM이 자기 펀드의 공시를 책임지고, Admin과 Operator는 사후 수정·삭제 권한을 모든 항목에 대해 유지한다). FM자기 펀드의 풀에만 글을 쓸 수 있다. Operator의 작성·수정·삭제는 어드민이 부여하는 pools 페이지 권한으로 선택적으로 열린다(열람과 같은 게이트다). 권한을 받은 Operator는 Admin과 Super Admin처럼 모든 풀에 대해 조치할 수 있다. DRAFT 풀에는 업데이트를 만들 수 없다.

⁴ 삭제는 소프트만 가능하다(deleted_at을 설정한다). 행은 보존되고(피드에서만 숨겨진다) 하드 삭제되지 않는다. 감사 추적이 영구적이기 때문이다. MATERIAL_EVENT를 수정하거나 분류를 그것으로 바꾸거나 그것에서 빼면, 수정이 반영되기 전에 이전 버전이 pool_update_revisions에 스냅샷된다. 그래서 중대 이벤트 이력이 조용히 다시 쓰일 수 없다.

상환

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
상환 대기열 열람자기 펀드
상환 승인(게이트, 자금 이동 없음)¹자기 펀드
Return position(요청을 닫고 LP를 투자자에게 반환)²

¹ 승인은 Service Key로 온체인에 기록되는 비수탁 게이트다(감사를 위해 승인한 FM의 id를 담는다). 그다음 지급이 Service Key로 원래 요청자에게 자동 실행된다. 요청 시 LP가 잠긴, 현재 검증된 LP 보유자(State A)이므로 수취인이 바뀔 수 없다(목적지 고정 = 비수탁. 홀더 검증 참조). 부족분파트너fund_wallet에서 fundRedemption에 서명해 메운다(자가 보관). Admin이나 Operator나 FM의 키가 아니다. 단일 승인이다(v2.x의 Operator→Admin 2단계와 FM_ACCEPTED 단계는 제거됐다).

² 예전 이름은 "상환 거부"였고 FM이 자기 펀드에 대해 쓸 수 있었다. v3-99가 FM 접근을 없앴다. 이 조작의 주된 용도가 펀드가 끝내 채우지 않은 부족분을 닫는 것인데 FM이 바로 그 펀드라, 그들에게 남기면 돈을 갚아야 할 쪽이 투자자의 이탈 요청을 끝낼 수 있게 된다. 투자자에게서 아무것도 빼앗지 않기 때문에 이름을 바꿨다. 요청이 닫히고 에스크로된 LP가 그들에게 돌아간다. 사유 분류와 자유 입력 메모가 둘 다 필수다. 즉시 풀에만 해당된다(회차 요청은 QUEUED로 생성되는데 이 조작은 그 상태를 받지 않는다). Return position 참조.

수익과 분배

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
settleYield 실행(회계 + 수수료 항목)✓ (권한 부여 시)자기 펀드
수익 설정 구성✓ (권한 부여 시)자기 펀드
수익 청구 열람자기 펀드
풀 재투자 정책 설정(allow_rollover

² allow_rollover풀 단위 플래그이고(Admin, 즉시) 그 풀에서 재투자를 가능하게 한다. 실제로 재투자할지는 투자자별 수동 선택이다(Manual Reinvest V1 / BD5). depositYield(자금 유입)는 파트너가 서명하고(Yield Depositor), distributeYield는 이미 입금된 자금을 pro-rata로 표시할 뿐이며, 투자자는 직접 서명해 청구한다.

⚠️ MVP에서는 재투자를 어느 화면에도 노출하지 않는다(2026-08-27, 아직 반영 안 됨, v3-151). 어드민 폼에서 토글이 빠지고 투자자 앱에서 CTA가 빠진다. 필드와 온체인 함수가 남으므로 권한 행도 남긴다. 경로를 닫는 것은 제거된 컨트롤이 아니라 기본값 false다.

펀드 관리

행위🔒 Super Admin👑 Admin⚙️ Operator📁 Fund Mgr
펀드 생성·삭제 · 펀드 상태 설정
펀드 정보 수정
펀드 구성원 추가·삭제✓ (자기 펀드)
펀드 대시보드 열람 · 펀드 CSV 내보내기자기 펀드

Fund Manager: 패널 범위 제한

펀드매니저는 같은 어드민 앱을 쓴다(별도 /fund-admin이 없다). FM이 무엇을 보는지는 role 권한이 통제하고, 어떤 데이터를 받는지는 서버에서 자기 펀드로 제한된다. "FM의 펀드"는 fund_members에서 그 FM에 매핑된 펀드들이고, "FM의 풀"은 pools.fund_id가 그 펀드 id에 속하는 풀들이다.

🔐 강제 계층이 둘이고 경계는 백엔드다

  1. 백엔드(권위 있음): 모든 목록·상세 엔드포인트가 FM의 펀드로 필터링하고 범위 밖 id에는 403을 반환해야 한다. 이것이 실제 보안 경계다. ✅ 현황(구현됨): 3역할(투자자 / FM / 어드민) 읽기 권한 계층이 운영 중이다. GET 엔드포인트가 withAuthresolveReadScope로 FM 펀드 범위 제한과 PII(이메일) 제거를 강제한다(예: deposits, redemption-requests, yield-distributions, dashboard, activity-events. activity-events 내보내기는 ADMIN/SUPER_ADMIN 전용이다). FM 읽기 범위 제한은 이제 프론트엔드만이 아니라 실제 백엔드 경계다. 쓰기 엔드포인트는 이미 보호되고 있었다.
  2. 프론트엔드(표현만): FM이 쓸 수 없는 내비 항목, 변경 버튼, PII 컬럼을 숨긴다. 여기에 라우트 단위 권한 가드가 더해져 숨긴 페이지를 URL로 직접 칠 수 없게 한다. 프론트엔드 필터링은 절대 경계가 아니고 UI 모양만 잡는다.

⚠️ 현재 패널은 사이드바로만 페이지를 숨긴다. 라우트에 가드가 없어서 FM이 URL로 숨겨진 페이지에 갈 수 있다. 해법(2026-06-12 결정): 중앙 가드와 공유 맵. route→pageKey 맵을 사이드바에서 공유 모듈로 빼내고, 사이드바(내비 게이팅)와 protected-layout(URL 가드)이 함께 쓴다. 가드가 현재 경로의 pageKey를 해석해 !hasPermission(pageKey)이면 리다이렉트한다. 파라미터 라우트(/pools/:id)는 정확한 일치가 아니라 접두사·세그먼트 매칭이 필요하다.

FM 권한 키: dashboard, deposits, redemptions, yield, pools, fund_managers(사이드바 라벨 "Funds"), 그리고 activity다(v3-42. /audit-log 라우트이지만 펀드 범위의 운영 전용 "Activity" 뷰로 렌더된다. 사이드바 라벨은 "Activity"이고 CSV 내보내기가 없다). kyc는 제외되고 admin-settings는 어드민 전용이다. 전체 컴플라이언스 감사 로그(플랫폼 전체, KYC/PII, 내보내기)는 어드민 전용으로 남는다. FM은 처리자이지 데이터 관리자가 아니다.

페이지별 스펙

페이지FM이 보나백엔드 데이터 범위FM에게 숨김·비활성PII 처리
대시보드범위 제한으로 보임통계, 조치 항목, 활동이 FM의 풀과 그 투자자로 제한된다플랫폼 전체 합계, 펀드 밖의 실패와 큐펀드 안의 투자자 이름만
입금읽기 전용으로 보임pool ∈ FM의 풀인 입금재시도·일괄 재시도 버튼
상환읽기 전용으로 보임(v3-82에 따라 수동 승인이 없고, Return positionv3-99에 따라 ADMIN 전용이다)pool ∈ FM의 풀인 요청Return position, 펀드 통지·에스컬레이션투자자 이메일 숨김
수익보임pool ∈ FM의 풀인 분배펀드 안의 투자자별 이름만
풀(목록)읽기 전용으로 보임fund_id ∈ FM의 펀드인 풀"Create Pool" 버튼
풀 상세읽기 전용으로 보임풀이 FM의 펀드에 속해야 하고 아니면 403수정 / 삭제 / 아카이브, 온체인 Operations 탭풀 안의 투자자 이름만
풀 수정제한적으로 보임풀이 FM의 펀드에 속해야 하고 아니면 403아래 수익 설정 필드를 뺀 전부
펀드(목록)보임id ∈ fund_members(FM)인 펀드"Create Fund" 버튼
펀드 상세보임펀드가 FM의 것이어야 하고 아니면 403. 팀·풀·알림도 범위 제한펀드 삭제FM과 팀 이메일(자기 펀드만)
KYC페이지 전체와 라우트 가드
어드민 설정페이지 전체와 라우트 가드
Activity / 감사 로그**"Activity"**로 보임(펀드 범위, 운영 전용)자기 풀의 입금·상환·수익·NAV와 풀 어드민 조작. KYC·PII와 플랫폼 전체 이벤트는 제외전체 컴플라이언스 "Audit Log" 라벨, CSV 내보내기, 펀드 간·KYC·PII 이벤트투자자 PII를 노출하지 않는다(v3-42)

📁 그룹 2 결정 (2026-06-12)

  • 입금은 보이고, 펀드 범위이며, 읽기 전용이다. FM은 자기 풀의 입금을 모니터링할 수 있지만 LP 민팅이나 알림을 재시도할 수 없다(그건 Operator와 Admin에 남는다).
  • 풀 수정은 수익 설정 필드만 가능하다. FM은 아래의 수익 운영 필드만, 그리고 자기 펀드의 풀에만 수정할 수 있다(평소의 상태 잠금은 그대로 적용된다. CLOSEDMATURED에서는 수정 불가). 아카이브된 풀도 수정할 수 없지만 이유가 다르고 축도 다르다. 아카이브는 lifecycle 상태가 아니라 deleted_at이므로 그 목록의 구성원이 아니고, 풀을 작업 대상에서 통째로 빼내는 것이다(v3-110, 상태 머신 → 숨김 축). 숨겨진 풀(is_hidden)은 완전히 수정 가능하다. 숨김은 노출만 바꾼다.

FM이 수정할 수 있는 필드(이것뿐이다):

필드비고
yield_frequency분배 주기(MONTHLY / QUARTERLY / CUSTOM 등). 다음 도래일과 D-n 카운트다운을 구동한다
custom_interval_value + custom_interval_unityield_frequency = CUSTOM일 때만
다음 분배 메모다음 수익 분배에 붙는 펀드 메모

참고: yield_trigger 필드는 없다. 수익은 claim 기반 수동 방식뿐이다(AUTO는 v3-20에서 제거됐다).

FM에게 읽기 전용(어드민 전용): 이름, 설명, 자산 유형, 카테고리, 발행자, capacity와 목표 규모, 최소 투자금, APY, 락업, 허용 통화, 담보 유형과 비율, reserve %, 만기, 패널티 설정(유형·요율·수수료), allow_rollover(재투자 정책. 매트릭스상 Admin이고 ⚠️ MVP에서는 어느 화면에도 노출하지 않는다, 2026-08-27, v3-151), 펀드와 체인, 시작·종료일, LP 발행 모델이다.

근거: 이것이 정확히 권한 매트릭스의 "수익 설정 구성 = 자기 펀드 ✓" 행이다. 그 외 모든 풀 필드는 경제·리스크·투자자 대상 조건이므로 어드민 전용으로 남는다(매트릭스에서 "풀 설정 수정"과 "재투자 정책 설정"이 FM에게 다). 그것들을 바꾸는 것은 투자자가 이미 동의한 딜 조건을 바꾸는 일이다.

🔒 FM에 대한 PII 정책 (2026-06-12 법무 확인)

기본: FM은 투자자의 이름과 지갑 주소를 보고 이메일은 숨겨진다. 모든 FM 페이지에서 그렇다. 플랫폼 운영자가 데이터 관리자이고 FM은 처리자이자 범위 제한 role이며, 자체적인 KYC/AML 의무가 없음이 확인됐다. 그래서 FM이 어떤 PII 필드를 보는지는 관리자가 정한다.

  • 이메일을 숨기는 이유: 데이터 최소화와 알 필요성 원칙이다(GDPR 제5조 1항 c호와 개인정보 보호법 제16조). 이름과 지갑은 운영상 필요하다(투자자를 식별하고 온체인 보유분과 대조한다). 이메일은 펀드 운용 기능에 필요 없는 연락 필드다. 처리자가 볼 수 있는 필드를 정하는 것은 관리자에게 유보된 "본질적 수단"이다(EDPB 가이드라인 07/2020).
  • 여기서 지갑 주소는 개인정보다(KYC가 그것을 특정인과 연결한다. EDPB 02/2025, 전문 26, Breyer C-582/14). 다만 식별 가능성은 수신자에 따라 다르다(EDPS 대 SRB C-413/23 P). KYC 매핑에 접근할 수 없는 FM에게는 지갑이 이름이나 이메일보다 노출도가 낮다.
  • 예외: 투자자에게 정말로 연락해야 하는 FM은 플랫폼을 통한 메시징을 거친다. 절대 원본 이메일을 주지 않는다.
  • 서버 쪽에서도 강제한다. UI에서 숨기는 데 그치지 않고 FM API 응답에서 investor_email을 아예 뺀다.

⚠️ 메인넷 전에 확인할 것: 개인정보 보호법 적용에 대한 국내 법률 자문(수집 단계 원칙을 UI 노출까지 확장한 해석이다), 그리고 펀드매니저가 보유해야 하는 AML/CDD·FATCA/CRS 최소 PII 요건(아직 확인하지 않았다).

백엔드 필터 참조(엔드포인트별, 호출자 role이 FUND_MANAGER일 때)

text
GET /pools                 → where fund_id IN (FM의 fund_ids)
GET /pools/{id}            → pool.fund_id ∈ FM의 fund_ids 가 아니면 403
GET /deposits              → where pool_id IN (FM 펀드의 풀들)   [읽기 전용]
GET /redemption-requests   → where pool_id IN (FM 펀드의 풀들)
GET /yield-distributions   → where pool_id IN (FM 펀드의 풀들)
GET /funds                 → where id IN (select fund_id from fund_members where lower(email) = FM email AND status = 'ACTIVE')
GET /funds/{id}            → id ∈ FM의 fund_ids 가 아니면 403
GET /dashboard/stats       → FM의 풀과 그 투자자에 대해서만 집계
GET /activity-events       → FM: pool ∈ FM의 펀드·풀 인 풀 대상 이벤트
                             (입금/상환/수익/NAV + 풀 어드민 조작).
                             유저 대상 컴플라이언스 행(PII_ACCESS/KYC_*)은 제외.
                             FM "Activity" 뷰와 대시보드 위젯을 구동한다(v3-42).
PUT /pools/{id}            → FM의 펀드가 아니면 403. FM에게는 다음만 허용
                             {yield_frequency, custom_interval_value/unit,
                              다음 분배 메모}. 페이로드의 다른 필드는 거부
(KYC / admin-users → FM에게 403. /activity-events/export (CSV) → 어드민 전용.
 전체 컴플라이언스 감사 로그 페이지는 어드민 전용이고, FM은 범위 제한된 Activity 뷰를 받는다.)

FM의 펀드 id는 fund_members에서 이메일을 키로 해석한다(lower(email) = FM의 어드민 이메일 AND status = 'ACTIVE'). admin_user_id FK가 없고 소속은 텍스트 이메일 일치다(fund_membersUNIQUE(fund_id, email)이 있고 admin_users와의 연결은 없다). FM이 여러 펀드에 매핑될 수 있으므로 단일 id가 아니라 집합으로 범위를 잡아야 한다.

🔑 이메일을 키로 하는 소속: 반드시 지켜야 할 강화 규칙 둘 (2026-06-12 결정)

소속은 이메일 전용으로 유지한다(이메일 초대 온보딩을 살리기 위해서다. 그 사람에게 admin_users 계정이 생기기 전에도 fund_members 행이 존재할 수 있다). 이메일이 인증 키이므로 구멍 둘을 반드시 막아야 한다.

  1. 이메일 정규화. fund_membersadmin_users에 쓸 때마다(생성과 수정 모두) lower(trim(email))로 저장하고, requireFundAccess에서 호출자 이메일도 lower()한다. 대소문자 불일치로 인증이 조용히 실패하거나, 더 나쁘게는 통과되는 것을 막는다.
  2. 오프보딩 정리. admin_users의 FM이 삭제되거나 비활성화되면 그 이메일의 fund_members.status도 함께 바꾸거나 행을 지운다. 그러지 않으면 재사용된 회사 이메일을 받은 신규 입사자가 FM으로 등록할 때 옛 펀드 소속을 자동으로 물려받는다.

🗑️ 어드민 계정 삭제는 소프트이고, 테이블이 셋이라 메커니즘도 셋이다

DELETE /admin-users/{id}는 모든 role에 대해 소프트 삭제만 한다. OPERATOR, ADMIN, FUND_MANAGER 다 마찬가지다. role은 권한을 바꿀 뿐 메커니즘을 바꾸지 않는다. SUPER_ADMIN은 절대 삭제할 수 없고, 마지막 남은 ADMIN은 거부되며(409), 자기 계정을 지우는 것도 안 된다.

무엇이 제거되나방식이메일이 계속 선점되나
계정(admin_users)deleted_at 설정, 행 보존그렇다. UNIQUE (email)deleted_at을 무시한다
펀드 소속(fund_members)status = 'INACTIVE'(deleted_at 컬럼이 없다)아니다
대기 중인 초대(fund_invites)status = 'REVOKED'아니다

첫 번째만 주소를 선점하므로, 삭제된 이메일을 다시 초대하면 두 번째 계정을 만드는 게 아니라 그 계정을 복원한다. 그 과정에서 페이지 권한과 자격 증명을 비우고 ADMIN_USER_RESTORE를 기록한다. 상세는 11-db-schema → admin_users 참조.

FM을 삭제하면 앞의 둘이 함께 처리된다(핸들러가 그 이메일의 소속을 비활성화한다). 그것이 위의 규칙 2다.

투자자 정보 표시

어드민 앱에서 투자자 신원과 PII가 어떻게 드러나고 누가 무엇을 보는지다. FM에 대한 PII 정책과 읽기 계층 권한 분리(v3-23) 위에 얹힌다.

신원 모델(현재): 투자자 하나 = 지갑 하나 + 체인 하나다. 로그인은 (wallet_address, chain_id)를 키로 하고, 다른 지갑은 유저를 만든다. 지갑이나 체인을 가로지르는 계정 연결이 현재 없다. 투자자당 다중 지갑은 보류된 제품 결정이다. 그래서 투자자의 익스플로러 링크는 그 하나의 지갑 체인으로 해석된다.

노출 지점: 입금 / 상환 / 수익 / KYC 표의 투자자 칸이 지갑과 이름을 보여 준다(이름은 KYC에서 오고, SumSub 실제 배선 전까지는 비어 있다, W7).

표시 동작(role별로 다르다):

표의 투자자 칸은 평범한 텍스트이고 클릭 대상이 아니다. 행을 선택하면 상세 패널이 열리고 투자자 블록이 그 안에 있다.

Role상세 패널의 투자자 블록PII 호출
Admin / Operator접혀 있는 "Investor" 섹션. 펼치면 패널이 렌더된다펼칠 때 → GET /users/{id}/investor-detail
Fund Manager이름과 지갑이 인라인으로 보이고 지갑에 익스플로러 링크가 붙는다없음

🔴 PII 읽기를 유발하는 것은 행 선택이 아니라 펼치기다. 대기열을 훑으려고 행을 고르는 것이 행마다 감사 줄을 남겨서는 안 되므로, 호출을 섹션 펼치기에 묶었다(routes/deposits.tsx, widgets/redemption-detail/redemption-detail-panel.tsx).

role별 투자자 패널 필드:

필드AdminOperatorFM
이름, 지갑✓ (인라인, 패널 없음)
이메일
국가
KYC 상태
KYC 상세 / 레벨 / SumSub 링크✗ (어드민 전용)
투자자 요약(풀별 보유, 총 투자금, 활동)
  • 서버에서 강제한다. FM 응답에서 investor_email을 아예 뺀다(KYC도 마찬가지다). 프론트엔드에서 숨기는 것은 경계가 아니다(읽기 권한 참조). 이메일 제거가 읽기 권한 스펙에 의존하는 부분이다.
  • 익스플로러: chain_id → explorer 맵으로 체인을 인식한다(Base 8453은 Basescan, Kaia 8217은 Kaiascan, Sepolia와 Base Sepolia는 각각). 하드코딩하지 않는다(현재는 Basescan으로 하드코딩돼 있다).
  • 이름과 국가 출처: SumSub KYC webhook에서 users.nameusers.country로 온다(온체인 SBT countryCode도 있다). SumSub 실제 배선(W7) 전까지 이 값들은 비어 있고, 그것이 현재 지갑만 표시되는 근본 원인이다.

🔒 PII 접근 감사(개인정보 안전성 확보조치 기준 §8)

투자자 패널을 여는 것은 PII 읽기다. 운영 목록 엔드포인트는 이름과 지갑만 반환하고, 패널은 GET /users/{id}/investor-detail을 호출한다. 이 엔드포인트는 이메일, 국가, KYC 상태, 보유 내역을 반환하기 전에 PII_ACCESS를 기록한다(행위자, 대상 투자자, 시각, 필드). 감사 insert가 실패하면 오류를 반환하고 PII를 주지 않는다. 클라이언트에서 던져 두고 잊는 감사 쓰기는 보안 경계가 아니다.

  • 보존: 2년(KYC는 민감정보이자 금융정보다). DB 감사 테이블이 이미 2년을 보존한다. CloudWatch의 48시간 디버그 로그는 컴플라이언스 기록이 아니다.
  • 감사 로그 뷰: 기본은 최근 구간이다. 날짜 범위 필터는 쿼리당 1년 이하, 내보내기는 어드민 전용이고 파일당 1년 이하이며 투자자 이메일을 제외한다. 범위를 조정하면 2년 전체에 도달할 수 있고, 더 오래된 행은 S3로 아카이브될 수 있다.
  • ⚠️ 국내 법률 자문 확인이 필요하다(FM PII 정책과 같은 미결 개인정보 보호법 검토다).

인증과 라우트 보호

🔐 인증

Super Admin: 이메일과 비밀번호(백엔드에서 생성, 플랫폼당 1명). Admin / Operator / Fund Manager: Google OAuth(이메일을 admin_users.email과 대조)이고 invite_code로 초대받는다.

패널 role에는 지갑이 필요 없다. 승인과 설정은 오프체인이다. 지갑은 서명자로만 등장한다. 투자자가 자기 deposit/requestRedemption/claimYield에 서명하고, 파트너의 fund_wallet(Safe)이 depositYield/fundRedemption에 서명한다. 상환 지급 자체는 Service Key가 잠긴 요청자에게 자동 실행한다(목적지 고정). 라우트 로드 시 GET /api/auth/role로 role을 확인한다. 세션 타임아웃은 30분이다.

⚠️ 메인넷 전에 withRole()이 필수다. 인증되지 않은 JWT가 백엔드의 Service Key(NAV, 분배, 수수료, 승인 게이트)를 구동하지 못하도록 어드민 API를 막는다. 투자자의 자가 서명은 자금 이동 구간만 보호할 뿐 withRole()대체하지 않는다. NAV·수익·거버넌스 조작은 투자자가 서명하지 않기 때문이다.

⚠️ 프론트엔드 라우트 가드도 필요하다. 사이드바가 FM이나 Operator가 쓸 수 없는 페이지를 숨기지만, 라우트 자체도 hasPermission(pageKey)를 확인해야 한다(protected-layout이든 라우트별이든). 그러지 않으면 숨긴 페이지에 URL을 쳐서 갈 수 있다. Fund Manager 패널 범위 제한의 백엔드 펀드 범위 제한과 함께 적용한다. 권위 있는 경계는 여전히 백엔드다.

🗄 데이터베이스 테이블

admin_users: 모든 패널 role(email, role, auth_method, password_hash, is_active, 파트너 서명자를 위한 선택값 wallet_address) admin_user_permissions: Operator의 페이지 단위 권한 fund_members: Fund Manager와 펀드의 매핑(fund_id, email, wallet_address, is_primary, name, status). 소속은 이메일로 대조하고(FM의 admin_users.email) admin_user_id FK가 없다. 펀드당 FM이 여럿이면 행이 여럿이고, 펀드의 온체인 지갑은 FM 개인 지갑들이 서명자인 공유 Safe다(k-of-n).

어드민 초대 플로우

① Super Admin이 Admin을 초대한다 🔒 Super Admin

설정 → Admin Users → **"Invite New User"**에서 이름과 이메일을 입력하고 role을 Admin으로 고른다. admin_users 행이 즉시 생성되고(role = ADMIN, auth_method = GOOGLE_OAUTH) 로그인 링크가 담긴 초대 메일이 최선 노력으로 발송된다.

② 초대받은 사람이 Google로 로그인한다 🔴 Admin

초대 링크나 코드도, 수락 페이지도 없다. 초대받은 사람이 어드민 패널을 열어 초대받은 이메일과 일치하는 Google 계정으로 로그인한다. /auth/admin-oauth가 미리 만들어진 행과 이메일을 대조해 통과시킨다.

Operator 초대 플로우

① Super Admin이나 Admin이 Operator를 초대한다 🔴 Admin

같은 "Invite New User" 모달에서 이름과 이메일을 입력하고 role을 Operator로 고른 뒤, 같은 모달에서 페이지 단위 권한을 체크한다.

② Operator 계정이 생성되고 권한이 부여된다 🟢 System

행이 생성되고(role = OPERATOR) 체크한 권한이 초대 시점에 admin_user_permissions에 기록된다. 그다음 Operator가 Google로 로그인하면(이메일 일치) 그 권한이 세션에 로드된다.

FM 온보딩 플로우

① Admin이 펀드를 만든다 🔴 Admin

Funds 페이지 → "Create Fund"에서 펀드 이름, 설명, 주 연락처를 입력한다.

② Admin이 FM 이메일을 입력한다 🔴 Admin

시스템이 invite_code가 담긴 초대 메일을 보낸다.

③ FM이 초대를 클릭하고 Google OAuth로 간다 🟣 FM

FM이 Google OAuth로 가입한다(이메일이 초대와 일치해야 한다). role = FUND_MANAGER로 계정이 생성된다. 패널 role에는 지갑이 필요 없다.

④ FM이 펀드에 자동 연결된다 🟢 System

fund_members로 FM이 연결된다. 펀드의 자금 이동용 fund_wallet(파트너 Safe)은 별도로 설정하고 자가 보관한다. 풀 모델 참조.

📖 RBAC 정의

Role-Based Access Control, 즉 배정된 역할에 따른 접근 통제다. Aset은 두 계층을 쓴다. 승인하는 오프체인 패널 role 넷(Super Admin, Admin, Operator, Fund Manager)과 실행하는 온체인 role 넷(Admin(cold key), Pauser, Service Key, Yield Depositor)이다. 이 둘을 묶는 규칙은 비수탁이다. Aset은 유저 자금을 움직이는 키를 보유하지 않는다.