Access Control
Prerequisites: This guide assumes familiarity with MOI's accounts and interactions. If these concepts are new, start with the Accounts, Logics and Interactions documentation first.
A MOI account is not a single bucket of state. It carries several dimensions - assets, logics, storage, and keys, among others - and each of them can, in some circumstance, need to be touched by someone other than its owner: a logic updating the data it keeps on your account, a manager operating an asset, a recovery flow rotating keys. Access Control is the protocol layer that governs those situations. Every account publishes its own Access Policies, and the execution engine evaluates them before permitting any write to your state that you did not initiate yourself.
Today, access control is enforced on the storage dimension only - the per-user data a logic keeps on your account. The rest of this document focuses on access policies for logic storage; the other dimensions are reserved for future versions (see Current Scope).
Why Writes Need Authorization
In Ethereum, a smart contract owns its storage. Modifying a user's balance means modifying the contract's own database, so authorization is implicit: the storage belongs to the contract, and anything finer is hand-written inside the contract (require(msg.sender == owner)).
MOI moves that state to the user. The data a logic keeps about each participant - its actor state, lives on that participant's own account. A logic therefore routinely writes to storage it does not own, and implicit trust breaks down. The protocol needs an explicit answer to the question: may this logic write to this account, in this situation?
Consider a scheduling logic that keeps each user's calendar in that user's actor state. Bob books and blocks his own time all day - he signs those interactions himself, and a write to his own account needs no extra permission. But Bob's assistant Alice also arranges meetings for him, and when she books one, the logic must write to a calendar that lives on Bob's account - inside an interaction Bob never signed. This is not something just anyone should be able to do, or the whole network could fill Bob's calendar. Before the write lands, the execution engine checks the Access Policies published on Bob's account: has Bob explicitly authorized this logic, driven by Alice, to modify his state? Alice is allowed, so her write goes through; the same write driven by anyone else is denied.
Anatomy of an Access Policy
An Access Policy is a record stored on your account that answers one question: under what conditions may someone other than me touch this resource of mine? Each policy names a resource, the actions it permits on it, and two constraints - who may be the immediate caller, and who may be the origin of the interaction.
| Field | Type | Description |
|---|---|---|
Resource | ResourceType | The class of protocol object being governed. Currently storage only. |
ResourceID | Identifier | The specific resource within that class. For storage, this is the Logic ID of the logic that performs the write. |
Actions | AccessAction | The set of operations the policy permits. At least one action must be set. |
Scope | Hex | [Reserved]. Intended to narrow a policy to part of a resource, such as a set of storage key prefixes. Leave it empty - it is unenforced and not returned by read RPCs. |
Caller | CallerConstraint | Constrains the immediate invoker: the sender for a direct call, or the calling logic in a cross-logic call. |
Origin | CallerConstraint | Constrains the original sender of the interaction. |
Resource Type
The class of protocol object a policy governs. Only storage is accepted today; a policy naming any other class is rejected at validation.
| Value | Encoding | Status |
|---|---|---|
storage | 1 | Supported. Governs writes to a logic's actor state. |
asset | 2 | [Reserved]. Defined in the model, but not in practice. |
logic | 3 | [Reserved]. Defined in the model, but not in practice. |
key | 4 | [Reserved]. Defined in the model, but not in practice. |
A policy is identified by the key (resource_type, resource_id) on the account that holds it. An account carries at most one policy per key.
Access Action
The operations a policy permits. A policy with an empty action set, or one carrying an unrecognized value, is invalid.
| Action | Value | Meaning |
|---|---|---|
storage_mutate | 1 | Write to the logic's actor state in this account. |
asset_access | 2 | Reserved for the asset resource class. |
logic_access | 3 | Reserved for the logic resource class. |
Caller Constraint
The Caller and Origin slots each hold a constraint of the same shape:
| Field | Type | Description |
|---|---|---|
Kind | CallerKind | How the constraint is matched - against everyone, or against an explicit list. |
Set | Array<Identifier> | The permitted identifiers. Must be non-empty when Kind is set; ignored when Kind is any. |
Caller Kind
| Kind | Encoding | Matches |
|---|---|---|
any | 0 | Every identifier. |
set | 1 | Only the identifiers listed in Set. |
A policy grants access only when all three of Actions, Caller, and Origin are satisfied at once.
Ground Rules
The network enforces these regardless of what any policy says:
| Rule | Statement |
|---|---|
| Closed by default | A resource with no policy is reachable only by its owner. |
| Owner access is free | The owner never needs a policy for its own state. If the caller or the origin is the target account, the write is allowed. |
| Owner-only management | A policy can be created, modified, or deleted only by the account it is stored on. The sender must equal target_account. |
| One policy per key | An account holds at most one policy per (resource_type, resource_id). |
| All conditions must hold | Action, caller, and origin must all be satisfied together. Any one failing denies the write. |
| Well-formedness | resource_type must be storage, resource_id must not be null, action_type must be non-empty, and a kind: 1 constraint must carry a non-empty set. |
Managing Policies
A policy lives entirely under its owner's control, through three interaction operations. Their full payload formats are documented in Interactions, and the tree they are written into is part of the account, described in Accounts.
| Type | Name | Effect |
|---|---|---|
| 18 | AccessCreate | Grants access: writes a new policy for a (resource_type, resource_id) key. Fails if a policy already exists for that key. |
| 19 | AccessUpdate | Changes a grant: replaces an existing policy as a whole. Fails if no policy exists. |
| 20 | AccessDelete | Revokes access: removes the policy, returning the resource to owner-only. |
Together they form a policy key's lifecycle. The key is always in one of two states - absent, the resource locked to its owner, or active, the grant in force - and the operations loop it between them:
Grant. Every key starts absent;
AccessCreatepublishes a policy and activates it.Amend.
AccessUpdatereplaces the active record in full - a swap, not a patch. To change one constraint, send the whole policy again with that constraint changed.Revoke.
AccessDeletereturns the key to absent, leaving no tombstone - a laterAccessCreatecan grant the same key afresh.
Every transition is owner-driven - an access operation whose sender is not the account it targets is rejected - and takes effect from the next tesseract onward, never retroactively.
Reading Policies
Two JSON-RPC methods expose the policies an account holds. Both are documented in full in the JSON-RPC API.
| Method | Returns |
|---|---|
moi.AccessPolicy | One policy, looked up by (id, resource_type, resource_id). Returns an error if the account holds no such policy. |
moi.AccessPolicies | Every policy an account holds for one resource type. Returns an empty array if there are none. |
Use moi.AccessPolicy when you know the resource ID, and moi.AccessPolicies to enumerate an account's grants when you don't. A returned policy looks like this:
{
"resource_type": "storage",
"resource_id": "0x20000000ff572431a4f52ad972f54fee061847c682eb22ffa51c97a900000000",
"action_type": ["storage_mutate"],
"caller_constraint": {
"kind": 1,
"set": [
"0x20000000784cf54a25a23390e71563a37d7d2c0d203746876e04992b00000000"
]
},
"origin_constraint": { "kind": 0, "set": null }
}
This policy governs storage written by logic 0x20000000ff5..., permits the storage_mutate action, admits exactly one caller (logic 0x20000000784...), and admits any origin.
How a Write Is Evaluated
Whenever a logic writes to actor state, four identities are in play:
| Value | Meaning |
|---|---|
| Target | The account whose storage is being written. |
| Resource ID | The logic performing the write. |
| Caller | The immediate invoker - the sender for a direct call, or the calling logic in a cross-logic call. |
| Origin | The account that sent the interaction. |
Every write resolves through the same three checks, in order:
| Step | Check | If it holds | If it fails |
|---|---|---|---|
| 1. Owner check | Is the caller or the origin the target account itself? | Allowed - evaluation stops | Continue to step 2 |
| 2. Policy lookup | Does the target hold a policy for (storage, resource ID)? | Continue to step 3 | Denied |
| 3. Constraint match | Is the action permitted, and do the caller and the origin both satisfy their constraints? | Allowed | Denied |
A denial is not a submission error. The interaction is admitted, executed, and then reverted - the receipt reports a failed status and nothing is committed.
The origin is always the interaction's sender, and the target is always the account being written. The other two values depend on the shape of the call. Suppose participant P1 sends the interaction and the write lands on participant P2's account:
| Call path | Resource ID | Caller | Origin |
|---|---|---|---|
P1 invokes logic LA, and LA writes to P2's storage | LA | P1 | P1 |
P1 invokes logic LA, LA calls logic LB, and LB writes to P2's storage | LB | LA | P1 |
If a policy isn't working during a cross-logic call, check the resource ID. The policy must be keyed to the logic doing the writing - the innermost one - not the outer logic. Policies keyed to the outer logic are never consulted.
Worked Example
Logic LA keeps a counter in the actor state of whoever it acts on, and can either write that counter itself or delegate the write to a second logic LB.
coco LA
state actor:
counter U64
interface OtherLogic:
state actor:
counter U64
endpoint:
dynamic TickAny(participant Identifier) -> (counter U64)
// Mutates the sender's own counter - owner access, never needs a policy.
endpoint dynamic TickMy() -> (counter U64):
mutate c <- LA.Sender.counter:
c += 1
counter = c
// Mutates another participant's counter - LA performs the store, so this is
// gated by that participant's policy for (storage, LA).
endpoint dynamic TickAny(participant Identifier) -> (counter U64):
mutate c <- LA.Actor(participant).counter:
c += 1
counter = c
// Delegates to another logic, which performs the store itself. The write is
// gated by the participant's policy for (storage, LB) - not (storage, LA).
endpoint dynamic TickAnyOther(other_logic, participant Identifier) -> (counter U64):
memory other = OtherLogic(other_logic)
counter = other.TickAny(participant)
Setting up the Policy
Alice wants to tick Bob's counter via LA, which delegates the write to LB. Bob wants to permit only this exact path. He sends an AccessCreate operation targeting his own account, naming the inner logic (LB) as the resource:
{
"type": 18,
"payload": {
"target_account": "0x...bob",
"access_policy": {
"resource_type": "storage",
"resource_id": "0x...LB",
"action_type": ["storage_mutate"],
"caller_constraint": { "kind": 1, "set": ["0x...LA"] },
"origin_constraint": { "kind": 1, "set": ["0x...alice"] }
}
}
}
Evaluating the call
Alice invokes LA.TickAnyOther(LB, Bob). The write occurs inside LB, so the execution engine sees:
resource ID = LBcaller = LAorigin = Alicetarget = Bob
Bob is neither the caller nor the origin, so the owner check does not apply. The engine loads Bob's policy for (storage, LB). The action (storage_mutate), caller (LA), and origin (Alice) all match the constraints. The write succeeds.
Alternative Call Paths
Given how specific Bob's policy is, here is how the other paths resolve when Alice invokes them:
| Call by Alice | Write reaches | Policy key consulted | Result |
|---|---|---|---|
LA.TickMy() | Alice's counter | - (owner access) | Allowed |
LA.TickAny(Bob) | Bob's counter | (storage, LA) | Reverted - Bob holds no policy keyed to LA |
LB.TickAny(Bob) | Bob's counter | (storage, LB) | Reverted - the caller is Alice, but the policy admits only LA |
Failure Modes
Failures occur at two stages:
| Stage | What the client sees | Causes |
|---|---|---|
| Rejected at admission | moi.SendInteractions returns an error. No interaction is created and no fuel is charged. | Malformed policy payload (empty action/constraint sets, null ID, unsupported resource) or the sender is not the target account. |
| Reverted at execution | moi.SendInteractions returns a hash, but the receipt reports a failed status. No state is committed. | Unauthorized write attempt, AccessCreate on an existing key, or AccessUpdate/AccessDelete on a missing key. |
Current Scope
The policy model is designed to eventually cover every dimension of an account's state, but the following guardrails are in place today:
- Resource types:
asset,logic, andkeyare defined in the schema but rejected at submission and by the read RPCs. Onlystorageis enforced. - Actions:
storage_mutateis the only action evaluated by the runtime.asset_accessandlogic_accessexist in the encoding but remain inactive. - Writes only: policies govern state mutations. There is no read-side enforcement, so state reads cannot be restricted.
- Scope field: present in the record but unenforced and ignored by the read RPCs. Leave it empty, which grants access to the entire resource.
Extending the layer to assets and logics requires a distinct ownership model: with storage, the account hosting the state is its owner, while assets and logics are owned by their creators or managers - accounts separate from where the state physically resides.
Where Next
- Access Policies Tutorial: a hands-on tutorial walking one policy from denial through grant, widening, and revocation.
- JSON-RPC API: reading policies with
moi.AccessPolicyandmoi.AccessPolicies. - Multi-Sig: a complementary layer that gates who signed, where access control gates what may be touched.