권한관리, AccessControl, Pausable
도입
스테이블코인에서 access control은 보안의 중심이다. 누가 mint할 수 있는지, 누가 주소를 freeze할 수 있는지, 누가 전체 transfer를 pause할 수 있는지에 따라 사용자 자금과 merchant settlement가 직접 영향을 받는다. 권한 설계는 컨트랙트 내부 문제가 아니라 운영, support, compliance, dashboard까지 이어지는 제품 설계다.
OpenZeppelin 문서는 access control이 mint, freeze, upgrade 같은 민감한 작업을 누가 수행할 수 있는지 결정한다고 설명한다. 또한 Pausable은 import만으로 동작하지 않고 실제 함수에 whenNotPaused 같은 modifier를 적용해야 한다. 이 강의는 이 두 사실을 stablecoin checkout의 release gate로 바꾼다.
학습 목표
- 역할 기반 권한과 pause 권한의 책임을 분리한다.
- 운영자 키 탈취와 과도한 권한 집중을 위협 모델에 넣는다.
- freeze, pause, upgrade, mint 권한의 범위와 모니터링 신호를 설계한다.
개념 설명
RoleMapped
역할 기반 권한과 pause 권한의 책임을 분리한다.
UnauthorizedCallRejected
권한 경계가 테스트되는가
EmergencyPaused
불변조건이 실패 상태를 잡는가
RunbookLinked
Stablecoin role matrix 만들기
1. Ownable, AccessControl, AccessManager는 운영 복잡도가 다르다
| 방식 | 장점 | 위험 | 언제 적합한가 |
|---|---|---|---|
| Ownable | 단순하고 빠르다. | owner 하나에 권한이 집중된다. | prototype, 작은 내부 도구 |
| AccessControl | role을 기능별로 나눌 수 있다. | role admin 설계를 잘못하면 더 위험하다. | stablecoin, checkout, treasury |
| AccessManager | 여러 contract 권한을 중앙 관리할 수 있다. | 운영 모델과 delay 설정이 복잡하다. | 여러 contract가 같은 governance를 공유할 때 |
| Timelock | 사용자와 운영팀에 exit/review window를 준다. | 긴급 대응이 느려질 수 있다. | upgrade, parameter change |
| Multisig | key 탈취와 실수를 줄인다. | signer 운영과 quorum 관리가 필요하다. | production admin, pauser, upgrader |
첫 설계 원칙은 "권한을 나누되, 각 권한의 admin도 같이 설계한다"이다. MINTER_ROLE과 FREEZER_ROLE을 나눠도 DEFAULT_ADMIN_ROLE 하나가 둘 다 즉시 바꿀 수 있으면 실제 위험은 admin에 모인다.
2. Stablecoin role matrix
| Role | 허용 함수 | 금지해야 할 것 | 권장 owner | 모니터링 event |
|---|---|---|---|---|
DEFAULT_ADMIN_ROLE | role grant/revoke, admin 설정 | 직접 mint, freeze, balance 조정 | multisig + delay | RoleGranted, RoleRevoked |
MINTER_ROLE | issuer 승인된 mint | role 변경, freeze, upgrade | issuer ops multisig | mint event, amount anomaly |
BURNER_ROLE | redeem/treasury burn | mint, freeze, role 변경 | treasury/redeem ops | burn event, redemption batch |
FREEZER_ROLE | freeze/unfreeze | user balance 이동, role 변경 | compliance multisig | freeze spike, unfreeze |
PAUSER_ROLE | pause/unpause | role 변경, upgrade | incident response multisig | Paused, Unpaused |
UPGRADER_ROLE | implementation upgrade | mint/freeze와 겸직 | governance/timelock | Upgraded, admin change |
RELAYER_ROLE | signed payment submit | amount 변경, nonce reset | backend service key with limits | submission rate, failure spike |
이 표에는 반드시 "금지해야 할 것"이 들어가야 한다. 허용 함수만 적으면 권한 집중을 놓친다. 예를 들어 FREEZER_ROLE은 주소 이동성을 막을 수 있지만, 잔고를 옮기거나 role을 바꾸면 안 된다.
3. DEFAULT_ADMIN_ROLE은 별도 위협 모델로 본다
OpenZeppelin 문서에서는 DEFAULT_ADMIN_ROLE이 다른 role의 admin이고 자기 자신의 admin이기도 하므로 민감한 권한임을 강조한다. Production stablecoin에서는 아래 완화책을 검토한다.
| 위험 | 완화책 |
|---|---|
| 단일 EOA admin 탈취 | multisig, hardware key, signer rotation |
| admin이 실수로 자기 role 제거 | two-step admin transfer, default admin rules |
| 즉시 role 변경 | timelock, proposal/approval/execution 분리 |
| 변경 감지 실패 | role event dashboard, incident alert |
| emergency 권한 남용 | 사후 review, reason code, support notice |
Admin 설계는 "누가 권한을 갖는가"뿐 아니라 "권한 변경이 얼마나 빨리 효력을 갖는가"를 포함한다. Upgrade와 mint 권한이 같은 multisig에 있으면 key compromise 하나가 code와 supply를 동시에 위험하게 만든다.
4. Pause와 freeze는 다르다
| 기능 | 범위 | 주로 쓰는 상황 | 사용자 영향 |
|---|---|---|---|
| Pause | contract 또는 product 전체 기능 | 전역 버그, exploit, route 장애 | 모든 사용자 결제/transfer 제한 |
| Freeze | 특정 주소 또는 계정 | 제재, 도난 자금, 계정 조사 | 대상 주소만 이동 제한 |
| Route disable | 특정 chain/token/route | CCTP 지연, liquidity stress | 일부 결제 수단 제한 |
| Manual review | 특정 payment/withdrawal | 이상 거래, reconciliation mismatch | 개별 건 처리 지연 |
Pause는 강력하지만 거칠다. 전체 checkout을 멈추면 신규 피해는 막을 수 있지만 refund, merchant settlement, emergency withdrawal도 같이 막힐 수 있다. 따라서 pause 상태에서도 어떤 함수가 열려 있어야 하는지 미리 정해야 한다.
5. Pausable은 함수마다 적용 범위를 정해야 한다
OpenZeppelin Pausable 문서 기준으로 module을 포함하는 것만으로 함수가 자동으로 멈추지 않는다. whenNotPaused, whenPaused, _requireNotPaused 같은 조건을 어디에 적용할지 설계해야 한다.
| 함수 | pause 중 기본 정책 | 이유 |
|---|---|---|
transfer | 중지 | 피해 확산 방지 |
transferFrom | 중지 | allowance 경로 우회 방지 |
approve | 정책 결정 필요 | 사고 중 새 allowance를 허용할지 검토 |
permit | 중지 또는 제한 | paused 중 allowance 생성 우회 방지 |
transferWithAuthorization | 중지 | signed transfer 우회 방지 |
mint | 중지 | 사고 중 supply 증가 방지 |
burn | 조건부 허용 가능 | redemption/emergency unwind 필요 가능 |
checkout claim | 중지 또는 manual review | service delivery와 정산 분리 필요 |
checkout refund | 조건부 허용 가능 | 사용자 보호와 회계 정책에 따라 다름 |
중요한 것은 정책을 테스트로 고정하는 것이다. "pause하면 멈춘다"가 아니라 어떤 함수가 revert하고 어떤 함수가 manual review로 넘어가는지를 표로 남긴다.
6. Emergency pause runbook
Runbook에는 unpause 조건도 있어야 한다. Pause는 실행보다 해제가 더 위험할 수 있다. Root cause가 확인되지 않았는데 unpause하면 같은 사고가 반복된다.
7. 테스트와 모니터링 항목
| 테스트 | 기대 결과 |
|---|---|
권한 없는 주소가 mint 호출 | revert |
권한 없는 주소가 freeze 호출 | revert |
| 마지막 admin 제거 시도 | 실패 또는 delay/2-step 요구 |
pause 상태에서 transfer | revert |
pause 상태에서 permit 또는 ERC-3009 | 정책대로 revert 또는 제한 |
freeze 대상 주소의 transferFrom | spender 경로도 실패 |
| freeze와 pause가 동시에 적용 | 우선순위와 revert reason이 문서와 일치 |
| unpause 후 정상 transfer | 정상 복구 |
| Dashboard signal | Action |
|---|---|
RoleGranted/RoleRevoked | production multisig 여부 확인 |
| unexpected mint | pause 검토, issuer 확인 |
| freeze spike | compliance/support 동기화 |
Paused | route status와 user notice 업데이트 |
Unpaused | smoke test와 settlement resume 확인 |
| upgrade event | storage/layout smoke test, admin 확인 |
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
컨트랙트OpenZeppelin AccessControl 기반 stablecoin 권한 패턴
Mock 학습용 코드와 달리 실제 배포 후보는 OZ
AccessControl을 상속한다._grantRole로 초기 권한을 분리하고,onlyRole로 함수마다 정확한 역할을 요구한다.
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";contract StablecoinV2 is AccessControl, Pausable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); constructor(address adminMultisig) { _grantRole(DEFAULT_ADMIN_ROLE, adminMultisig); // MINTER 등은 adminMultisig가 추후 grantRole 로 분리 부여 — 단일 EOA 집중 방지 } function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) whenNotPaused { _mint(to, amount); } function pause() external onlyRole(PAUSER_ROLE) { _pause(); }}컨트랙트Pausable 적용 범위를 함수별로 분기
whenNotPaused는 modifier로 명시한 함수에만 적용된다. burn/refund는 사고 중에도 열어둘 수 있게 정책에 따라 분리한다.
function transfer(address to, uint256 amount) public override whenNotPaused returns (bool) { return super.transfer(to, amount);}function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) { // pause 중에도 redemption은 열어둔다 — whenNotPaused 일부러 누락 _burn(from, amount);}function refund(bytes32 paymentId) external nonReentrant { // refund는 사용자 보호 차원에서 pause 중에도 허용 Payment storage p = payments[paymentId]; if (p.status != Status.Paid) revert PaymentNotPaid(paymentId); p.status = Status.Refunded; IERC20(p.token).safeTransfer(p.payer, p.amount);}컨트랙트AccessControlDefaultAdminRules — 2-step + delay
OZ 4.9+의
AccessControlDefaultAdminRules는 DEFAULT_ADMIN_ROLE을 2-step transfer + delay로 보호한다. admin 키 탈취·실수의 충격을 줄인다.
import {AccessControlDefaultAdminRules} from "@openzeppelin/contracts/access/extensions/AccessControlDefaultAdminRules.sol";contract StablecoinV2 is AccessControlDefaultAdminRules { constructor(address adminMultisig) AccessControlDefaultAdminRules(3 days, adminMultisig) // 3일 지연 {} // beginDefaultAdminTransfer → acceptDefaultAdminTransfer 두 단계 + 3일 대기 // cancelDefaultAdminTransfer 로 취소 가능}운영Role 이벤트 모니터링 — viem watcher 예시
권한 변경은 사고 신호다. RoleGranted/Revoked/Paused/Unpaused를 production multisig 가 아닌 곳에서 발생하면 즉시 알림.
import { createPublicClient, http, parseAbi } from "viem";import { mainnet } from "viem/chains";const TRUSTED_ADMIN = "0xMultisigAddress";const client = createPublicClient({ chain: mainnet, transport: http() });const abi = parseAbi([ "event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)", "event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender)"]);client.watchContractEvent({ address: STABLECOIN_ADDRESS, abi, eventName: "RoleGranted", onLogs: (logs) => { for (const log of logs) { if (log.args.sender?.toLowerCase() !== TRUSTED_ADMIN.toLowerCase()) { pageOncall({ severity: "critical", reason: "RoleGranted by untrusted sender", log }); } } }});강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| Role matrix | 각 role이 무엇을 할 수 있고 무엇을 하면 안 되는가? | stablecoin role matrix |
| Admin risk | DEFAULT_ADMIN_ROLE이 어디에 있고 변경 delay가 있는가? | admin threat model |
| Pause scope | pause 중 어떤 함수가 막히고 어떤 함수가 열리는가? | pause function coverage |
| Monitoring | role/pause/freeze/upgrade event가 dashboard에 잡히는가? | alert list와 runbook |
실무 예시
운영[CONTRACT] 상황: checkout 팀이 PAUSER_ROLE을 backend deployer EOA에 주고, FREEZER_ROLE과 UPGRADER_ROLE도 같은 주소에 두려 한다. 테스트넷에서는 편하지만 production에서는 key 하나가 checkout 전체, 사용자 주소 freeze, implementation upgrade를 동시에 통제한다.
| 문제 | 위험 | 수정 방향 |
|---|---|---|
| deployer EOA가 pauser | key 탈취 시 전역 중단 | multisig pauser, incident channel |
| freezer와 upgrader 겸직 | compliance 조치와 code 변경 권한 집중 | freezer는 compliance multisig, upgrade는 timelock |
| pause 중 refund 정책 없음 | 사고 중 사용자 자금 고착 | refund/emergency path 사전 정의 |
| role event dashboard 없음 | 권한 변경 감지 지연 | RoleGranted, RoleRevoked, Paused alert |
이 예시의 산출물은 코드 수정 제안만이 아니다. Role owner, multisig threshold, timelock delay, pause 대상 함수, unpause 조건을 포함한 운영 문서가 필요하다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
AccessControl을 쓰면 권한 설계가 자동으로 안전해진다. | role admin, multisig, timelock, monitoring을 따로 설계해야 한다. |
Pausable을 import하면 모든 함수가 멈춘다. | modifier가 붙은 함수만 멈춘다. |
| pause와 freeze는 같은 기능이라고 본다. | pause는 전역, freeze는 주소 단위다. |
| admin key는 개발팀만 알면 된다고 본다. | support, compliance, finance가 영향을 받으므로 운영 runbook이 필요하다. |
실습 과제
- 컨트랙트Stablecoin role matrix 만들기: DEFAULT_ADMIN, MINTER, BURNER, FREEZER, PAUSER, UPGRADER, RELAYER role별 owner, admin role, 허용 함수, 금지 함수, multisig/timelock 요구, dashboard event를 표로 정리한다.
- 운영Emergency pause runbook 작성하기: pause trigger, 승인자, pause 대상 함수, refund/emergency path, unpause 조건, 사용자 안내, dashboard alert를 단계별로 작성한다.
- 컨트랙트Freeze/Pause 우회 테스트 설계하기: frozen 또는 paused 상태에서 transfer, transferFrom, approve, permit, ERC-3009, checkout claim/refund가 어떻게 동작해야 하는지 테스트 표로 작성한다.
완료 기준
- role matrix를 만들었다.
- pause 범위를 설명했다.
- 권한 변경 모니터링 항목을 정의했다.
- emergency pause와 unpause 승인 절차를 runbook으로 작성했다.
근거 자료
- 권한관리 AccessControl Pausable: 02-보안-테스트/02-권한관리-AccessControl-Pausable.md
- OpenZeppelin Contracts: Access Control: https://docs.openzeppelin.com/contracts/5.x/access-control
- OpenZeppelin Contracts API: Access: https://docs.openzeppelin.com/contracts/api/access
- OpenZeppelin Contracts API: Pausable: https://docs.openzeppelin.com/contracts/5.x/api/utils
- OpenZeppelin AccessControlDefaultAdminRules: https://docs.openzeppelin.com/contracts/4.x/api/access#AccessControlDefaultAdminRules