Skip to content

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.

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.

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.

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.

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.

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 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.

One tenant at its ceiling does not consume another’s allowance. See Limits — and note the scope, which is one instance.

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.

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.

Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.