Access control у Anchor-програмі — це механізм, який визначає, хто має право викликати конкретну інструкцію та змінювати стан програми. Неправильна реалізація призводить до повного захоплення контракту: будь-хто зможе змінити конфігурацію, вивести кошти або зупинити роботу. Нижче наведено перевірені патерни для чотирьох основних сценаріїв, межі їх застосування та способи перевірки коректності.
Pattern: owner-only функції
Найпоширеніший випадок — обмеження виклику інструкції лише власником програми або конкретного облікового запису. У Anchor це реалізується через перевірку ключа або через constraint `has_one`.
Варіант 1: Пряма перевірка ключа.
Коли обліковий запис власника зберігається в стані програми, перевірка виглядає так:
Увага: наведений код ілюструє патерн. Перед використанням у production обов'язково проведіть експертний перегляд безпеки.
У структурі облікового запису визначається поле `authority`:
- У макросі `#[account]` поле `authority` типу `Pubkey` зберігає ключ власника.
- У інструкції додається обліковий запис `authority: Signer<'info>`.
- У constraint вказується: `config.authority == authority.key()`.
Очікуваний результат: інструкція виконається лише якщо підписувач збігається з `authority`, збереженим в обліковому записі `config`. Інакше Anchor поверне помилку `ConstraintOwner` або кастомну помилку з вказаним кодом.
Варіант 2: Constraint `has_one`.
Коротший запис тієї ж логіки. Якщо в обліковому записі є поле `authority`, а в інструкції — параметр з такою самою назвою, достатньо додати `#[account(has_one = authority)]`:
- Anchor автоматично порівняє `config.authority` з `authority.key()`.
- Підписувача (`Signer`) все одно треба вказати окремо — `has_one` перевіряє лише збіг ключів, а не наявність підпису.
Межі застосування: цей патерн підходить для одиночного власника. Якщо потрібна мультивласність (multisig), доведеться делегувати виклик через окрему програму-авторизатор або реалізувати порогову логіку всередині інструкції.
Перевірка: напишіть тест, де інструкцію викликає адреса, відмінна від `authority`. Переконайтеся, що транзакція повертає помилку з коректним кодом, а стан не змінився.
План відкату: якщо після розгортання виявиться, що constraint занадто суворий (наприклад, потрібна ротація ключів), оновіть `authority` через окрему інструкцію `update_authority` з аналогічним `has_one` — це дозволяє передати права іншому ключу без перевипуску програми.
Ролі та дозволи через PDA
Коли програма потребує кількох ролей (admin, manager, operator) і делегування прав без передачі приватного ключа, використовують PDA (Program Derived Address — адреса, похідна від програми та набору сидів).
Принцип: PDA належить програмі, тому ніхто ззовні не може підписати транзакцію від його імені. Проте через `seeds` та `seeds::program` Anchor може верифікувати, що конкретний обліковий запис є саме тим PDA, який очікує програма.
Реалізація ролей:
- Створіть обліковий запис `RoleAccount` з полями `role` (enum: `Admin`, `Manager`, `Operator`) та `wallet` (Pubkey реального користувача).
- Виведіть PDA для цього запису з сидів, що включають `b"role"`, `wallet` та, за потреби, ідентифікатор програми.
- У цільовій інструкції отримайте `RoleAccount` через `#[account(seeds = [...], bump)]` та перевірте `role_account.role == Role::Admin` у constraint.
Делегування через PDA-авторизатора: для складніших сценаріїв створюють окремий PDA, який виступає авторизатором для інших облікових записів. Наприклад, treasury-акаунт має `authority = pda_authority.key()`. Інструкція, яка змінює treasury, верифікує PDA-авторизатора через сиди, а потім виконує зміну від його імені через `CPI` з `invoke_signed`.
Очікуваний результат: права доступу прив'язані до стану на ланцюжку, а не до приватних ключів. Зміна ролі вимагає виклику інструкції `assign_role`, яка також має власний access control (зазвичай admin-only).
Межі застосування: цей патерн збільшує складність програми та кількість облікових записів у транзакції. Для простих випадків з одним admin-ключем краще залишитися на `has_one`. PDA-ролі доцільні, коли ролі призначаються динамічно або їхня кількість зростає.
Перевірка: у тестах створіть два `RoleAccount` з різними ролями. Переконайтеся, що інструкція з `role == Admin` проходить для admin і відхиляється для operator. Також перевірте, що неможливо створити `RoleAccount` з довільними сидами, які обійдуть перевірку.
План відкату: якщо логіку ролей треба змінити, оновіть enum та constraints у новій версії програми. Існуючі `RoleAccount` залишаться на ланцюжку — переконайтеся, що міграція не порушує їхню структуру, або реалізуйте інструкцію міграції.
Перевірка підписувача в інструкціях
Anchor автоматично перевіряє наявність підпису для облікових записів із типом `Signer<'info>`. Проте є ситуації, де цього недостатньо або де автоматична перевірка не спрацьовує.
Сценарій 1: Непідписуваний обліковий запис у constraint.
Якщо ви перевіряєте `account.key() == expected_key`, але не вказали `Signer`, будь-хто зможе передати цей обліковий запис у транзакцію — ключ збігатиметься, але підпису не буде. Це класична вразливість: перевірено хто, але не перевірено, що цей хто згоден.
Рішення: завжди додавати `Signer` до типу облікового запису, якщо він має авторизувати дію. Виняток — коли обліковий запис є PDA і підписується через `invoke_signed` у CPI.
Сценарій 2: Перевірка `is_signer` вручну.
Іноді обліковий запис приходить як `AccountInfo` або `UncheckedAccount`, і перевірка підпису лежить на розробнику:
- Зверніться до `ctx.accounts.account.is_signer` і додайте явну перевірку в логіку інструкції або в кастомний constraint.
- Це доречно в CPI-викликах, де підписувач передається транзитом і його тип не можна вказати як `Signer` на рівні структури.
Сценарій 3: Remaining accounts.
Коли інструкція приймає динамічний список облікових записів через `remaining_accounts`, Anchor не застосовує жодних автоматичних перевірок. Кожен елемент цього масиву треба перевіряти вручну: і ключ, і підпис, і власника, і дані.
Очікуваний результат: жодна інструкція, що змінює стан, не виконується без явної перевірки підпису авторизованої особи.
Перевірка: для кожної інструкції складіть таблицю: який обліковий запис підписує, як це верифікується, чи є виключення. Потім у тестах передайте правильний ключ без підпису — транзакція має відхилитися.
Типові помилки реалізації access control
Нижче наведено фактичні причини вразливостей, які повторюються в аудитацьких звітах. Кожна описана за наслідком і реальною причиною, а не за припущеннями.
Відсутність перевірки підпису при збігу ключів. Розробник порівнює `account.key() == authority.key()`, але забуває додати `Signer` до типу. Причина: фокус на верифікації ідентичності ключа, а не на верифікації згоди власника ключа. Наслідок: будь-яка транзакція, що містить цей обліковий запис (навіть без підпису), проходить перевірку.
Плутанина між `owner` облікового запису та `authority` програми. У Solana кожен обліковий запис має поле `owner` — це програма, яка має право змінювати його дані. Деякі розробники перевіряють `account.owner == program_id`, вважаючи це access control. Насправді це перевірка домену даних, а не авторизації виклику. Причина: непорозуміння семантики поля `owner` у структурі `AccountInfo`. Наслідок: будь-хто може викликати інструкцію, якщо передає коректний обліковий запис, що належить програмі.
Зайвий `mut` на облікових записах, які не повинні змінюватися. Якщо `authority` позначено як `mut`, а в інструкції його дані не змінюються, це не є вразливістю саме по собі, але створює хибне відчуття контролю і ускладнює аудит. Причина: копіпаста з інших інструкцій або шаблон Anchor, де всі акаунти за замовчуванням `mut`. Наслідок: при рефакторингу хтось може випадково змінити `authority` всередині інструкції без додаткового контролю.
Використання `UncheckedAccount` без ручних перевірок. Тип `UncheckedAccount` вимикає всі автоматичні перевірки Anchor. Це доречно лише для специфічних сценаріїв (CPI з інших програм, системні акаунти). Причина: спроба обійти помилку компіляції замість того, щоб налаштувати правильний тип або constraint. Наслідок: обліковий запис може мати довільного власника, довільні дані та відсутній підпис — програма сприйме його як валідний.
Ігнорування `remaining_accounts`. Інструкція приймає `remaining_accounts`, але не перевіряє їхні ключі, підписи чи власників перед використанням у CPI або записі даних. Причина: передбачалося, що клієнтський код завжди передає коректні акаунти. Наслідок: атакуючий передає шкідливий обліковий запис, який програма використовує як цільову адресу для переказу коштів або запису стану.
Порада щодо діагностики: для кожної інструкції програми складіть перелік усіх облікових записів із зазначенням: хто їх підписує, як програма це верифікує, чи є винятки. Якщо хоча б для одного облікового запису, що впливає на стан, відповідь «не перевіряється» — це вразливість, яку треба усунути до розгортання.
Наступний крок: після того як access control реалізовано та перевірено тестами, варто розглянути суміжний клас вразливостей — перейдіть до матеріалу про як захиститися від reentrancy-подібних вразливостей.