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.
The two planes (read this first)
Section titled “The two planes (read this first)”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)?;Storage types
Section titled “Storage types”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) |
Operation masks
Section titled “Operation masks”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 ownership — SetWriters / 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.
Group-writable storage in practice
Section titled “Group-writable storage in practice”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(()) }}User-owned and write-once storage
Section titled “User-owned and write-once storage”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)) }}AuthoredMap vs UserStorage
Section titled “AuthoredMap vs UserStorage”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) |
AccessControl: named roles
Section titled “AccessControl: named roles”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.
The API
Section titled “The API”| 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.
When to reach for it
Section titled “When to reach for it”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.
The #1 gotcha
Section titled “The #1 gotcha”Rotation and concurrency
Section titled “Rotation and concurrency”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):
- Causal-first — if one rotation happens-before the other, the later one wins unconditionally (its author rotated from full knowledge of the earlier).
- HLC tiebreak — truly concurrent rotations resolve by larger
HybridTimestamp(embeds wall-time and a node ID, giving a total order). - 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.
Node-local private state
Section titled “Node-local private state”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>>>—Noneif nothing is stored yet.private_load_or_default()→Result<EntryRef<Self>>— loads, or seedsSelf::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.
See also
Section titled “See also”- Collections — the CRDT types these wrappers guard, and their merge semantics
- Governance — how context membership and roles drive the
Publicwrite boundary - Capability inheritance — how writer / admin capabilities flow through subgroups
- Divergence recovery — what happens when replicas disagree, and how they reconverge