Skip to content

Access-Controlled Storage

Most app state in Calimero is public: any context member can write it. When you need narrower rules — only the owner, only this group of writers, write-once, per-user slots — you wrap the value in an access-controlled collection from calimero_storage::collections. This page covers which wrapper to use, and the part that trips people up: how that access rule is actually enforced once data starts syncing between nodes.

Authorization in Calimero happens on two independent planes. Knowing which one is the real security boundary is the single most important thing on this page.

PlaneWhereWhat it doesTrust
API planeonly_owner(), guard(), Authorizer::authorize — runs locally before a writeFail-fast UX sugar: returns an error before you commit a doomed writeBypassable. A modified node can skip it entirely
Merge planeInterface::apply_action — runs on every node as it ingests a remote actionRe-derives the authoritative writer set and ed25519_verifys the signature against itThe real boundary. Cannot be bypassed

The API plane is a courtesy: it lets an honest client discover “you’re not a writer” cheaply, without round-tripping a write that every other node will throw away. It is not security. A caller who patches their binary can call right past only_owner()?.

The merge plane is what makes the rule hold. When a remote action arrives, apply_action does not trust the writer set the action claims. It re-derives the authoritative writer set from stored / causal state and verifies the action’s Ed25519 signature against that set. A forged write signed by a non-writer fails verification and is dropped — on every honest node, independently, with no coordination.

// crates/storage/src/interface.rs — apply_action, Shared arm (paraphrased)
let authoritative_writers = match ctx.effective_writers.as_ref() {
Some(effective) => effective.clone(), // causal writer set from the DAG
None => stored_writers.unwrap_or(claimed), // else the entity's stored set
};
// Find a writer whose key actually signed this action — NOT the claimed signer.
let signer = authoritative_writers.keys().copied().find(|w| {
crate::env::ed25519_verify(&sig_data.signature, w.digest(), &payload)
});
let Some(signer) = signer else {
return Err(StorageError::InvalidSignature); // forged / non-writer → dropped
};
// Then a per-operation capability gate (WRITE vs DELETE vs ADMIN):
Self::enforce_op_mask(&signer, Self::required_op_mask(&action), &authoritative_writers)?;

Every stored entity carries a StorageType stamp (crates/storage/src/entities.rs). The stamp is what the merge plane reads to decide who may write the entity. The wrappers below are ergonomic front-ends that set the right stamp for you.

StorageTypeWho may writeHow it’s enforced at merge
Public (default)Any context memberMembership only — no signature. A member’s WRITE+DELETE is implied by DEFAULT_MEMBER_MASK
User { owner }Only ownered25519_verify vs the owner key + a monotonic per-entity nonce (replay protection)
FrozenThe first writer, onceWrite-once: a delete is unconditionally rejected, and the content cannot be modified
Shared { writers: BTreeMap<PublicKey, OpMask> }Any key in writers, maskeded25519_verify vs the authoritative writers set; the signer must also hold the op’s OpMask bit
SharedMember { anchor }Writer set resolved from anchorMember carries no writer set of its own — writers are read from the anchor’s rotation log, so rotating the anchor revokes the whole subtree in O(1)

Shared writers are not all-or-nothing. Each writer carries an OpMask(u8) — a bitset of three independent capabilities:

MaskBitsGates
OpMask::WRITE0b001Put (add / update a value or entry)
OpMask::DELETE0b010Delete (remove a value or entry)
OpMask::ADMIN0b100Object ownershipSetWriters / writer-set rotation
OpMask::FULL0b111All three; the default a writer gets when granted without a restriction

The merge check tests individual bits via OpMask::contains. A Put requires WRITE; a Delete requires DELETE; rotating the writer set requires ADMIN. Because ADMIN is separate, a plain writer with WRITE+DELETE still cannot lock others out by rotating an object’s writer set — that needs an explicit ownership (ADMIN) grant.

Decision matrix — data need → structure

Section titled “Decision matrix — data need → structure”
Your data needUseStamp it produces
Shared by the whole context, any member writesa plain #[app::state] field (UnorderedMap, LwwRegister, …)Public
One group of writers, set can evolve over timeSharedStorage<T>Shared / SharedMember
A single owner, transferableOwnable<T> (= PermissionedStorage<T, OwnerAcl>)Shared with a one-key writer set
Per-key WRITE / DELETE / ADMIN capabilitiesPermissionedStorage<T, ProtocolAuthorizer>Shared with per-writer OpMasks
Named roles ("editor", "moderator", …) over a DEFAULT_ADMIN_ROLEAccessControlrole grants in a guarded map
One private slot per user, keyed by identityUserStorage<T>User { owner } per slot
Write-once, content-addressed, immutableFrozenStorage<T>Frozen
Shared keyspace, each entry owned by whoever created itAuthoredMap<K, V>per-entry owner stamp

SharedStorage<T> and Ownable<T> are both thin aliases over PermissionedStorage<T, A> — they differ only in the Authorizer policy A (WriterSetAcl for group-writable, OwnerAcl for single-owner). All three share the same merge-time enforcement; the Authorizer only changes the API-plane pre-check. Ownership transfer (transfer_ownership) is a signed ADMIN rotation of the one-key writer set — which is precisely the authenticated capability that UserStorage does not offer.

From apps/kv-store-with-shared-storage. A SharedStorage<T> field guards everything inside it — when the wrapper holds a collection, every entry inherits the SharedMember stamp, so a non-writer’s delta to any entry is rejected at merge, not just the wrapper.

#[app::state(emits = for<'a> Event<'a>)]
pub struct KvStore {
// Group-writable register; initial writer is whoever installed the app.
shared_value: SharedStorage<LwwRegister<String>>,
// Group-writable *map*: every entry inherits the same writer set.
shared_map: SharedStorage<UnorderedMap<String, LwwRegister<String>>>,
}
#[app::logic]
impl KvStore {
#[app::init]
pub fn init() -> KvStore {
let initializer: PublicKey = calimero_sdk::env::executor_id().into();
let mut writers = BTreeSet::new();
writers.insert(initializer);
KvStore {
// `false` = writer set is rotatable (pass `true` to freeze it).
shared_value: SharedStorage::new(writers.clone(), false),
shared_map: SharedStorage::new(writers, false),
}
}
// Caller must be a current writer; a non-writer's write is dropped at merge.
pub fn set_shared(&mut self, value: String) -> app::Result<()> {
self.shared_value.insert(LwwRegister::new(value))?;
Ok(())
}
// Rotate the writer set. Caller must be a current writer; rejected if frozen.
pub fn rotate_writers(&mut self, new_writers: Vec<PublicKey>) -> app::Result<()> {
let set: BTreeSet<PublicKey> = new_writers.into_iter().collect();
self.shared_value.rotate_writers(set)?;
Ok(())
}
}

From apps/kv-store-with-user-and-frozen-storage. UserStorage<T> gives each identity its own slot keyed by executor_id(); FrozenStorage<T> is content-addressed and immutable.

#[app::state(emits = for<'a> Event<'a>)]
pub struct KvStore {
items: UnorderedMap<String, LwwRegister<String>>, // Public: any member writes
user_items_simple: UserStorage<LwwRegister<String>>, // one slot per identity
frozen_items: FrozenStorage<String>, // write-once, hashed key
}
#[app::logic]
impl KvStore {
// Writes to the CURRENT user's slot (env::executor_id()). Another user's
// slot is `User { owner }`-stamped and only that owner's signature verifies.
pub fn set_user_simple(&mut self, value: String) -> app::Result<()> {
self.user_items_simple.insert(value.into())?;
Ok(())
}
// Returns the hex SHA-256 of the value (its key). Re-adding the same content
// deduplicates to the same hash; the value can never be modified or deleted.
pub fn add_frozen(&mut self, value: String) -> app::Result<String> {
let hash = self.frozen_items.insert(value)?;
Ok(hex::encode(hash))
}
}

Both attach an identity to data, but they answer different questions.

AuthoredMap<K, V>UserStorage<T>
KeyspaceOne shared keyspace; keys are app-chosenOne slot per user, keyed by executor_id()
Who insertsAnyone may insert a new key (stamped with their identity)Each user writes only their own slot
Who updates / removesOnly the entry’s recorded authorOnly the slot owner
Use it whenA collaborative collection where each row has an owner (posts, listings, claims)Per-user private data (a profile, a personal sub-store)

From apps/components-demo. The wrappers above bind capabilities to keys (a writer set, an owner, per-key OpMasks). AccessControl adds a layer of indirection on top: capabilities bind to named roles ("editor", "moderator", …) and accounts are granted membership in those roles — the same shape as OpenZeppelin’s role-based access control. You grant and revoke "editor" once; the gate on a method asks “does this account hold "editor"?” rather than naming keys directly.

There is a single admin tier — the OpenZeppelin DEFAULT_ADMIN_ROLE. Any admin may grant or revoke any role; roles do not have their own per-role admin roles. Under the hood AccessControl wraps one SharedStorage<UnorderedMap<…>> (itself a PermissionedStorage<T, WriterSetAcl>): the admins are exactly that storage’s writer set, and each grant is an entry (role\0member_hex → LwwRegister<bool>, true = held, false = revoked) in its writer-set-guarded map. So a non-admin’s hand-crafted grant delta is rejected by the merge plane for the same reason a non-writer’s SharedStorage write is — the registry entries inherit the admin writer set. The fail-fast guards (only_admin(), only_role()) mirror what merge enforces.

CallSignatureWho may callEffect
AccessControl::new(admin)(admin: PublicKey) -> SelfCreate with admin as the sole initial admin
AccessControl::new_admin_caller()() -> SelfSame, with the current executor as admin (the init case)
grant(role, who)(&mut self, role: &str, who: PublicKey)an adminGive who the role
revoke(role, who)(&mut self, role: &str, who: &PublicKey)an adminTake the role from who
has_role(role, who)(&self, role: &str, who: &PublicKey) -> Result<bool>anyoneWhether who currently holds role
only_role(role)(&self, role: &str) -> Result<()>Fail-fast: the current executor must hold role
only_admin()(&self) -> Result<()>Fail-fast: the current executor must be an admin
is_admin(who) / admins()(&self, who: &PublicKey) -> bool / (&self) -> BTreeSet<PublicKey>anyoneQuery the admin tier
grant_admin(who) / revoke_admin(who)(&mut self, who: …) -> Result<()>an adminAdd / remove an admin — an authenticated writer-set rotation (cannot remove the last admin)

To gate a mutation on a role, call only_role("editor")? (fail-fast UX) before the write — but the real boundary is that the mutated data lives in its own writer-set-guarded wrapper, so the role check has teeth only when the role is also projected onto that data (next paragraph). Admin changes go through grant_admin / revoke_admin, which rotate the backing writer set the same signed way Ownable::transfer_ownership does.

use calimero_sdk::{app, env, PublicKey};
use calimero_storage::collections::{AccessControl, LwwRegister, PermissionedStorage};
#[app::state]
pub struct Docs {
// The installer is the sole initial admin; admins grant/revoke roles.
roles: AccessControl,
// The role-gated payload lives in its own writer-set-guarded wrapper.
body: PermissionedStorage<LwwRegister<String>>,
}
#[app::logic]
impl Docs {
#[app::init]
pub fn init() -> Docs {
let me: PublicKey = env::executor_id().into();
Docs {
roles: AccessControl::new(me),
body: PermissionedStorage::new([me].into(), false),
}
}
// Admin-only: grant the "editor" role. Fail-fast here, enforced at merge
// (a forged grant delta is rejected — the registry inherits the admin set).
pub fn grant_editor(&mut self, who: PublicKey) -> app::Result<()> {
self.roles.grant("editor", who)?;
Ok(())
}
// Role-gated mutation: caller must hold "editor".
pub fn edit(&mut self, value: String) -> app::Result<()> {
self.roles.only_role("editor")?; // fail-fast UX
self.body.insert(LwwRegister::new(value))?; // merge-enforced write
Ok(())
}
}

For the role gate to be load-bearing at merge (not just fail-fast), push the role memberships onto the guarded data’s capability map with AccessControl::project_onto(&[("editor", OpMask::WRITE)], &mut self.body) after any role change. That hands every "editor" the WRITE bit on body, signed and enforced by the merge plane — so a non-editor’s forged write is dropped even on a patched node. Re-run project_onto after each grant/revoke; the registry write and the projection are separate signed actions that converge independently.

AccessControl is the right choice when the same capability is shared by a set of accounts that changes over time, and you want to manage that set by name. Tie it back to the decision matrix:

You wantReach for
One owner, transferableOwnable<T> (a one-key writer set)
A flat group of writers, no namingSharedStorage<T>
Per-key WRITE / DELETE / ADMIN bitsPermissionedStorage<T, ProtocolAuthorizer>
Named roles you grant/revoke, each conferring a capabilityAccessControl (+ project_onto)

The difference from PermissionedStorage is the indirection: with bare per-key masks you re-grant every account when a policy changes; with AccessControl you change the "editor" membership once and re-project. That indirection is worth it when several objects share one role policy, or when membership churns; for a single object with a fixed writer set, the plain wrappers are simpler.

rotate_writers re-stamps only the anchor entity, never the members beneath it. Members carry SharedMember { anchor }, so a single rotation retroactively changes the writer set for the entire subtree in O(1) — no per-entity churn, no risk of split-brain from a partial re-stamp. Rotating the anchor is how you revoke a writer’s access to everything under it at once.

The revocation bites at the merge plane: a removed writer can still author a Put, but every honest node re-derives the post-rotation writer set in apply_action and the removed key no longer verifies — so the write is dropped, not retroactively trusted:

When two current writers rotate concurrently (DAG siblings, neither in the other’s causal history), the merge resolves deterministically (docs/adr/0001-shared-storage-concurrent-rotation.md):

  1. Causal-first — if one rotation happens-before the other, the later one wins unconditionally (its author rotated from full knowledge of the earlier).
  2. HLC tiebreak — truly concurrent rotations resolve by larger HybridTimestamp (embeds wall-time and a node ID, giving a total order).
  3. Signer-pubkey tiebreak — on an HLC tie (astronomically rare), the smaller signing-key bytes win.

The same ordering resolves ordinary value writes. Crucially, a write is verified against the writer set as of its own causal parents, not the post-merge set — so a write authored while you were a valid writer is never retroactively rejected by a later rotation that removed you.

Every wrapper above narrows who in the context may write a value, but the value still syncs — it lives in the Merkle root and reaches every node. The tightest boundary of all is the one where data never leaves the node at all: #[app::private] declares a struct that stores into a separate PrivateState column, is never synchronized, sits outside the Merkle root, and has exactly one writer — this node. There is no merge plane here because there is no merge: each node keeps its own copy, and per-node divergence is intentional. See Synced vs node-local state for when to choose it (secrets, per-node caches, derived indexes).

Annotating a struct with #[app::private] generates typed accessors on it:

  • private_load()Result<Option<EntryRef<Self>>>None if nothing is stored yet.
  • private_load_or_default()Result<EntryRef<Self>> — loads, or seeds Self::default().
  • private_load_or_init_with(f)Result<EntryRef<Self>> — loads, or seeds from a closure.

An EntryRef derefs to &Self for reads. Call .as_mut() to get an EntryMut, which derefs to &mut Self and writes back on drop — so a read → modify → save cycle is just “load, mutate through as_mut(), let it fall out of scope.” This is the apps/private_data pattern:

// Node-local private state — never synced, single-writer, outside the Merkle root.
#[derive(BorshSerialize, BorshDeserialize, Default)]
#[borsh(crate = "calimero_sdk::borsh")]
#[app::private]
pub struct Secrets {
secrets: UnorderedMap<String, String>, // structural collection: auto-private
secrets_added: u64, // plain value: no CRDT in private state
}
pub fn add_secret(&mut self, game_id: String, secret: String) -> app::Result<()> {
// Load (or seed default), then take a save-on-drop mutable view.
let mut secrets = Secrets::private_load_or_default()?;
let mut secrets_mut = secrets.as_mut();
secrets_mut.secrets.insert(game_id.clone(), secret.clone())?;
secrets_mut.secrets_added = secrets_mut.secrets_added.saturating_add(1);
// `secrets_mut` writes the private blob back when it drops at end of scope.
// Only the hash crosses the wire, into ordinary synced state.
let hash_hex = hex::encode(Sha256::digest(secret.as_bytes()));
self.games.insert(game_id, hash_hex.into())?;
Ok(())
}

For a one-liner that does not need an intermediate binding, the underlying private_storage::EntryHandle<T> (returned by the generated private_handle()) offers get_or_init_with(f) and a modify(|state| { ... }) closure that loads, mutates, and saves in a single call.

  • Collections — the CRDT types these wrappers guard, and their merge semantics
  • Governance — how context membership and roles drive the Public write boundary
  • Capability inheritance — how writer / admin capabilities flow through subgroups
  • Divergence recovery — what happens when replicas disagree, and how they reconverge