# Who can see what

A dashboard that anyone with the URL can read is a screenshot with extra steps. The reason to keep dashboards in a maintained platform rather than in a folder of exported HTML is that the platform remembers who is allowed to open them — and keeps remembering after the person who built them has moved on.

This page is the honest map of that model: what it enforces today, where it stops, and which parts you can reach from code.

## The three roles[​](#the-three-roles "Direct link to The three roles")

Every membership of an account carries exactly one role:

| Role        | Value     | What it means                                                           |
| ----------- | --------- | ----------------------------------------------------------------------- |
| **Admin**   | `admin`   | Bypasses every access-rights check. Manages members, groups and grants. |
| **Creator** | `creator` | May create and mutate. Subject to access rights.                        |
| **Viewer**  | `viewer`  | Read-only. Refused on any route that mutates.                           |

Three things about this are worth knowing before you design around it.

**The role lives on the membership row, not in the token.** The JWT carries no role claim, so every role decision is a database read on the account/user pair. That is a little slower and a lot harder to forge.

**It fails closed.** A membership whose stored role is unrecognised — a legacy value, a typo — resolves to `viewer`, not to admin.

**Admin is an override, not a top rank.** An admin is not "a creator with more grants": the permission check short-circuits to *allow* before any grant is consulted. You cannot deny an admin out of a folder.

An API key acts as the person who created it

An API key resolves to its creating user, **with that user's role**. A key minted by an admin is an admin key, and everything below applies to it exactly as it applies to that person in the browser. There is no separate, narrower permission scope for keys, and a key does not expire when its creator's role is downgraded — revoke the key.

Twenty-four routes are deliberately closed to keys and need a real browser session: billing and payment (16 of them), API-key management itself, reading your subscription, account deletion, password change, telemetry, and embed-token signing. **Everything else accepts a key** — including every access-rights, group, folder, dashboard and dataset route.

**One deliberate exception.** Flipping a dashboard to `public` is gated by an account switch, `apiKeysMayPublish`, that **defaults to `false`** and can only be turned on by an admin in a browser. So a key — and therefore an agent holding one — can build, publish and share inside your account, but cannot by default be what makes something world-readable. See [Publish and share](/guides/publish-and-share.md#going-public-is-gated-separately-and-an-agent-cannot-do-it-alone).

## Groups[​](#groups "Direct link to Groups")

A group is a named bag of users inside one account. You grant to the group; membership changes without touching the grant.

Two limits, both deliberate:

* **Groups contain users, not other groups.** There is no nesting, so there is no group hierarchy to reason about.
* **Reads are open to every member of the account; writes are admin-only.** Any member can list the groups and see who is in them. Only an admin can create, rename, delete, or change membership.

Groups *are* in the SDK:

```
from vibedasher.models import GroupCreate, GroupUpdate



created = client.groups.create(GroupCreate(name="analytics", description="Read-only analysts"))

client.groups.update(created.group.id, GroupUpdate(user_ids_to_add=["<user-id>"]))



for g in client.groups.list().groups:

    print(g.name, g.user_count)
```

```
const created = await client.groups.create({ name: 'analytics', description: 'Read-only analysts' });

await client.groups.update(created.group.id, { userIdsToAdd: ['<user-id>'] });



const { groups } = await client.groups.list();
```

## Access rights — the grant is on the folder[​](#access-rights--the-grant-is-on-the-folder "Direct link to Access rights — the grant is on the folder")

This is the single most important sentence on this page, and the one most likely to surprise you:

> **A grant names a folder, never an individual dashboard or dataset.**

An access right is a row of the shape:

```
(account, folder, resource type, permission, principal) → allow | deny
```

* **resource type** — `dataset` or `viz`. Those are the two a grant can name; see [What a grant can name](#what-a-grant-can-name) for what governs everything else.
* **permission** — `view`, `edit`, `delete`, `create`.
* **principal** — a user or a group.
* **allow / deny** — the two access types.

A dashboard is covered by a grant because it *sits in* the granted folder. Move it to another folder and it is governed by that folder's grants instead. There is no per-dashboard grant to lose track of, which is the good half of this design; the other half is that **the folder is the smallest unit you can grant**, so a folder you share is a folder you share entirely.

### That is not a ceiling on how narrow a share can be[​](#that-is-not-a-ceiling-on-how-narrow-a-share-can-be "Direct link to That is not a ceiling on how narrow a share can be")

The sentence above is about the *grant*, not about the share. A folder is the smallest thing you can name in a grant — and you decide what is in a folder. To share exactly one dashboard, give it a folder of its own, which is three calls your agent already has:

```
create_folder(name="Q3 board pack")

move_to_folder(resource_type="viz", resource_id=<vizId>, folder_id=<newFolderId>)

grant_access(folder_id=<newFolderId>, resource_type="viz",

             principal_id=<userId>, principal_type="user", permissions=["view"])
```

`resource_type` is `dataset` or `viz` on the move, `principal_type` is `user` or `group`, and `access_type` defaults to `allow` — `deny` is the other value, and it beats every allow.

These three are MCP tools, and the MCP is the supported way to do this. **The access-rights routes are deliberately kept out of the published SDK and the API reference** — they are access-control plumbing whose shape we have not committed to, so no generated client method exists for them and none should be relied on. `create_folder` is the one of the three with an SDK twin, `client.folders.create`.

Moving a dashboard changes which grants govern it and nothing else — the dashboard keeps its id, and [its link is unaffected](/guides/publish-and-share.md). So "per-dashboard sharing is impossible" is the wrong conclusion to draw from the folder rule: it is three calls, and the folder is how you express it.

### Both halves of a dashboard grant[​](#both-halves-of-a-dashboard-grant "Direct link to Both halves of a dashboard grant")

A dashboard and the data under it are governed by **separate resource types**, so a share that only names one of them is half done. `viz:view` lets somebody open the dashboard; `dataset:view` is what lets its tiles return numbers. The next section is why that is not a detail you can defer.

### How a decision is actually made[​](#how-a-decision-is-actually-made "Direct link to How a decision is actually made")

In order. The first rule that matches wins:

1. **Admin** → allow. Unconditionally, before any grant is read.
2. **The internal `system` principal** → allow. This is the platform's own worker identity (refresh jobs, scheduled runs); it is not an account you can log into.
3. **Ownership** → allow. The user who owns the row can always read and change it.
4. Otherwise, the folder grants decide, and **a single deny beats every allow.**

Deny-wins is worth stating precisely, because it is narrower than it sounds. A deny removes a folder from the set you were granted — it does not subtract from rules 1–3. **You cannot deny an admin, and you cannot deny someone out of a row they own.** If your mental model is "deny is the emergency stop", it is not: it is a subtraction inside the grant layer only.

### Two behaviours that will bite you[​](#two-behaviours-that-will-bite-you "Direct link to Two behaviours that will bite you")

**Grants do not cascade into subfolders.** The check is a flat set-membership test on the resource's own `folder_id`. Folders do have a parent/child relationship, and the UI draws it as a tree — but no authorization code walks it. A grant on *Marketing* does **not** reach *Marketing / Q3*. Grant each folder you mean to grant.

**A deny on `create` used to do nothing — fixed 2026-09-08.** The create-permission check read only the `allow` rows; a Creator's bootstrap right to create in the account root or in a folder they own, and an explicit `allow` row elsewhere, both bypassed any `deny` entirely. An admin's explicit `create` / `deny` row now overrides both — the same "a single deny beats every allow" rule the rest of this page describes for view/edit/delete. Admins remain undeniable, matching every other permission here.

### Who may change a grant[​](#who-may-change-a-grant "Direct link to Who may change a grant")

An **account admin**, or the **owner of the folder** the grant is on. Reading the grants is open to any member of the account: the sharing state is rendered in the app on pages that any member can open, so the read is deliberately ungated. Treat the grant list itself as account-visible information.

## What a grant can name[​](#what-a-grant-can-name "Direct link to What a grant can name")

**Two resource types are grantable: `viz` (dashboards) and `dataset`.** Build a governance model out of those two, held in folders — that is the shape the model enforces and the only shape the dialog offers. `POST /v1/access-rights` refuses any other resource type with a `400`, at the moment you write it rather than at the moment somebody relies on it.

Three things are governed by something other than a grant, and knowing which is which is the whole point of this section:

| Resource             | What governs it                                                                                                                      |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Folder**           | Account membership for reads; role for mutations. A folder is the container a grant *names*, not a thing a grant is written against. |
| **Refresh schedule** | Ownership plus the account boundary.                                                                                                 |
| **Question**         | Owner-only. A question cannot be shared, by any mechanism.                                                                           |
| **Task**             | The ACL of the *dashboard* it belongs to.                                                                                            |
| **Connection**       | Its owner and the account's admins. Any Creator may create one.                                                                      |

**An empty list tells you which kind of empty it is.** `GET /v1/access-rights/{folder}/{type}` carries an `enforced` flag, so `total: 0` for a non-grantable type means *no right of this type can exist anywhere*, while `total: 0` for `viz` means *nobody holds one*. Rows written before the refusal are still listed, and still removable, with `enforced: false` beside them.

## Sharing a dashboard without sharing its data does not work[​](#sharing-a-dashboard-without-sharing-its-data-does-not-work "Direct link to Sharing a dashboard without sharing its data does not work")

This is the question the model does *not* answer the way most people expect, so it is worth one flat sentence: **`viz:view` alone does not give somebody a working dashboard.**

A dashboard's tiles fetch their numbers with `POST /v1/query`, and that route authorizes **every participating dataset through the dataset ACL**, deny-don't-drop — one dataset the viewer cannot read rejects the whole query with `403 query_dataset_forbidden`. It is never executed against the subset they *are* allowed. So "let the intern see this dashboard but not the data behind it" produces a dashboard whose tiles fail, not a dashboard with the numbers hidden.

Grant `dataset:view` on the folder holding the datasets as well — `viz:view` and `dataset:view` are a **pair**, and a grant carrying only the first produces a dashboard whose every tile `403`s. If the datasets should not be shared wholesale, give them a folder of their own the same way the recipe above does. Or, if the point really is to show a result without handing over the underlying data, **publish the dashboard or embed it**. Both of those lanes run as the platform's own service identity and never consult this ACL at all, which is exactly why they can show a number to somebody who may not query it.

## Checking your own work[​](#checking-your-own-work "Direct link to Checking your own work")

Every field above describes a rule; none of them tells you what a *particular person* ends up with, and that is not something you can read off the grant list. Rights compose — group memberships, a `deny` that beats every allow including one inherited through a group, the per-resource-type split, and the separate role gate on mutation — into an outcome no single list shows. The inherited `deny` is the case that matters: it never appears in the allow list you read, so "why can this person not see it" is unanswerable from the list alone.

`GET /v1/access-rights/{folder}/effective?userId=` resolves it. For every (resource type, permission) pair it returns what the ACL decides, **which rule decided**, whether the person's role permits it at all, whether the type is enforced, the product of those — and **the access-right row that produced the verdict**, which is the id to revoke when the answer is a deny. Your agent has the same thing as `describe_effective_access`.

It answers the ACL question only, and says so in a `caveats` list on every response: it cannot see rows the person *owns* (ownership is per-row and outranks the folder ACL), it knows nothing about the public-viz or embed lanes, and — per the section above — a working dashboard needs rights on its datasets too. You may always ask about yourself; asking about somebody else needs the same standing as changing the ACL, an account admin or the folder's owner.

## Reaching this from code[​](#reaching-this-from-code "Direct link to Reaching this from code")

| Surface                                      | In the SDKs?                  |
| -------------------------------------------- | ----------------------------- |
| Groups (create, list, update, membership)    | **Yes** — `client.groups.*`   |
| Users (list, update, role, group membership) | **Yes** — `client.users.*`    |
| Account                                      | **Yes** — `client.accounts.*` |
| **Access rights (the grants themselves)**    | **No**                        |

Access rights are excluded from the generated SDK surface, so there is no `client.accessRights` and no entry for them in the **[API Reference](/reference/vibedasher-sdk-api.md)**. The routes exist and accept an API key — they are simply not part of the published, versioned client contract, and we would rather tell you that than let you build against a shape we have not committed to. Manage grants in the app, or from your agent: the MCP server exposes grant, revoke and list-access-rights tools that call the same routes under your own identity, and inherit the same admin-or-folder-owner rule.

## Row-level filtering: the embed lane, and only the embed lane[​](#row-level-filtering-the-embed-lane-and-only-the-embed-lane "Direct link to Row-level filtering: the embed lane, and only the embed lane")

Row-level security is real in Vibedasher, but it lives in one place: **embedded dashboards behind a signed token.**

When your server mints an embed token, it can attach filters to the token payload. On every query the platform rewrites each dataset reference into a filtered subquery, so the viewer physically cannot read rows outside their slice. The filters come **exclusively** from the verified token — never from the request body — so a viewer cannot widen their own scope by editing a request. A filter naming a column that exists in no dataset schema is rejected with a `403` rather than silently ignored, because a filter that quietly does nothing is a hole.

Three limits to plan around:

* **It applies to the embed lane only.** The SDK query lane has *no* mandatory filter injection: an API key is a trusted server-side caller, and scoping its own users is your job, not the platform's.
* **The folder ACL is not row-level.** It decides which dashboards and datasets exist for you, not which rows inside them you can read. Those are different questions and Vibedasher answers them with different machinery.
* **A dashboard that queries in the browser cannot be scoped at all — by design.** This is the one to decide before you build, not after. A self-hosted dashboard on the credential-free lane — the ejected tree whose `dataLane` is `wasm`, or anything you built yourself over a Parquet or JSON extract — downloads the data and runs every query in the visitor's browser. The extract is keyed by **dataset and snapshot, not by a query**, so it holds every row of that dataset, and every visitor receives the same file. No server is left in the path to narrow it: a filter written into the page changes what is *drawn*, never what was *delivered*.

Row scope and the credential-free lane are mutually exclusive

That lane's whole appeal is that there is no key in the bundle, no token to mint and no backend of your own to run — and the price is exactly the same property seen from the other side: nothing can hand one viewer fewer rows than another. It is not a gap that will be closed; it is what putting the data in the browser means.

If some viewers must see fewer rows, keep the query on a server. Two shapes do that, and both keep the credential off the page: **your own backend mints a short-lived embed token carrying the filter** (the platform appends that predicate to every query it authorizes, ahead of any join or aggregate, so the page cannot widen it), or **trusted server code queries under an API key** and returns rows you have already narrowed. Choosing the fast lane first and "scoping" it later with a filter in the page produces a dashboard that looks right, raises no error, and ships every row.

See **[Embedding](/api/embedding-the-share-iframe.md)** for how a token is minted and presented.

## Two-factor authentication[​](#two-factor-authentication "Direct link to Two-factor authentication")

Email + password is the one way into Vibedasher that rests on a single secret. Google and LinkedIn sign-ins carry their provider's second factor; API keys and the `vibedasher login` CLI token are not logins at all. So the second factor is **opt-in, per user, on the password sign-in only**: a six-digit TOTP code from any authenticator app (Google Authenticator, 1Password, Authy, …), enrolled from **Settings → Two-factor authentication** by scanning a QR code and confirming one code. Turning it on hands you **eight one-time recovery codes**, shown once — a lost phone is signed in with one of those.

What it changes at sign-in: after a correct password, the browser gets **no session** — it gets a five-minute, single-purpose challenge that only the code step can redeem. The API never sees that challenge as a credential: it carries an audience the API refuses outright, so it cannot be replayed against `api.*.vibedasher.com` or dropped into the session cookie. Five wrong codes lock the challenge for fifteen minutes; a code is valid once.

Three limits to know:

* **Enrolment is per person.** Each member turns it on for their own account; it is not something an admin switches on for everybody.
* **OAuth sign-ins are never challenged**, by design — adding our factor on top of the provider's is friction without security.
* **Lockout is an admin action.** A member with no phone and no recovery code asks an **account admin**, who resets their second factor from the member's page (the action is logged). An owner who is the only admin, with no recovery code, goes through support.

## A practical setup[​](#a-practical-setup "Direct link to A practical setup")

For a team of ten with a couple of sensitive datasets:

1. Make the one or two people who administer the account **admins**. Remember they are ungovernable by grants — that is the point of the role, and the reason to keep the list short.
2. Everyone who builds is a **creator**; everyone who only reads is a **viewer**. The viewer role is a hard refusal on mutation, so it is a real boundary, not a UI convention.
3. Put dashboards and datasets into folders that match how you want to share them — **one folder per audience**, not per topic. The folder is the grant.
4. Grant to **groups**, not to people. The grant then survives someone joining or leaving.
5. Do not create nested folders and expect the parent's grant to reach them. Grant each one.
6. If a dashboard needs to go to people outside the account, publish it and embed it with a signed token carrying their row filters — do not give them an account.
