Skip to main content

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:

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:

IdentifierIs
TICKER_IDthe Logic ID of the deployed Ticker logic
ALICE_IDthe account that invokes the logic
BOB_IDthe 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:

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);

The interaction was accepted and executed, but the storage write was refused, so the whole interaction reverted. Bob's counter is unchanged.

note

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.

ValueFor this scenarioRule
Resource IDTICKER_IDThe logic that performs the write - in a cross-logic call, the innermost one.
CallerALICE_IDThe immediate caller of the storing logic. Alice invokes Ticker directly, so the caller is Alice herself.
OriginALICE_IDThe 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:

// 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);

Three things about this operation matter:

  • target_account must 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 - sendInteraction throws rather than returning a reverted receipt.

  • target_account must also appear in the participant list under a mutate lock. Here that is Bob, the sender, whom the SDK adds automatically, so no explicit participants entry 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:

const policies = await provider.getAccessPolicy(
accountId,
ResourceType.STORAGE,
TICKER_ID,
);

console.log("Storage policies: ", policies);

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.

note

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:

ValueIsNot
Resource IDTICKER_ID - the logic that performs the storeLA_ID
CallerLA_ID - the calling logicALICE_ID
OriginALICE_ID - the sender, preserved across the callLA_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.

danger

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

SymptomLikely cause
Invoke reverts, and the policy reads back correctlyThe 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 callcaller_constraint names the sender instead of the calling logic.
Invoke reverts after changing who sends itorigin_constraint is a set that doesn't include the new sender.
sendInteraction throws instead of returning a hashtarget_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: 18The SDK cannot serialize access operations yet - see Current SDK support.
AccessUpdate revertsNo policy exists for that key - use AccessCreate.
AccessCreate revertsA policy already exists for that key - use AccessUpdate.
moi.AccessPolicies returns fewer policies than expectedEnumeration reads committed state; wait for the tesseract carrying the create to finalize.
moi.AccessPolicies returns an errorresource_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 typeresource_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.AccessPolicy and moi.AccessPolicies parameters and responses.
  • Logics - writing and deploying the logics whose access you are governing.