Skip to main content

Access Control

PISA v0.8.0

Storage access control is enforced by the PISA v0.8.0 runtime. Logics compiled for earlier targets are executed by the legacy runtime and are not affected.

On PISA v0.8.0 a logic may not write into an arbitrary actor's state. The runtime checks every actor-state write and defaults to deny — the actor whose state is written has to grant permission first.

endpoint dynamic Transfer(to Identifier, amount U256):
mutate sender_bal <- Token.Sender.balance: // always allowed — the sender's own state
sender_bal -= amount
mutate to_bal <- Token.Actor(to).balance: // denied unless `to` granted access
to_bal += amount
error: actor is not allowed to write into other actor's storage

In Cocolab, the grant command stands in for the actor's wallet: it is how you simulate an actor authorizing a logic to write their state.

What Is Gated

OperationGated
mutate Module.Sender.*No — the sender is the origin of the interaction
mutate Module.Actor(id).* where id is the senderNo — same actor
mutate Module.Actor(id).* where id is anyone elseYes — needs a grant
mutate Module.Logic.*No — logic state belongs to the logic
Every observe (own, other actor, logic, cross-logic)No — reads are never gated
Asset engine operations (asset.Transfer(), …)No — the asset engine has its own rules

Only the storage-mutate action on actor state is policed.

Commands

CommandDescription
grant storage_mutate to callers(<c>) origins(<o>) through <logic> [as <participant>]Grant write access to the participant's state for <logic>
get storage_mutate [through <logic>] [as <participant>]Show one grant, or every grant of a participant
wipe storage_mutate through <logic> [as <participant>]Revoke a grant
  • as <participant> is the actor doing the granting — it is their state that is being opened up. Policies are owned by users, so this must be a registered user, by name or by address. Defaults to default.sender.
  • through <logic> is the logic whose writes the grant covers. It must already be compiled.
  • <c> is the keyword any or a comma-separated list of compiled logics; <o> is any or a list of registered users. Names and 32-byte addresses are both accepted, and anything unknown — or of the wrong kind — is rejected when the grant is issued.
>>> grant storage_mutate to callers(any) origins(alice) through Points as bob
storage_mutate granted through logic Points on bob

>>> get storage_mutate as bob
storage_mutate grants on bob:
through logic Points: caller=any, origin=set{alice}

>>> wipe storage_mutate through Points as bob
storage_mutate access through logic Points on bob is wiped
# any caller, any origin — the widest possible grant
grant storage_mutate to callers(any) origins(any) through Points as bob

# several named logics and users
grant storage_mutate to callers(Points, Router) origins(alice, carol) through PointsB as bob

Callers and Origins

A policy answers one question: may this caller, acting for this origin, write this target's state through this logic?

IdentityMeaning
targetThe actor whose state is written — the Actor(id) in the mutate. The target owns the policy (as <target>) and is always a registered user.
resourceThe logic whose state slot is written (through <logic>). Each logic keeps a separate storage area under every actor.
callerWhoever called the logic that performs the write.
originThe account that signed the interaction — the as <sender> of the whole call.

The write is allowed when caller == target, or origin == target, or a matching policy exists on the target for that logic and both constraints accept.

The caller is not always the sender. It is the immediate caller of the logic that performs the write:

Call shapecallerorigin
alice invokes Points.Credit(bob) directlyalicealice
alice invokes Points.CreditVia(...), which calls PointsB.Credit(bob)Points (the logic)alice

Cocolab enforces that split when the grant is issued: callers(...) may only name compiled logics, origins(...) only registered users.

So a cross-logic write is granted through the writing logic, naming the calling logic:

# alice → Points → PointsB, and PointsB writes bob's state
grant storage_mutate to callers(Points) origins(alice) through PointsB as bob

On a direct call the caller is the signing user, which callers(...) cannot name. Cover that case with callers(any) and pin the user through the origin instead:

# alice invokes Points.Credit(bob) herself
grant storage_mutate to callers(any) origins(alice) through Points as bob

That also lets any logic acting on alice's behalf through Points write bob's slot. Where that matters, grant through the logic that actually performs the write and keep its caller set narrow.

Full Session

compile Points from manifest(points.yaml)
compile PointsB from manifest(points.yaml)
register alice
register bob
set default.sender alice
enlist Points.Register() as alice
enlist Points.Register() as bob

# the sender's own state — always allowed
invoke Points.Claim(amount: 10) as alice
# → balance: 10

# someone else's state — denied until bob grants
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → error: actor is not allowed to write into other actor's storage

grant storage_mutate to callers(any) origins(alice) through Points as bob
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → balance: 5

# cross-logic — the grant names the writing logic and the calling logic
grant storage_mutate to callers(Points) origins(alice) through PointsB as bob
invoke Points.CreditVia(remote: PointsB, to: bob, amount: 7) as alice with bob
# → balance: 7

# revoking restores the deny
wipe storage_mutate through Points as bob
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → error: actor is not allowed to write into other actor's storage

Rules

RuleDetail
Default denyNo policy means the write fails. There is no permissive fallback.
Callers are logicsA user can never be named in callers(...), even though a direct call's caller is a user. Cover that case with callers(any) and a narrow origins(...).
Origins are usersA logic signs nothing, so it can never be named in origins(...).
Set members are validatedEvery name in callers(...)/origins(...) must resolve, and to the right kind. One bad entry rejects the whole grant — a typo is an error at grant time, not a silent deny later.
any, not allThe wide-open keyword is any. all parses as an ordinary name and fails as an unknown caller.
An empty set deniescallers() parses and the grant succeeds, but it matches nobody.
Compile the logic firstPolicies are keyed by resolved address. grant and get report invalid logic '<name>' when the logic doesn't exist; wipe only reports no matching access policy found, so an unknown logic there looks like an already-clean slate.
One policy per pairOnly one policy may exist per (participant, logic). Re-granting fails — wipe it first.
Policies are per-participantA grant on bob says nothing about alice's state.
Both constraints must passcaller and origin are checked; either one failing denies the write.
The policy is the only gateCocolab resolves the touched participants itself, so the write lands with or without with <actor>. The grant is what authorizes it.
Access levels are a second gatewith bob/read or /none refuses the write with cannot access actor dynamically even when the grant is wide open. /write (the default) leaves the policy in charge.
Every as is validatedgrant, both forms of get, and wipe reject a name that isn't a registered user. A typo never comes back as an empty grant list or a missing policy. The as <sender> of deploy/invoke/enlist/create is checked the same way, except that it also accepts a compiled logic.
Grants are persistedPolicies are stored in the session snapshot and restored on the next start, after logics and users so that both resolve.

Errors

MessageCauseFix
actor is not allowed to write into other actor's storagemutate Module.Actor(id).* with no matching policyHave id grant access through this logic, with callers(any) and the sender in origins(...)
error in external call: pisa.Exception [builtin.AccessError] …The same, but the write happened inside a cross-logic callGrant through the called logic, naming the calling logic in callers(...)
invalid logic '<name>'through <name> before the logic was compiledcompile <name> from manifest(...) first
invalid user '<name>': not a registered useras <name> on grant/get/wipe storage_mutateregister <name> first
invalid user '<name>': access policies are owned by registered users, but '<name>' is a compiled logicA logic in the as clause of a storage_mutate commandName the user whose state is written
invalid user '<name>': not a registered user or a compiled logicas <name> on deploy/invoke/enlist/createregister <name>, or compile the logic, first
unknown caller '<name>'callers(<name>) names neither a compiled logic nor a known addresscompile <name> first, or fix the typo
invalid caller '<name>': callers must be compiled logics, but '<name>' is a registered userA user in callers(...)Use callers(any) and name the user in origins(...)
unknown origin '<name>'origins(<name>) names neither a registered user nor a known addressregister <name> first, or fix the typo
invalid origin '<name>': origins must be registered users, but '<name>' is a compiled logicA logic in origins(...)Name the signing user instead
access policy already exists; wipe it before granting againA grant already exists for that (participant, logic) pairwipe storage_mutate through <logic> as <participant>, then grant
no matching access policy foundget/wipe for a policy that does not existList them with get storage_mutate as <participant>
cannot access actor dynamicallyThe participant was listed with <actor>/read or /none — the access level, a separate gateUse /write (the default) or drop the level
sender not configured for environmentNo as clause and no default senderset default.sender <user>

Designing For It

  1. Prefer sender-writes. An endpoint that only touches Module.Sender.* never needs a grant. Where the design allows it, let the beneficiary call the logic themselves.
  2. Pull instead of push. Instead of crediting the recipient's actor state during a transfer, park the amount in logic state — which is never gated — and let the recipient claim it with an endpoint that writes only their own actor state.
  3. Ask for a grant when a push is required. Token ledgers, airdrops and settlement logics genuinely need to write other actors' state. Treat the grant as part of onboarding, next to enlist.
  4. Keep the constraints tight. callers(any) origins(any) lets any interaction through that logic touch the actor's state. Name the specific router logic in callers(...); for a direct write, where the caller set cannot help, pin the signer with origins(<user>).
  5. Grant the writing logic, not the entry point. In a router → implementation chain, the grant goes through the logic that performs the mutate, with the router in callers(...).

Testing Both Paths

The deny is the branch that regresses silently, so script both. In coco.nut:

[lab.scripts]
test-access = [
"compile Points from manifest(points.yaml)",
"register alice",
"register bob",
"set default.sender alice",
"enlist Points.Register() as alice",
"enlist Points.Register() as bob",
# denied before the grant
"invoke Points.Credit(to: bob, amount: 5) as alice with bob",
# allowed after it
"grant storage_mutate to callers(any) origins(alice) through Points as bob",
"invoke Points.Credit(to: bob, amount: 5) as alice with bob",
"invoke Points.BalanceOf(account: bob)",
]
coco lab run test-access --new-session

See Running Scripts for the wider scripting setup.