This page describes Vault’s access-control model: how files become owned by a user, how owners share them, and how the platform decides — server-side — who is allowed to read, edit, or delete a file. The model is Cognito + S3 in spirit: Identity supplies who you are, Vault enforces what you can touch.
✦ Shipped
This model is live (ADR 016). The Bucket app is built on it end-to-end — the snippets below are taken from its source.
The keystone: how the system knows who you are
The most important property — and the strongest security guarantee — is that the app frontend never decides who you are. The platform does, server-side, from something the app cannot touch.
- You sign in once at
identity.{org}.local. Identity sets a session cookie on.{org}.localthat isHttpOnly— JavaScript cannot read it, so app code can never read, steal, or forge it. - Every request your browser makes to
api.{org}.localcarries that cookie automatically — you don’t attach it, the browser does. - The Gateway takes the cookie, validates it against
platform.sessions(real? not expired?), and resolves it to youruser_id. - It forwards the request to Vault with your
user_idin an HMAC-signed internal header. Vault trusts it only because it’s signed with the Gateway’s secret — a compromised app container cannot forge it. - Vault stamps that
user_idas the file’sowner, and uses it for every later access check.
Browser ──cookie (HttpOnly, .{org}.local)──▶ Gateway
│ validate session → resolve user_id
│ sign internal headers (HMAC)
▼
Vault ── owner / access checks keyed on user_id
In one line: identity is never asserted by the frontend — it’s derived by the Gateway from an unforgeable cookie and signed inward, so a malicious app frontend cannot impersonate another user, because it never handles identity at all.
Two kinds of app
You choose your app’s model simply by whether you wire in Identity:
| App type | Login? | Default visibility | Available |
|---|---|---|---|
| Communal | no | app-shared (everyone using the app shares everything) | + public |
| Personal (Drive-like) | yes — via Identity | private | + share per-user · + public |
There is no halfway: “private to me” is meaningless if the system doesn’t know who “me” is, so private requires Identity. The moment you want per-user files, you’re a Personal app.
Visibility: every file/folder has exactly one
In a Personal app, each file and each collection (folder) has a single visibility setting:
| Visibility | Who can see it | Set by |
|---|---|---|
| Private (default) | the owner only | nothing to do — it’s the default |
| Shared | specific people you name, each as viewer or editor | the owner, per user |
| Public | anyone — even logged-out — via a stable URL | owner toggles is_public |
The crucial distinction: public ≠ “everyone in the app.” Public means no authentication at all — a public file is fetchable by anyone on the LAN who has the URL. That’s why public is its own tool, separate from sharing: it lets you publish something to the world without inviting strangers into your user database.
Roles
Sharing is per individual user (no group/role grants — kept deliberately simple). Each grant carries one role:
| Role | Can | How you get it |
|---|---|---|
| owner | everything + share + make public + transfer | you uploaded it |
| editor | read, download, upload/overwrite, delete | an owner shared it with you |
| viewer | read + download only | an owner shared it with you |
| app admin | list + delete/transfer for housekeeping — never reads private contents | implicit (Console admin) |
| public | read + download | the file is is_public |
A collection editor grant is what enables “others can upload into my folder.”
Two gates, by design
Access is two independent layers that stack — both must pass:
- App access (Layer 1 — Identity): are you allowed into this app at all? Default-deny, admin-granted. This is the gate every NubleStation app already enforces.
- File access (Layer 2 — Vault): within the app, can you see this file? Owner + shares + public.
A consequence worth saying out loud: you can only share a file with someone who is already a member of the app. Creating an account isn’t enough — an admin must first grant them app access (Layer 1); only then do they appear in your share picker (Layer 2). Admins control who’s in the app; owners control who sees their files.
How Vault resolves a request
For any action, with the principal being the cookie-derived user:
public + read → allow (even anonymous, via the public URL)
principal == owner → allow all
grant on the file OR its collection → that role's capabilities
app admin → list / delete / transfer only — never read contents
otherwise → deny
Listing
So a user’s “My Drive” view never fills with other people’s public files, listing is explicit about scope:
| Call | Returns |
|---|---|
listMine(collection?) | files you own |
listSharedWithMe() | files explicitly shared with you (each annotated with your role) |
listPublic(collection?) | the separate public space |
list(collection?) | everything you can access — own ∪ shared ∪ public ∪ communal (app-admins see all) |
Bucket uses the first three to back its My Files / Shared with me / Public tabs. list() is the catch-all for apps that don’t need the split.
Defaults (locked)
- New files and collections are private to their owner.
- Sharing is per individual user, with role
vieweroreditor. - Public is anonymous — no login required to read a public file.
- Sharing requires the recipient to already have app access.
SDK & example
The @nublestation/vault SDK exposes listMine / listSharedWithMe / listPublic / list, share / unshare / listGrants, plus the existing upload / download / setPublic / delete. @nublestation/identity exposes listAppUsers() to populate a share picker.
The SDK does not take any user argument — identity comes from the cookie. The only setup difference from a communal app is that the Vault client sends the session cookie automatically (credentials: "include", built in).
Loading the three views
This is the whole of Bucket’s view loader — the view toggles which scoped call runs:
// apps/bucket — useVaultStore.ts
import { createVaultClient } from "@nublestation/vault";
const vault = createVaultClient({ url: NUBLE_URL, apiKey: NUBLE_KEY });
async function load(view: "mine" | "shared" | "public") {
if (view === "mine") return vault.listMine(); // files I own
if (view === "shared") return vault.listSharedWithMe(); // shared with me (+ role)
return vault.listPublic(); // public space
}
Sharing a file
Owners share with one user at a time, as viewer or editor:
// Who can I share with? (everyone with access to this app, minus me)
const people = await identity.listAppUsers();
// Grant — filename = the file, or null for a whole collection
await vault.share("reports", "q1.pdf", granteeUserId, "editor");
// See and revoke current grants on a resource I own
const grants = await vault.listGrants("reports", "q1.pdf");
await vault.unshare("reports", "q1.pdf", granteeUserId);
Uploading & publishing
Upload is always private to the uploader; setPublic opts a file into the anonymous public URL:
await vault.upload("reports", "q1.pdf", bytes); // private, owner = me
await vault.setPublic("reports", "q1.pdf", true); // now world-readable
Enforcement is server-side
The SDK is a thin client — every rule above is enforced in Vault, not the browser. A viewer who calls delete(), or anyone trying to read a private file they weren’t granted, gets a 403 regardless of what the frontend allows. The UI hides those buttons (via the per-file role field) purely for ergonomics.