proven on the hosted testnet instance · 2026-09-13 · QA-14
Tenancy: what is isolated, what is visible
A tenant is a Sui address. Not an account you create here, not a row you are issued — an address, which your users may already have.
Where it comes from depends on the credential:
| Credential | The tenant is |
|---|---|
An API key (pf_sk_…) |
the address the key was minted against, when the invite was redeemed |
| A signed token | the token’s sub claim, which must arrive as 0x + 64 lower-case hex |
The edge resolves it, attaches it to the request, and the storage layer enforces it. The two lanes produce the same attached context with the same values, so nothing downstream can tell which credential a request arrived with — and neither lane can set that context itself.
What is isolated
Section titled “What is isolated”Buckets are owned at creation
Section titled “Buckets are owned at creation”A bucket belongs to the tenant that created it. Twelve operations check the caller against the bucket’s owner before doing anything:
CreateBucket · DeleteBucket · ListObjects · ListMultipartUploads · PutObject ·
GetObject · DeleteObject · CreateMultipartUpload · UploadPart · ListParts ·
CompleteMultipartUpload · AbortMultipartUpload
Anyone else gets 403 AccessDenied. ListBuckets returns only your own.
Objects are owned row by row
Section titled “Objects are owned row by row”Underneath the bucket check, every object carries its owner, and a read or a delete on a row owned by someone else is refused before any decryption key is selected. That second layer is what covers the case the first cannot: a bucket holding more than one tenant’s rows.
Both layers fail closed
Section titled “Both layers fail closed”A caller whose tenant cannot be resolved at all is denied on a tenant-owned resource, never admitted. Absence of an answer is never read as permission.
Every refusal is written down
Section titled “Every refusal is written down”The storage layer logs each denial with the operation, the bucket, the owner it found and the caller it had. That log is the witness the isolation run reads: a two-tenant run asserts not only that each cross-tenant operation was refused, but that the denial log grew by exactly the number of operations that should have been refused. A guard that silently stops running is a guard whose log stops growing.
/v1/api reads answer 404, not 403
Section titled “/v1/api reads answer 404, not 403”GET /v1/api/meta/objects/{id} and the custody id routes answer 404 for an object that is not
yours — the same 404 they answer for an object that does not exist. A 403 there would confirm
that the id is real, which would make the route a way to enumerate other tenants’ objects. Custody
goes further: an id whose owner cannot be resolved is also that same 404.
Custody rows are per tenant
Section titled “Custody rows are per tenant”Custody objects are not separated by bucket — every tenant’s rows live in one place and ownership is
enforced per row, by the same check the object layer uses. GET /v1/api/custody/list enumerates
only the caller’s own.
Rate is per tenant
Section titled “Rate is per tenant”One tenant at its ceiling does not consume another’s allowance. See Limits — and note the scope, which is one instance.
What is visible across tenants
Section titled “What is visible across tenants”Bucket names, and nothing else.
A HeadBucket on a name another tenant created answers, because bucket existence is not guarded. So
a caller can learn that a name is taken. That is the whole of it: they cannot list the bucket, read
from it, write to it, or learn who owns it. Contents, ownership and every byte stay refused.
This is deliberate and recorded rather than an oversight. Treat a bucket name as public, the way an S3 bucket name is public everywhere, and do not encode anything in one that you would not put in a URL.
One related residue, for completeness: a bucket that predates tenant ownership is not scoped at the bucket layer, so its listing shows every key in it. The object layer still refuses the bytes of every row the caller does not own. No bucket you create is in that state — a bucket is owned from the moment it is created.
What tenancy is not
Section titled “What tenancy is not”It is not encryption, and encryption is not it. Objects are encrypted at rest, but the decryption key is selected from the row’s owner, not from the caller — so encryption alone would have handed one tenant another’s object, decrypted. The access check is what stops that, and it runs first. Encryption at rest says what that layer does and does not cover.
It is not proof of address ownership. On this instance the address is a format-checked identifier. On the invite path you supply it; on the token path the instance trusts your issuer to have established that the subject owns it. A wallet-ownership challenge is a control for a different deployment, not this one.
It is not a self-service view for everyone. /me and the portal authenticate by key, so a tenant
that arrived through a signed token has no key to present and sees its usage on our operator surface
on request instead. See Keys and the portal.
- Bring your own issuer — how a tenant appears on first use, with no key minted.
- Onboard with an invite — the key lane.
- Encryption at rest — the layer beneath this one.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.