Managing Access Policies in MOI
The data a logic keeps about each participant - its actor state - lives on that participant's own account, not on the logic's. When a logic writes to an account that neither sent the interaction nor is the logic itself, that is a foreign access, and the protocol denies it unless the owning account has published a policy permitting it.
The policy model is designed to cover every dimension of an account's state - assets, logics, storage, and keys - but the protocol currently enforces it on the storage dimension only, so logic storage is what this tutorial works with.
This tutorial walks one policy through its whole lifecycle: observing the denial, working out what to grant, granting it, reading it back, widening it, and revoking it. It uses three interactions - AccessCreate, AccessUpdate, and AccessDelete - and two read RPCs, moi.AccessPolicy and moi.AccessPolicies.
Prerequisites
We recommend reading:
- Access Control - the authorization model and decision function
- Submitting an Interaction - the signing and submission flow
- Interactions - the exact payload formats
The walkthrough assumes your code already has a provider and an initialized wallet signer, as set up in Setting up JS-MOI-SDK.
The Scenario
Ticker is a logic that keeps a counter in the actor state of whichever participant it acts on - meaning the counter it maintains for Bob physically lives on Bob's account.
coco Ticker
state actor:
counter U64
// Bumps the sender's own counter.
endpoint dynamic TickMy() -> (counter U64):
mutate c <- Ticker.Sender.counter:
c += 1
counter = c
// Bumps another participant's counter.
endpoint dynamic TickAny(participant Identifier) -> (counter U64):
mutate c <- Ticker.Actor(participant).counter:
c += 1
counter = c
Three identifiers appear throughout:
| Identifier | Is |
|---|---|
TICKER_ID | the Logic ID of the deployed Ticker logic |
ALICE_ID | the account that invokes the logic |
BOB_ID | the account whose storage is written - Bob is the one who must grant access |
Alice wants to call TickAny(Bob), which bumps the counter living on Bob's account. The write lands on Bob's account, yet Bob is not the sender of the interaction and his account is not the logic's own - a foreign access. The protocol denies it until Bob says otherwise.
Step 1: Observe the Denial
Alice invokes TickAny(Bob) before any policy exists:
- Code
- Output
const logic = await getLogicDriver(TICKER_ID, ALICE_WALLET);
const ix = await logic.routines.TickAny(BOB_ID).send();
const receipt = await ix.wait();
console.log("Status:", receipt.status);
// Console Output
Status: 1 // non-zero - the interaction reverted
The interaction was accepted and executed, but the storage write was refused, so the whole interaction reverted. Bob's counter is unchanged.
A denial never surfaces as a submission error - sendInteraction returns a hash either way, and only the receipt status reveals the failure. Fuel is still consumed for the work performed before the denial.
Step 2: Identify What Bob Must Grant
Every access decision is made against three values. Getting them right is the whole exercise.
| Value | For this scenario | Rule |
|---|---|---|
| Resource ID | TICKER_ID | The logic that performs the write - in a cross-logic call, the innermost one. |
| Caller | ALICE_ID | The immediate caller of the storing logic. Alice invokes Ticker directly, so the caller is Alice herself. |
| Origin | ALICE_ID | The account that sent the interaction. |
On a direct invoke, caller and origin are the same account. They diverge as soon as one logic calls another - see Cross-logic calls.
Step 3: Bob Grants Access
Bob sends an AccessCreate (type 18) writing a policy onto his own account. He pins the origin to Alice and leaves the caller open: that covers any route driven by Alice, including the direct invoke from Step 2, where the caller is Alice herself:
- Code
- Output
// Bob grants: the Ticker logic may mutate my storage, when driven by Alice.
const ix = await new Access(BOB_WALLET)
.storage(TICKER_ID)
.allow(AccessAction.STORAGE_MUTATE)
.caller(access.anyCaller())
.origin(access.callers(ALICE_ID))
.create()
.send();
const receipt = await ix.wait();
console.log("Status:", receipt.status);
// Console Output
Status: 0 // policy written
Three things about this operation matter:
target_accountmust be the sender. Only the account that owns the state may govern access to it. An interaction attempting to write a policy onto someone else's account is rejected at submission -sendInteractionthrows rather than returning a reverted receipt.target_accountmust also appear in the participant list under a mutate lock. Here that is Bob, the sender, whom the SDK adds automatically, so no explicitparticipantsentry is needed.A policy occupies storage bytes on Bob's account. Bob must hold enough storage allocation to cover it.
Access operations return no result data; the receipt status is the whole answer.
Step 4: Verify the Policy
Read the policy back with moi.AccessPolicy, keyed by owner plus resource:
- Code
- Output
const policies = await provider.getAccessPolicy(
accountId,
ResourceType.STORAGE,
TICKER_ID,
);
console.log("Storage policies: ", policies);
{
"resource_type": "storage",
"resource_id": "0x20000000ff572431a4f52ad972f54fee061847c682eb22ffa51c97a900000000",
"action_type": ["storage_mutate"],
"caller_constraint": { "kind": 0, "set": null },
"origin_constraint": {
"kind": 1,
"set": [
"0x000000001ec28dabfc3e4ac4dfc2084b45785b5e9cf1287b63a4f46900000000"
]
}
}
To list everything Bob has granted - useful when the resource IDs aren't known in advance - use moi.AccessPolicies with the resource_id omitted. resource_type is still required: policies are enumerated one resource type at a time, and there is no "all types" option.
Alice now repeats the invoke from Step 1. This time the receipt status is 0 and Bob's counter increments.
Step 5: Widen or Narrow the Policy
A policy is replaced wholesale, never appended to. An account holds exactly one policy per (resource type, resource ID) key; multiplicity lives inside the policy, in the action set and the caller/origin sets.
Bob decides that Ticker may write to his storage no matter who the sender is. He sends an AccessUpdate (type 19) on the same key, with the origin constraint widened:
const ix = await new Access(BOB_WALLET)
.storage(TICKER_ID)
.allow(AccessAction.STORAGE_MUTATE)
.caller(access.anyCaller())
.origin(access.anyCaller())
.update()
.send();
Narrowing is the same operation in reverse: another AccessUpdate on the same key, carrying kind: CALLER_SET and the identifiers Bob still wants to allow. Cost tracks the record's serialized size rather than how permissive it is - listing more identifiers charges additional storage bytes, while listing fewer, or dropping the set for CALLER_ANY as above, credits bytes back.
AccessUpdate requires the key to already exist and AccessCreate requires that it doesn't. If you're unsure which applies, read the key with moi.AccessPolicy first - it errors when the key is absent.
Step 6: Revoke the Policy
AccessDelete (type 20) removes the grant. A delete names only the key, so it carries no constraints:
const ix = await new Access(BOB_WALLET).storage(TICKER_ID).delete().send();
The resource returns to being reachable only by Bob, moi.AccessPolicy reports the key as absent, and every byte the policy occupied is credited back. Deletion leaves no trace - Bob can AccessCreate the same key again later, and it behaves exactly like a first grant.
Cross-logic calls
This is where a policy most often looks correct and fails to take effect. Suppose Alice invokes logic LA, LA calls Ticker, and the write to Bob's storage happens inside Ticker. Relative to the direct case only the caller changes - but each of the three values invites a mistake:
| Value | Is | Not |
|---|---|---|
| Resource ID | TICKER_ID - the logic that performs the store | LA_ID |
| Caller | LA_ID - the calling logic | ALICE_ID |
| Origin | ALICE_ID - the sender, preserved across the call | LA_ID |
So Bob's policy becomes:
{
target_account: BOB_ID,
access_policy: {
resource_type: "storage",
resource_id: TICKER_ID, // inner logic
action_type: ["storage_mutate"],
caller_constraint: { kind: CALLER_SET, set: [LA_ID] }, // outer logic
origin_constraint: { kind: CALLER_SET, set: [ALICE_ID] }, // the sender
},
}
Because caller and origin are checked independently, this grant is precise: it permits exactly the path through LA into Ticker, driven by Alice. A direct invoke of Ticker.TickAny(Bob) by Alice is still denied, because that store's caller is Alice, not LA.
The policy key is the inner logic while the caller is the outer logic. A policy keyed on the outer logic looks correct, but the runtime never consults it - only the policy keyed to the logic performing the write is checked.
Self-access needs no policy
None of this applies when an account touches its own state. If either caller or origin equals the account being written, the write is allowed unconditionally - policies are only ever consulted for foreign access.
So when Alice invokes Ticker.TickMy(), which writes her own counter, it always works - directly, and also when routed through LA, because Alice remains the origin across the cross-logic call.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Invoke reverts, and the policy reads back correctly | The policy is keyed on the wrong logic. In a cross-logic call, key it on the innermost logic that performs the store. |
| Invoke reverts on a cross-logic call | caller_constraint names the sender instead of the calling logic. |
| Invoke reverts after changing who sends it | origin_constraint is a set that doesn't include the new sender. |
sendInteraction throws instead of returning a hash | target_account isn't the sender, or the policy is invalid - an empty action_type, a kind: 1 constraint with an empty set, a null resource_id, or a resource_type other than "storage". |
sendInteraction throws Unsupported interaction type: 18 | The SDK cannot serialize access operations yet - see Current SDK support. |
AccessUpdate reverts | No policy exists for that key - use AccessCreate. |
AccessCreate reverts | A policy already exists for that key - use AccessUpdate. |
moi.AccessPolicies returns fewer policies than expected | Enumeration reads committed state; wait for the tesseract carrying the create to finalize. |
moi.AccessPolicies returns an error | resource_id was supplied (omit it, or use moi.AccessPolicy for a single policy), or resource_type was omitted - it is required, not defaulted. |
moi.AccessPolicy returns invalid resource type | resource_type was omitted or sent as a number. It is required, and must be the name "storage". |
Where Next
- Access Control - the authorization model, the decision function, and the caller/origin contract in full.
- Interactions - complete payload formats for the three access operations.
- JSON-RPC API -
moi.AccessPolicyandmoi.AccessPoliciesparameters and responses. - Logics - writing and deploying the logics whose access you are governing.