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.

Plane Where What it does Trust
API plane only_owner(), guard(), Authorizer::authorize — runs locally before a write Fail-fast UX sugar: returns an error before you commit a doomed write Bypassable. A modified node can skip it entirely
Merge plane Interface::apply_action — runs on every node as it ingests a remote action Re-derives the authoritative writer set and ed25519_verifys the signature against it The 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.

StorageType Who may write How it’s enforced at merge
Public (default) Any context member Membership only — no signature. A member’s WRITE+DELETE is implied by DEFAULT_MEMBER_MASK
User { owner } Only owner ed25519_verify vs the owner key + a monotonic per-entity nonce (replay protection)
Frozen The first writer, once Write-once: a delete is unconditionally rejected, and the content cannot be modified
Shared { writers: BTreeMap<PublicKey, OpMask> } Any key in writers, masked ed25519_verify vs the authoritative writers set; the signer must also hold the op’s OpMask bit
SharedMember { anchor } Writer set resolved from anchor Member 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:

Mask Bits Gates
OpMask::WRITE 0b001 Put (add / update a value or entry)
OpMask::DELETE 0b010 Delete (remove a value or entry)
OpMask::ADMIN 0b100 Object ownershipSetWriters / writer-set rotation
OpMask::FULL 0b111 All 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 need Use Stamp it produces
Shared by the whole context, any member writes a plain #[app::state] field (UnorderedMap, LwwRegister, …) Public
One group of writers, set can evolve over time SharedStorage<T> Shared / SharedMember
A single owner, transferable Ownable<T> (= PermissionedStorage<T, OwnerAcl>) Shared with a one-key writer set
Per-key WRITE / DELETE / ADMIN capabilities PermissionedStorage<T, ProtocolAuthorizer> Shared with per-writer OpMasks
Named roles ("editor", "moderator", …) over a DEFAULT_ADMIN_ROLE AccessControl role grants in a guarded map
One private slot per user, keyed by identity UserStorage<T> User { owner } per slot
Write-once, content-addressed, immutable FrozenStorage<T> Frozen
Shared keyspace, each entry owned by whoever created it AuthoredMap<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>
Keyspace One shared keyspace; keys are app-chosen One slot per user, keyed by executor_id()
Who inserts Anyone may insert a new key (stamped with their identity) Each user writes only their own slot
Who updates / removes Only the entry’s recorded author Only the slot owner
Use it when A 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.

Call Signature Who may call Effect
AccessControl::new(admin) (admin: PublicKey) -> Self Create with admin as the sole initial admin
AccessControl::new_admin_caller() () -> Self Same, with the current executor as admin (the init case)
grant(role, who) (&mut self, role: &str, who: PublicKey) an admin Give who the role
revoke(role, who) (&mut self, role: &str, who: &PublicKey) an admin Take the role from who
has_role(role, who) (&self, role: &str, who: &PublicKey) -> Result<bool> anyone Whether 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> anyone Query the admin tier
grant_admin(who) / revoke_admin(who) (&mut self, who: …) -> Result<()> an admin Add / 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 want Reach for
One owner, transferable Ownable<T> (a one-key writer set)
A flat group of writers, no naming SharedStorage<T>
Per-key WRITE / DELETE / ADMIN bits PermissionedStorage<T, ProtocolAuthorizer>
Named roles you grant/revoke, each conferring a capability AccessControl (+ 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:

  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