# Pièges de l'Access Control — Au-delà de onlyOwner
Le modifier onlyOwner est le premier pattern de contrôle d'accès que tout développeur Solidity apprend. Mais c'est aussi l'un des plus dangereux en production.
Le Problème du Single Point of Failure
// ❌ Dangereux en production
contract Token {
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
Risques :
- Clé privée compromise → perte totale du contrôle
- Clé perdue → contrat gelé pour toujours
- Insider attack → l'owner peut tout faire
- Pas de séparation des privilèges
Solution 1 : Role-Based Access Control (RBAC)
OpenZeppelin AccessControl permet de définir des rôles granulaires :
import "@openzeppelin/contracts/access/AccessControl.sol";
contract VaultWithRoles is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
bool public paused;
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(PAUSER_ROLE, msg.sender);
}
function withdraw(uint256 amount) external onlyRole(WITHDRAWER_ROLE) {
require(!paused, "Paused");
// logique de retrait
}
function pause() external onlyRole(PAUSER_ROLE) {
paused = true;
}
function grantWithdrawer(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
grantRole(WITHDRAWER_ROLE, account);
}
}
Avantages :
- Séparation des privilèges (withdrawal ≠ pause ≠ upgrade)
- Plusieurs adresses par rôle
- Révocation granulaire
Solution 2 : Two-Step Ownership Transfer
Ne jamais transférer l'ownership en une seule transaction :
contract SafeOwnable {
address public owner;
address public pendingOwner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "Invalid address");
pendingOwner = newOwner;
}
function acceptOwnership() external {
require(msg.sender == pendingOwner, "Not pending owner");
address oldOwner = owner;
owner = pendingOwner;
pendingOwner = address(0);
emit OwnershipTransferred(oldOwner, owner);
}
}
Protection : évite les typos fatales (0x123... au lieu de 0x124...).
Solution 3 : Multisig via Gnosis Safe
Pour les contrats critiques, utilisez un multisig 3-of-5 ou 4-of-7 :
contract Treasury {
address public gnosisSafe; // 4-of-7 multisig
modifier onlyGovernance() {
require(msg.sender == gnosisSafe, "Not governance");
_;
}
function emergencyWithdraw(address token, uint256 amount)
external
onlyGovernance
{
// Nécessite 4 signatures sur 7
IERC20(token).transfer(gnosisSafe, amount);
}
}
Solution 4 : Timelock pour les Actions Critiques
Ajoutez un délai entre proposition et exécution :
contract TimelockController {
mapping(bytes32 => uint256) public queuedTransactions;
uint256 public constant DELAY = 2 days;
event TransactionQueued(bytes32 indexed txHash, uint256 executeTime);
function queueTransaction(
address target,
bytes memory data
) external onlyOwner returns (bytes32) {
bytes32 txHash = keccak256(abi.encode(target, data, block.timestamp));
queuedTransactions[txHash] = block.timestamp + DELAY;
emit TransactionQueued(txHash, queuedTransactions[txHash]);
return txHash;
}
function executeTransaction(
address target,
bytes memory data,
uint256 queuedAt
) external onlyOwner {
bytes32 txHash = keccak256(abi.encode(target, data, queuedAt));
uint256 executeTime = queuedTransactions[txHash];
require(executeTime != 0, "Not queued");
require(block.timestamp >= executeTime, "Timelock not expired");
delete queuedTransactions[txHash];
(bool success,) = target.call(data);
require(success, "Execution failed");
}
}
Avantage : la communauté a 48h pour réagir à une action malveillante.
Pièges Courants à Éviter
1. Oublier de Retirer les Droits du Déployer
// ❌ Le déployer garde tous les rôles !
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, governance);
}
// ✅ Révocation explicite
constructor(address governance) {
_grantRole(DEFAULT_ADMIN_ROLE, governance);
_grantRole(MINTER_ROLE, governance);
renounceRole(DEFAULT_ADMIN_ROLE, msg.sender); // ⚠️ Irréversible !
}
2. Missing Access Control sur les Fonctions Critiques
// ❌ Oubli du modifier → n'importe qui peut mint
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
Utilisez Slither :
slither . --detect missing-zero-check,unprotected-upgrade
3. Pas d'Events sur les Changements de Rôles
// ✅ Toujours émettre des events
function grantRole(bytes32 role, address account) public override {
super.grantRole(role, account);
emit RoleGranted(role, account, msg.sender);
}
Les events permettent le monitoring off-chain (Tenderly, Forta).
Best Practices en Production
renounceRole fonctionneExemple Complet : DeFi Protocol
contract DeFiProtocol is AccessControl {
bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE");
bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");
bytes32 public constant STRATEGIST_ROLE = keccak256("STRATEGIST_ROLE");
address public immutable timelockController;
bool public paused;
constructor(address _timelock, address[] memory guardians) {
timelockController = _timelock;
// Le timelock a le rôle admin
_grantRole(DEFAULT_ADMIN_ROLE, _timelock);
// 3 guardians peuvent pauser en cas d'urgence
for (uint256 i = 0; i < guardians.length; i++) {
_grantRole(GUARDIAN_ROLE, guardians[i]);
}
// Le déployer n'a aucun rôle permanent
}
function emergencyPause() external onlyRole(GUARDIAN_ROLE) {
paused = true;
emit EmergencyPause(msg.sender);
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
// Nécessite une transaction timelock (48h de délai)
paused = false;
}
}
Conclusion
onlyOwner est un point de départ pour le prototypage, mais pas une solution de production. Adoptez RBAC + multisig + timelock pour sécuriser vos contrats critiques.
Ressources :