Skip to content

Commit cb87d98

Browse files
smartcontract: enforce Permission-based authorization in existing instructions
1 parent 53175e4 commit cb87d98

30 files changed

Lines changed: 842 additions & 1264 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Onchain Programs
12+
- Serviceability: add `Permission` account with `CreatePermission`, `UpdatePermission`, `DeletePermission`, `SuspendPermission`, and `ResumePermission` instructions for managing per-keypair permission bitmasks onchain
13+
- SDK
14+
- Split `execute_transaction` into `execute_transaction` (no auth) and `execute_authorized_transaction` (injects Permission PDA) to avoid breaking processors that use `accounts.len()` for optional-account detection
15+
- CLI
16+
- Add `permission get`, `permission list`, and `permission set` commands with table and JSON output; `permission set` supports incremental `--add` / `--remove` flags and creates or updates the account as needed
1117
- Activator
1218
- Suppress noisy program log output from race conditions caused by dual event processing (websocket + snapshot poll). The SDK's new `execute_transaction_quiet` returns a `SimulationError` with program logs; the activator verifies suspected races by re-fetching user state before deciding whether to print logs ([#3197](https://github.com/malbeclabs/doublezero/pull/3197))
1319
- Telemetry
@@ -62,7 +68,6 @@ All notable changes to this project will be documented in this file.
6268
- Serviceability: DeleteUser instruction supports atomic deallocate+closeaccount when OnchainAllocation feature is enabled
6369
- Serviceability: CreateLink instruction supports atomic create+allocate+activate when OnchainAllocation feature is enabled
6470
- Serviceability: DeleteLink instruction supports atomic deallocate+closeaccount when OnchainAllocation feature is enabled
65-
- Serviceability: CreateSubscribeUser instruction supports atomic create+allocate+activate when OnchainAllocation feature is enabled
6671

6772
## [v0.9.0](https://github.com/malbeclabs/doublezero/compare/client/v0.8.11...client/v0.9.0) - 2026-02-27
6873

activator/src/process/user.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,7 +1765,7 @@ mod tests {
17651765
UserStatus::Deleting,
17661766
|user_service, _, seq| {
17671767
user_service
1768-
.expect_execute_transaction_quiet()
1768+
.expect_execute_authorized_transaction()
17691769
.times(1)
17701770
.in_sequence(seq)
17711771
.with(
@@ -1789,7 +1789,7 @@ mod tests {
17891789
UserStatus::PendingBan,
17901790
|user_service, _, seq| {
17911791
user_service
1792-
.expect_execute_transaction_quiet()
1792+
.expect_execute_authorized_transaction()
17931793
.times(1)
17941794
.in_sequence(seq)
17951795
.with(
@@ -2837,7 +2837,7 @@ mod tests {
28372837

28382838
// Stateless mode: use_onchain_deallocation=true
28392839
client
2840-
.expect_execute_transaction_quiet()
2840+
.expect_execute_authorized_transaction()
28412841
.times(1)
28422842
.in_sequence(&mut seq)
28432843
.with(
@@ -2941,7 +2941,7 @@ mod tests {
29412941
.returning(move |_| Ok(AccountData::User(user2.clone())));
29422942

29432943
client
2944-
.expect_execute_transaction_quiet()
2944+
.expect_execute_authorized_transaction()
29452945
.times(1)
29462946
.in_sequence(&mut seq)
29472947
.with(

smartcontract/programs/doublezero-serviceability/src/instructions.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,6 @@ mod tests {
10351035
publisher: false,
10361036
subscriber: true,
10371037
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
1038-
dz_prefix_count: 0,
10391038
}),
10401039
"CreateSubscribeUser",
10411040
);

smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use crate::{
2+
authorize::authorize,
23
error::DoubleZeroError,
34
serializer::try_acc_close,
4-
state::{accesspass::AccessPass, accounttype::AccountType, globalstate::GlobalState},
5+
state::{
6+
accesspass::AccessPass, accounttype::AccountType, globalstate::GlobalState,
7+
permission::permission_flags,
8+
},
59
};
610
use borsh::BorshSerialize;
711
use borsh_incremental::BorshDeserializeIncremental;
@@ -70,11 +74,15 @@ pub fn process_close_access_pass(
7074
"Invalid System Program Account Owner"
7175
);
7276

73-
// Parse the global state account & check if the payer is in the allowlist
77+
// Parse the global state account & check authorization
7478
let globalstate = GlobalState::try_from(globalstate_account)?;
75-
if !globalstate.foundation_allowlist.contains(payer_account.key) {
76-
return Err(DoubleZeroError::NotAllowed.into());
77-
}
79+
authorize(
80+
program_id,
81+
accounts_iter,
82+
payer_account.key,
83+
&globalstate,
84+
permission_flags::ACCESS_PASS_ADMIN,
85+
)?;
7886

7987
if let Ok(data) = accesspass_account.try_borrow_data() {
8088
let account_type: AccountType = data[0].into();

smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::{
2+
authorize::authorize,
23
error::DoubleZeroError,
34
pda::*,
45
seeds::{SEED_ACCESS_PASS, SEED_PREFIX},
@@ -7,6 +8,7 @@ use crate::{
78
accesspass::{AccessPass, AccessPassStatus, AccessPassType, ALLOW_MULTIPLE_IP, IS_DYNAMIC},
89
accounttype::AccountType,
910
globalstate::GlobalState,
11+
permission::permission_flags,
1012
tenant::Tenant,
1113
},
1214
};
@@ -107,19 +109,15 @@ pub fn process_set_access_pass(
107109
"Invalid System Program Account Owner"
108110
);
109111

110-
// Parse the global state account & check if the payer is in the allowlist
112+
// Parse the global state account & check authorization
111113
let globalstate = GlobalState::try_from(globalstate_account)?;
112-
if globalstate.sentinel_authority_pk != *payer_account.key
113-
&& !globalstate.foundation_allowlist.contains(payer_account.key)
114-
{
115-
msg!(
116-
"sentinel_authority_pk: {} payer: {} foundation_allowlist: {:?}",
117-
globalstate.sentinel_authority_pk,
118-
payer_account.key,
119-
globalstate.foundation_allowlist
120-
);
121-
return Err(DoubleZeroError::NotAllowed.into());
122-
}
114+
authorize(
115+
program_id,
116+
accounts_iter,
117+
payer_account.key,
118+
&globalstate,
119+
permission_flags::ACCESS_PASS_ADMIN,
120+
)?;
123121

124122
if let AccessPassType::SolanaValidator(node_id) = value.accesspass_type {
125123
if node_id == Pubkey::default() {

smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs

Lines changed: 76 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use solana_program::{
1414
account_info::{next_account_info, AccountInfo},
1515
entrypoint::ProgramResult,
1616
msg,
17-
program_error::ProgramError,
1817
pubkey::Pubkey,
1918
};
2019
use std::{fmt, net::Ipv4Addr};
@@ -36,90 +35,6 @@ impl fmt::Debug for MulticastGroupSubscribeArgs {
3635
}
3736
}
3837

39-
pub struct SubscribeUserResult {
40-
pub mgroup: MulticastGroup,
41-
/// True if the publisher list transitioned between empty and non-empty
42-
/// (gained first publisher or lost last publisher). Callers that need to
43-
/// trigger activator reprocessing should check this flag.
44-
pub publisher_list_transitioned: bool,
45-
}
46-
47-
/// Toggle a user's multicast group subscription.
48-
///
49-
/// Handles both create-time subscription (user lists start empty, only adds)
50-
/// and post-activation subscription changes (add/remove toggle). The caller is
51-
/// responsible for setting `user.status = Updating` when
52-
/// `publisher_list_transitioned` is true and the user is already activated.
53-
pub fn subscribe_user_to_multicastgroup(
54-
mgroup_account: &AccountInfo,
55-
accesspass: &AccessPass,
56-
user: &mut User,
57-
publisher: bool,
58-
subscriber: bool,
59-
) -> Result<SubscribeUserResult, ProgramError> {
60-
let mut mgroup = MulticastGroup::try_from(mgroup_account)?;
61-
if mgroup.status != MulticastGroupStatus::Activated {
62-
msg!("MulticastGroupStatus: {:?}", mgroup.status);
63-
return Err(DoubleZeroError::InvalidStatus.into());
64-
}
65-
66-
// Check allowlists for additions
67-
if publisher && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) {
68-
msg!("{:?}", accesspass);
69-
return Err(DoubleZeroError::NotAllowed.into());
70-
}
71-
if subscriber && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key) {
72-
msg!("{:?}", accesspass);
73-
return Err(DoubleZeroError::NotAllowed.into());
74-
}
75-
76-
let mut publisher_list_transitioned = false;
77-
78-
// Manage the publisher list
79-
match publisher {
80-
true => {
81-
if !user.publishers.contains(mgroup_account.key) {
82-
let was_empty = user.publishers.is_empty();
83-
mgroup.publisher_count = mgroup.publisher_count.saturating_add(1);
84-
user.publishers.push(*mgroup_account.key);
85-
if was_empty {
86-
publisher_list_transitioned = true;
87-
}
88-
}
89-
}
90-
false => {
91-
if user.publishers.contains(mgroup_account.key) {
92-
mgroup.publisher_count = mgroup.publisher_count.saturating_sub(1);
93-
user.publishers.retain(|&x| x != *mgroup_account.key);
94-
if user.publishers.is_empty() {
95-
publisher_list_transitioned = true;
96-
}
97-
}
98-
}
99-
}
100-
101-
// Manage the subscriber list
102-
match subscriber {
103-
true => {
104-
if !user.subscribers.contains(mgroup_account.key) {
105-
mgroup.subscriber_count = mgroup.subscriber_count.saturating_add(1);
106-
user.subscribers.push(*mgroup_account.key);
107-
}
108-
}
109-
false => {
110-
if user.subscribers.contains(mgroup_account.key) {
111-
mgroup.subscriber_count = mgroup.subscriber_count.saturating_sub(1);
112-
user.subscribers.retain(|&x| x != *mgroup_account.key);
113-
}
114-
}
115-
}
116-
117-
Ok(SubscribeUserResult {
118-
mgroup,
119-
publisher_list_transitioned,
120-
})
121-
}
122-
12338
pub fn process_subscribe_multicastgroup(
12439
program_id: &Pubkey,
12540
accounts: &[AccountInfo],
@@ -163,7 +78,15 @@ pub fn process_subscribe_multicastgroup(
16378
);
16479
assert!(user_account.is_writable, "user account is not writable");
16580

166-
// Parse and validate user
81+
// Parse accounts
82+
let mut mgroup: MulticastGroup = MulticastGroup::try_from(mgroup_account)?;
83+
if mgroup.status != MulticastGroupStatus::Activated {
84+
#[cfg(test)]
85+
msg!("MulticastGroupStatus: {:?}", mgroup.status);
86+
87+
return Err(DoubleZeroError::InvalidStatus.into());
88+
}
89+
16790
let mut user: User = User::try_from(user_account)?;
16891
if user.status != UserStatus::Activated && user.status != UserStatus::Updating {
16992
msg!("UserStatus: {:?}", user.status);
@@ -193,22 +116,76 @@ pub fn process_subscribe_multicastgroup(
193116
return Err(DoubleZeroError::Unauthorized.into());
194117
}
195118

196-
let result = subscribe_user_to_multicastgroup(
197-
mgroup_account,
198-
&accesspass,
199-
&mut user,
200-
value.publisher,
201-
value.subscriber,
202-
)?;
203-
204-
// Trigger activator reprocessing when publisher list transitions
205-
// (gaining first publisher requires dz_ip allocation, losing last means it's no longer needed)
206-
if result.publisher_list_transitioned {
207-
user.status = UserStatus::Updating;
119+
// Check if the user is in the allowlist
120+
if value.publisher && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) {
121+
msg!("{:?}", accesspass);
122+
return Err(DoubleZeroError::NotAllowed.into());
123+
}
124+
if value.subscriber && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key) {
125+
msg!("{:?}", accesspass);
126+
return Err(DoubleZeroError::NotAllowed.into());
127+
}
128+
129+
// Manage the publisher lists
130+
match value.publisher {
131+
true => {
132+
if !user.publishers.contains(mgroup_account.key) {
133+
let was_empty = user.publishers.is_empty();
134+
// Increment publisher count
135+
mgroup.publisher_count = mgroup.publisher_count.saturating_add(1);
136+
// Add multicast group to user's publisher list
137+
user.publishers.push(*mgroup_account.key);
138+
// Only trigger activator reprocessing when gaining first publisher
139+
// (activator needs to allocate dz_ip)
140+
if was_empty {
141+
user.status = UserStatus::Updating;
142+
}
143+
}
144+
}
145+
false => {
146+
if user.publishers.contains(mgroup_account.key) {
147+
// Decrement publisher count
148+
mgroup.publisher_count = mgroup.publisher_count.saturating_sub(1);
149+
// Remove multicast group from user's publisher list
150+
user.publishers.retain(|&x| x != *mgroup_account.key);
151+
// Trigger activator reprocessing when losing last publisher
152+
// (dz_ip no longer needed)
153+
if user.publishers.is_empty() {
154+
user.status = UserStatus::Updating;
155+
}
156+
}
157+
}
158+
}
159+
160+
// Manage the subscriber lists
161+
match value.subscriber {
162+
true => {
163+
if !user.subscribers.contains(mgroup_account.key) {
164+
// Increment subscriber count
165+
mgroup.subscriber_count = mgroup.subscriber_count.saturating_add(1);
166+
// Add multicast group to user's subscriber list
167+
user.subscribers.push(*mgroup_account.key);
168+
// No activator reprocessing needed for subscriber changes
169+
// (subscriber groups don't affect tunnel or dz_ip config)
170+
}
171+
}
172+
false => {
173+
if user.subscribers.contains(mgroup_account.key) {
174+
// Decrement subscriber count
175+
mgroup.subscriber_count = mgroup.subscriber_count.saturating_sub(1);
176+
// Remove multicast group from user's subscriber list
177+
user.subscribers.retain(|&x| x != *mgroup_account.key);
178+
}
179+
}
208180
}
209181

210-
try_acc_write(&result.mgroup, mgroup_account, payer_account, accounts)?;
182+
try_acc_write(&mgroup, mgroup_account, payer_account, accounts)?;
211183
try_acc_write(&user, user_account, payer_account, accounts)?;
212184

185+
#[cfg(test)]
186+
msg!("Updated: {:?}", mgroup);
187+
#[cfg(test)]
188+
msg!("Updated: {:?}", user_account);
189+
213190
Ok(())
214191
}

smartcontract/programs/doublezero-serviceability/src/processors/user/ban.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use crate::{
2+
authorize::authorize,
23
error::DoubleZeroError,
34
serializer::try_acc_write,
4-
state::{globalstate::GlobalState, user::*},
5+
state::{globalstate::GlobalState, permission::permission_flags, user::*},
56
};
67
use borsh::BorshSerialize;
78
use borsh_incremental::BorshDeserializeIncremental;
@@ -56,9 +57,13 @@ pub fn process_ban_user(
5657
assert!(user_account.is_writable, "PDA Account is not writable");
5758

5859
let globalstate = GlobalState::try_from(globalstate_account)?;
59-
if globalstate.activator_authority_pk != *payer_account.key {
60-
return Err(DoubleZeroError::NotAllowed.into());
61-
}
60+
authorize(
61+
program_id,
62+
accounts_iter,
63+
payer_account.key,
64+
&globalstate,
65+
permission_flags::USER_ADMIN,
66+
)?;
6267

6368
let mut user: User = User::try_from(user_account)?;
6469
if user.status != UserStatus::PendingBan {

0 commit comments

Comments
 (0)