Skip to main content

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.

FieldTypeDescription
ResourceResourceTypeThe class of protocol object being governed. Currently storage only.
ResourceIDIdentifierThe specific resource within that class. For storage, this is the Logic ID of the logic that performs the write.
ActionsAccessActionThe set of operations the policy permits. At least one action must be set.
ScopeHex[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.
CallerCallerConstraintConstrains the immediate invoker: the sender for a direct call, or the calling logic in a cross-logic call.
OriginCallerConstraintConstrains 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.

ValueEncodingStatus
storage1Supported. Governs writes to a logic's actor state.
asset2[Reserved]. Defined in the model, but not in practice.
logic3[Reserved]. Defined in the model, but not in practice.
key4[Reserved]. Defined in the model, but not in practice.
info

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.

ActionValueMeaning
storage_mutate1Write to the logic's actor state in this account.
asset_access2Reserved for the asset resource class.
logic_access3Reserved for the logic resource class.

Caller Constraint

The Caller and Origin slots each hold a constraint of the same shape:

FieldTypeDescription
KindCallerKindHow the constraint is matched - against everyone, or against an explicit list.
SetArray<Identifier>The permitted identifiers. Must be non-empty when Kind is set; ignored when Kind is any.

Caller Kind

KindEncodingMatches
any0Every identifier.
set1Only the identifiers listed in Set.
info

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:

RuleStatement
Closed by defaultA resource with no policy is reachable only by its owner.
Owner access is freeThe 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 managementA policy can be created, modified, or deleted only by the account it is stored on. The sender must equal target_account.
One policy per keyAn account holds at most one policy per (resource_type, resource_id).
All conditions must holdAction, caller, and origin must all be satisfied together. Any one failing denies the write.
Well-formednessresource_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.

TypeNameEffect
18AccessCreateGrants access: writes a new policy for a (resource_type, resource_id) key. Fails if a policy already exists for that key.
19AccessUpdateChanges a grant: replaces an existing policy as a whole. Fails if no policy exists.
20AccessDeleteRevokes 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:

  1. Grant. Every key starts absent; AccessCreate publishes a policy and activates it.

  2. Amend. AccessUpdate replaces the active record in full - a swap, not a patch. To change one constraint, send the whole policy again with that constraint changed.

  3. Revoke. AccessDelete returns the key to absent, leaving no tombstone - a later AccessCreate can 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.

MethodReturns
moi.AccessPolicyOne policy, looked up by (id, resource_type, resource_id). Returns an error if the account holds no such policy.
moi.AccessPoliciesEvery 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:

ValueMeaning
TargetThe account whose storage is being written.
Resource IDThe logic performing the write.
CallerThe immediate invoker - the sender for a direct call, or the calling logic in a cross-logic call.
OriginThe account that sent the interaction.

Every write resolves through the same three checks, in order:

StepCheckIf it holdsIf it fails
1. Owner checkIs the caller or the origin the target account itself?Allowed - evaluation stopsContinue to step 2
2. Policy lookupDoes the target hold a policy for (storage, resource ID)?Continue to step 3Denied
3. Constraint matchIs the action permitted, and do the caller and the origin both satisfy their constraints?AllowedDenied
note

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 pathResource IDCallerOrigin
P1 invokes logic LA, and LA writes to P2's storageLAP1P1
P1 invokes logic LA, LA calls logic LB, and LB writes to P2's storageLBLAP1
danger

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 = LB
  • caller = LA
  • origin = Alice
  • target = 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 AliceWrite reachesPolicy key consultedResult
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:

StageWhat the client seesCauses
Rejected at admissionmoi.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 executionmoi.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, and key are defined in the schema but rejected at submission and by the read RPCs. Only storage is enforced.
  • Actions: storage_mutate is the only action evaluated by the runtime. asset_access and logic_access exist 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.AccessPolicy and moi.AccessPolicies.
  • Multi-Sig: a complementary layer that gates who signed, where access control gates what may be touched.