Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ All notable changes to this project will be documented in this file.
- Bump the e2e base image to agave v3.0.4 and build the onchain programs with platform-tools v1.54 to match the solana 3.0 migration.
- Pin the e2e ledger `solana-test-validator` to the deploy floor (agave 2.2.16, testnet) so a green e2e proves a change actually deploys and runs on the production cluster runtime. Previously the runtime validator rode the SBF build toolchain version (2.3.13); it is now decoupled and pinned independently. The build toolchain is unchanged. (#3957)
- Fix a `TestE2E_Multicast` flake where the post-connect `doublezero status` check could observe only the first multicast group. After incrementally adding the second group, the test relied on `WaitForTunnelUp`, which returns immediately because the first tunnel is already up, so the single-shot status assertion could race the onchain propagation and the daemon's cached program data. Add an `Eventually` poll on `doublezero user list` for both groups before the post-connect checks.
- Smartcontract (Serviceability)
- Honor a Permission account bearing `ACCESS_PASS_ADMIN` / `USER_ADMIN` on the `UpdateMulticastGroupRoles` grant path and the `CreateSubscribeUser` owner-override, so the feed oracle can subscribe validator-owned users and provision cross-owner users on a Permission account instead of `foundation_allowlist` membership. Granting multicast roles requires `ACCESS_PASS_ADMIN`; removal-only cleanup stays `USER_ADMIN`. Both handlers disambiguate the optional trailing Permission account from the EdgeSeat feed/device accounts by PDA match. The change is additive: every existing caller keeps its current authority. (#3966)

## [v0.28.0](https://github.com/malbeclabs/doublezero/compare/client/v0.27.1...client/v0.28.0) - 2026-06-26

Expand Down
6 changes: 4 additions & 2 deletions smartcontract/cli/src/permission/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ use std::{collections::HashMap, io::Write};
/// `AUTHORIZE_GATED_FLAGS` (the serviceability crate owns that list); this const only
/// records the residual GlobalState-gated privileges that no flag covers.
const NON_MIGRATED_SUBSYSTEMS: &[&str] = &[
"user create (custom owner override requires foundation_allowlist or sentinel_authority; \
qa_allowlist/foundation_allowlist bypass device status & seat limits)",
"user create (custom owner override now honors USER_ADMIN via authorize() — foundation_allowlist \
via the USER_ADMIN legacy mapping, Permission holders directly — so only the sentinel_authority \
direct check remains un-migrated; qa_allowlist/foundation_allowlist still bypass device status & \
seat limits)",
];

/// A legacy key that authorizes a migrated instruction today but lacks an equivalent
Expand Down
26 changes: 24 additions & 2 deletions smartcontract/programs/doublezero-serviceability/PERMISSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ sufficient.
|---------------------|---------|------------------------------------------------------|
| `ACTIVATOR` | `1<<7` | Activate/reject network entities |
| `SENTINEL` | `1<<8` | Suspend network entities |
| `USER_ADMIN` | `1<<9` | Administer users (ban, delete, close account) |
| `ACCESS_PASS_ADMIN` | `1<<10` | Create and modify access passes |
| `USER_ADMIN` | `1<<9` | Administer users (ban, delete, close account); create users with a custom owner; strip a user's multicast roles as delete/ban cleanup |
| `ACCESS_PASS_ADMIN` | `1<<10` | Create and modify access passes; manage their multicast subscriber allowlists; grant multicast roles |

### Tier 4 — Technical/automated roles

Expand Down Expand Up @@ -113,6 +113,28 @@ Even when `RequirePermissionAccounts` is set (legacy mode disabled), `foundation
can still call Permission instructions without a Permission account. This prevents the foundation
from being locked out of the permission system when migrating to strict mode.

### Domain instruction enforcement (access pass / multicast / user)

These instructions call `authorize()` and therefore honor a Permission account bearing the listed
flag, in addition to their legacy authorities (and, where noted, an OR'd resource-owner branch).
The Permission path has **no owns-it restriction**, so a flag holder may act across owners — this
is what lets the feed oracle operate on validator-owned passes and users while holding only
`ACCESS_PASS_ADMIN | USER_ADMIN`, rather than sitting in `foundation_allowlist`.

| Instruction | Flag | Also authorized by (besides the flag's legacy authorities) |
|------------------------------------|---------------------|-------------------------------------------------------------|
| `SetAccessPass` | `ACCESS_PASS_ADMIN` | a `tenant_add` administrator (feed authority owns-it gated) |
| `CloseAccessPass` | `ACCESS_PASS_ADMIN` | (feed authority owns-it gated) |
| `AddMulticastGroupSubAllowlist` | `ACCESS_PASS_ADMIN` | the multicast group owner (feed authority owns-it gated) |
| `RemoveMulticastGroupSubAllowlist` | `ACCESS_PASS_ADMIN` | the multicast group owner |
| `UpdateMulticastGroupRoles` (grant roles) | `ACCESS_PASS_ADMIN` | the access pass `user_payer` |
| `UpdateMulticastGroupRoles` (remove roles) | `USER_ADMIN` | the access pass `user_payer` (removal-only cleanup) |
| `CreateSubscribeUser` (custom owner) | `USER_ADMIN` | the sentinel authority |
| `DeleteUser` | `USER_ADMIN` | the user's `owner` |

This table covers the access-pass, multicast, and user surface. Other domains (topology, resource,
index, permission) also enforce their own flags — see the individual processors.

---

## Instructions
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::{
authorize::authorize,
authorize::{authorize, split_trailing_permission},
error::DoubleZeroError,
pda::{get_accesspass_pda, get_globalstate_pda, get_resource_extension_pda},
processors::{
Expand Down Expand Up @@ -162,8 +162,18 @@ pub fn process_update_multicastgroup_roles(
let globalstate = GlobalState::try_from(gs_account)?;
let multicast_publisher_block_ext = next_account_info(accounts_iter)?;

let payer_account = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
// Trailing layout: [device?, feed?, payer, system, permission?]. The SDK appends the payer's
// Permission PDA last (via execute_authorized_transaction); the optional EdgeSeat device/feed
// accounts for post-activation metro re-gating precede payer/system, because the client pushes
// them into the instruction's account list ahead of the [payer, system, permission] trailer
// that assemble_instructions always appends. split_trailing_permission identifies the
// Permission by PDA match rather than by position, so it never mistakes device/feed for the
// Permission account regardless of which optional accounts are present.
let remaining: Vec<&AccountInfo> = accounts_iter.collect();
let (payer_account, system_program, leading, permission_account) =
split_trailing_permission(program_id, &remaining)?;
let device_account = leading.first().copied();
let feed_account = leading.get(1).copied();

#[cfg(test)]
msg!("process_update_multicastgroup_roles({:?})", value);
Expand Down Expand Up @@ -224,32 +234,51 @@ pub fn process_update_multicastgroup_roles(
if accesspass.user_payer != *payer_account.key
&& !globalstate.foundation_allowlist.contains(payer_account.key)
{
// A caller who is neither the pass's user_payer nor a foundation member may still act on
// another owner's pass with the right permission, and the two operations require different
// grants:
// - Removal-only cleanup (stripping roles as a prerequisite to delete/request-ban) is a
// USER_ADMIN operation, as DeleteUserCommand / RequestBanUserCommand authorize the
// final instruction with the same flag.
// - Granting roles (subscribe/publish) on behalf of another owner manages the pass's
// entitlements, so it is an ACCESS_PASS_ADMIN operation. This is the path the oracle
// uses to subscribe validator-owned users (accesspass.user_payer = validator) once it
// drops out of foundation and operates on its Permission account.
// The oracle holds both flags. authorize() reads the optional trailing Permission account
// the SDK appends and also honors the corresponding legacy authorities.
let removal_only = !value.publisher && !value.subscriber;
if removal_only {
// Consumes the trailing Permission account if the SDK appended one.
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)?;
let required_flag = if removal_only {
permission_flags::USER_ADMIN
} else {
msg!(
"AccessPass user_payer {:?} does not match payer {:?}",
accesspass.user_payer,
payer_account.key
);
return Err(DoubleZeroError::Unauthorized.into());
permission_flags::ACCESS_PASS_ADMIN
};
let authorized = authorize(
Comment thread
juan-malbeclabs marked this conversation as resolved.
program_id,
&mut permission_account.into_iter(),
payer_account.key,
&globalstate,
required_flag,
)
.is_ok();
if !authorized {
if !removal_only {
msg!(
"AccessPass user_payer {:?} does not match payer {:?}",
accesspass.user_payer,
payer_account.key
);
}
// Preserve the historical error variants: a removal-only cleanup that fails
// authorization returns NotAllowed (as the prior `authorize()?` did), while an
// attempt to add roles without authority returns Unauthorized.
return Err(if removal_only {
DoubleZeroError::NotAllowed.into()
} else {
DoubleZeroError::Unauthorized.into()
});
}
}

// Optional trailing accounts for the EdgeSeat feed metro gate: the user's device (for its
// exchange) and the Feed covering it. Read AFTER the authorize() above so they do not consume
// the optional trailing Permission account.
let device_account = accounts_iter.next();
let feed_account = accounts_iter.next();

// EdgeSeat passes derive joinable groups from their feeds' metro→group map. The seat was
// ticked at connect (CreateSubscribeUser), so post-activation role adds only re-validate
// coverage; they do not re-tick.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ pub fn process_create_user(
globalstate_account,
tenant_account,
payer_account,
// CreateUser never overrides the owner (owner_override is None below), so the
// owner-override authorization that consumes this is never reached.
permission_account: None,
};

let mut result = create_user_core(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
pda::{get_accesspass_pda, get_user_old_pda, get_user_pda},
state::{
accesspass::{AccessPass, AccessPassStatus, AccessPassType},
accounttype::AccountType,
device::{Device, DeviceStatus},
globalstate::GlobalState,
permission::permission_flags,
tenant::Tenant,
user::*,
},
Expand Down Expand Up @@ -36,6 +38,10 @@ pub struct CreateUserCoreAccounts<'a, 'b> {
pub globalstate_account: &'a AccountInfo<'b>,
pub tenant_account: Option<&'a AccountInfo<'b>>,
pub payer_account: &'a AccountInfo<'b>,
// Optional trailing Permission PDA for the payer. Present for CreateSubscribeUser, where a
// USER_ADMIN holder may set a custom owner; None for CreateUser, which never overrides the
// owner. Consumed by authorize() in the owner-override check.
pub permission_account: Option<&'a AccountInfo<'b>>,
}

/// Result returned by `create_user_core` containing mutable state for callers to finish writing.
Expand Down Expand Up @@ -107,16 +113,25 @@ pub fn create_user_core(

let mut globalstate = GlobalState::try_from(core.globalstate_account)?;

// Determine effective owner: foundation allowlist members or sentinel can set a custom owner
// Determine effective owner: the sentinel authority or a USER_ADMIN holder can set a custom
// owner. authorize() reads the optional trailing Permission account and also honors the legacy
// USER_ADMIN authority (foundation allowlist), so only the sentinel needs a separate check.
// This is what lets the oracle provision validator-owned subscribe-users (owner = validator)
// via its USER_ADMIN grant, without foundation membership.
let effective_owner = match owner_override {
Some(pk) if pk != Pubkey::default() => {
let is_foundation = globalstate
.foundation_allowlist
.contains(core.payer_account.key);
let is_sentinel = globalstate.sentinel_authority_pk == *core.payer_account.key;
if !is_foundation && !is_sentinel {
let has_user_admin = authorize(
Comment thread
juan-malbeclabs marked this conversation as resolved.
program_id,
&mut core.permission_account.into_iter(),
core.payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)
.is_ok();
if !is_sentinel && !has_user_admin {
msg!(
"Only foundation allowlist members or sentinel can set a custom owner, payer: {}",
"Only the sentinel or a USER_ADMIN holder (foundation allowlist or Permission account) can set a custom owner, payer: {}",
core.payer_account.key
);
return Err(DoubleZeroError::NotAllowed.into());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{
authorize::split_trailing_permission,
error::DoubleZeroError,
seeds::{SEED_PREFIX, SEED_USER},
serializer::{try_acc_create, try_acc_write},
Expand Down Expand Up @@ -90,12 +91,17 @@ pub fn process_create_subscribe_user(
)?
.expect("dz_prefix_count > 0 guarantees Some");

let payer_account = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;

// Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced by the pass)
// that covers the device's exchange and lists the target multicast group.
let feed_account = accounts_iter.next();
// Trailing layout after the resource-extension accounts: [feed?, payer, system, permission?].
// The optional Feed account (EdgeSeat metro gate — the feed covering the device's exchange and
// listing the target multicast group) precedes payer/system; the optional payer Permission PDA
// (appended by the SDK when it exists on-chain, authorizing a USER_ADMIN owner-override inside
// create_user_core) is last. split_trailing_permission identifies the Permission by PDA match
// rather than by position, so Feed and Permission coexist unambiguously — a single positional
// slot cannot, since either may be present or absent independently.
let remaining: Vec<&AccountInfo> = accounts_iter.collect();
let (payer_account, system_program, leading, permission_account) =
split_trailing_permission(program_id, &remaining)?;
let feed_account = leading.first().copied();

msg!("process_create_subscribe_user({:?})", value);

Expand All @@ -106,6 +112,7 @@ pub fn process_create_subscribe_user(
globalstate_account,
tenant_account: None, // No tenant support for multicast group users
payer_account,
permission_account,
};

let owner_override = if value.owner != Pubkey::default() {
Expand Down
Loading
Loading