Access control
Control who can read, write, and delete records using roles (RBAC), ownership, shares and never-rules.
#Access control
Access starts from two questions asked separately — what a role may DO, and which records it REACHES — and then four things that widen or narrow the second:
- Role (RBAC) — what a role may do to a resource (create / read / update / delete / transfer). This is the ceiling, and nothing below raises it.
- Reach — which records that applies to: all of them, or only the ones the caller owns.
- Ownership — what "their own" means. Since ownership can be handed over it
is
owned_by, falling back tocreated_byfor a record nobody has moved. See Ownership. - Shares — one record handed to a person, a crew, or everyone. Adds records to someone's reach; never adds an action.
- Visibility rules — a standing condition on the collection: anyone may view records where status is published. Adds records for a whole audience.
- Never-rules — the only piece that SUBTRACTS. Terminal, and binds even super admins unless the rule says otherwise.
Every one of them compiles down to the same permissions table and is enforced
at the API middleware layer: each request is authenticated, its role's grants are
composed, and a row-level WHERE clause is injected before the query reaches D1.
A cell of that table now holds one row per writer — the role editor, this Access screen, a collection preset, a visibility rule — which is why editing one no longer silently destroys another. The access inspector reports which contributed, as "Composed from: …".
Workspace model: all users share one workspace. Data is scoped by an internal
project_id(retained as a seam for future hard multi-tenancy), but today there is a single shared workspace and crews provide optional soft grouping. See the roadmap for the deferred hard-isolation work.
#How it works
D1 (SQLite) has no native row-level security. EmuView enforces it by rewriting queries at the middleware layer:
- The request is authenticated (session token or API key).
- The user's role is resolved.
- The collection's permission for the action is loaded (KV-cached).
- A row-level
WHEREclause is injected from the permission'sitem_filter. - The query executes against D1.
- Forbidden columns are stripped from the response.
The same flow as a diagram:
Request
│
▼
┌─────────────────────┐
│ 1. Authenticate │ session token ──┐
│ (who are you?) │ OR api key (sk-) ─┘
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 2. Resolve role │ super_admin / admin / editor /
│ │ viewer / api / public / custom
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 3. Load permission │ for (role, resource, action)
│ (KV-cached) │ ── miss? read D1, then cache
└─────────┬───────────┘
│ no grant → 403 Forbidden
▼
┌─────────────────────┐
│ 4. Inject row WHERE │ from permission.item_filter
│ ($CURRENT_USER, │ ($CURRENT_ROLE, $NOW, …)
│ …) │
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 5. Execute on D1 │ parameterised SQL
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 6. Strip columns │ hidden + role-restricted fields
└─────────┬───────────┘
▼
Response
super_adminshort-circuits this pipeline: it is granted at step 2 and skips the row filter at step 4 entirely.
#The two-axis model
Role answers what actions are allowed; scope answers which rows. A permission grant only takes effect where both axes agree — a coarse role grant is narrowed by the finer scope.
The grid below is the core of it, and it is still how the two axes combine. Three things adjust the ROW side afterwards, and none of them touches the verb side: shares and visibility rules ADD rows to what somebody reaches, and never-rules REMOVE them from everybody. The full sentence is under Permissions.
SCOPE (which rows?)
collection policy
───────────────────────────
none all own
┌──────────┬─────────┬─────────┬─────────┐
R cr │ create │ ✗ │ all │ mine │
O ud │ read │ hidden │ all │ mine │
L │ update │ ✗ │ all │ mine │
E │ delete │ ✗ │ all │ mine │
└──────────┴─────────┴─────────┴─────────┘
(what?) ▲ ▲
RBAC grant │ │
coarse grant fine scope narrows
from the role it to a row subset
effective access = ROLE grants the action <- the ceiling
AND ( SCOPE allows the row <- this grid
OR a share reaches it
OR a visibility rule covers it )
AND no never-rule forbids it <- terminal
A role that can read a collection at all scope sees every row; give that same role own scope and it still reads, but only rows it created. Everything beyond that comes from a share.
#Built-in roles
| Role | Access level |
|---|---|
super_admin |
Full access to everything in the workspace; bypasses row filters. |
admin |
Manages collections, users, roles, and per-collection access. Full CRUD on collections. |
editor |
Read/write on collections where granted. |
viewer |
Read-only where granted. |
api |
API-key identity. Scoped by the key's assigned role. |
public |
Unauthenticated visitors. Only what a collection explicitly opens to public. |
You can also create custom roles and grant them per-collection scopes.
#Per-collection access
Each collection's Access panel (collection settings → Access) sets, per role, a single row scope plus whether that role can write:
| Scope | Behaviour |
|---|---|
none |
The role cannot see the collection at all (it is also hidden from the collection list). |
all |
The role can access every record (no row filter). |
own |
The role can only access records it created (created_by = current user). |
There used to be a fourth,
crew, which scoped rows by the caller's crew membership. It has been removed — a crew reaches a record through a share now. A config still naming it (or acrew_column, or the legacymode: "crew_scoped") is answered with400 crew_reach_removed. See flock record access.
super_admin and admin always have full access and are not editable here. Set it from the dashboard or the API:
curl -X POST https://your-api.example.com/api/v1/collections/products/access-control \
-H "Authorization: Bearer sk-your-api-key" \
-H "Content-Type: application/json" \
-d '{
"owner_column": "created_by",
"sharing": "owners",
"roles": {
"editor": { "access": "all", "write": true },
"viewer": { "access": "all", "write": false },
"public": { "access": "none", "write": false }
}
}'
sharingis preserved when you omit it. This endpoint replaces the whole config, but that one key is resolved against the stored value first, so a request that says nothing about sharing leaves the operator's choice alone. Send it only when you mean to change it. Valid values:off,owners,curators— andoffREVOKES every share the collection already has.Everything else here IS a wholesale replace: a role missing from
rolesloses its grants. Read the current config withGET /:name/access-controland send it back with your changes.
PUT /:name/policyis removed. The older endpoint (with{ permissions: { read/write/delete: { allow } } }) was deprecated as a thin alias onto the model above, and is gone as of 2026-09-10 —POST /:name/access-controlis the only way to write a collection's access config. Translate a legacy body by naming the roles directly:read.allowbecomes each role'saccess(all,ownornone), andwrite.allowanything other thannonebecomeswrite: trueoneditor.
GET /:name/policyis unaffected. It is a read, derived from the same rows, and still answers in the legacy shape.
#Crew-based row scoping (removed)
Flocks are groups of people; this reference calls them crews, as the API does. A collection used to be able to partition its rows by crew: a crew_id column on each record, a crew row scope, and three filter variables that compiled the two together.
That mechanism is gone. A crew reaches a record through an item share, which is the next section, and which does everything the column did plus several things it could not — a record can reach two crews, or a crew and a person, or a crew at read while somebody else edits.
There is nothing to migrate on an instance that never used it. There is one command to run first on an instance that did; see flock record access and updating an EmuView instance.
own scope still needs an owner_column (default created_by), and the Access panel offers to add it if it is missing.
#Item sharing
Independent of every scope above, the owner of a record or file can grant it to named people or to crews. This adds ROWS to what somebody reaches; it never adds verbs, so the role grants in this document remain the ceiling.
| Level | Read | Update | Delete | Share onward |
|---|---|---|---|---|
view |
✓ | |||
edit |
✓ | ✓ | ✓ (crew: + manager) | |
manage |
✓ | ✓ | ✓ | ✓ |
A crew share is narrowed twice — by the level granted and by the member's own crew TIER. A share naming a person has no crew standing, so only the level applies. Both are still capped by the role.
POST /api/v1/shares { itemType, collection, itemId, userId | crewId, level, expiresAt }
GET /api/v1/shares?itemType=&collection=&itemId=
DELETE /api/v1/shares/:id
Exactly one of userId or crewId. Re-sharing with the same subject updates the
existing grant rather than adding a second. Shares are swept automatically when a
record is hard-deleted or a collection is torn down.
Full guide: sharing an item.
#Crew isolation modes
Can a workspace admin touch a crew they aren't a member of? The security.crew_isolation setting answers it, and it is now about the crew container only — viewing the crew, inviting people, managing members and invitations.
| Mode | Manage crew (invite / members)? | Notes |
|---|---|---|
strict |
Members only | Membership is absolute — even admins need to join a crew to touch it. |
admin_manage |
Admins (any crew) | Default, recommended. Admins can run any crew's membership. |
A mode change takes effect immediately — the setting is re-read on each check, with no cache to bust.
There used to be a third mode,
admin_full, which additionally dropped the crew row filter so admins could read every crew's records. With no collection partitioned by crew, that half of the setting distinguishes nothing, so it is gone. Records an admin reaches are decided by their role's grants and by shares, like everyone else's;super_adminstill bypasses row filters entirely, in every mode.
#Row-level security & dynamic variables
Row filters are injected as parameterised SQL. The scope you pick maps to these tokens, resolved at query time:
| Variable | Resolves to |
|---|---|
$CURRENT_USER |
The authenticated user's ID (used by own scope) |
$CURRENT_ROLE |
The user's role name |
$PROJECT_ID |
The caller's workspace ID |
$NOW |
Current Unix timestamp |
$CURRENT_USER_CREWS, $CURRENT_USER_CREW_EDITORS and $CURRENT_USER_CREW_ADMINS were retired with crew scoping. A filter still naming one is refused on every evaluation rather than resolving to an empty list — because "matches nothing" inverts to "matches everything" under _neq, _nin and _not, so a filter meant to confine a caller to their crews would have admitted every row in the collection the moment the token stopped resolving. Rewrite the grant, or delete it.
#Column-level security
Which fields appear in responses is controlled by a role's permission fields (managed in the RBAC role editor, not the per-collection Access panel):
- Hidden columns are stripped from every response regardless of role.
- Role-restricted columns are returned only to the allowed roles; others receive the response without those fields.
#Access presets
There are two preset families, and they answer different questions. Mixing them up is easy because both are called "presets" in conversation.
| Family | Answers | Where |
|---|---|---|
| Collection presets | WHO | accessPreset at collection creation |
| Row-scope presets | HOW MUCH | Buttons on Settings → Access |
They are separate on purpose: "editors can write" and "editors can only write their own" are different decisions, and one list covering both would have to enumerate every combination of the two.
#Row-scope presets (the buttons)
On Settings → Access for any collection, three buttons fill in the per-role matrix as a starting point:
| Button | Sets editor / viewer to | Meaning |
|---|---|---|
| Shared | all |
Everyone with access sees every record |
| Private to each person | own |
People see and edit only records they created |
| Shared within crews | crew |
People see records belonging to crews they are in |
Three properties worth knowing:
- They fill in the matrix; they do not save. Read what appeared, adjust anything you disagree with, then press Save. Nothing records which button was pressed — a remembered preset would stop being true the moment one row was edited by hand.
- They never make a collection public. Whether
publicorapican read it is the WHO axis, and no button about row scope will decide it for you. - They only set
editorandviewer. If you have added custom roles, the screen names any that still see everything, so a button cannot quietly under-deliver.
The endpoint serves the definitions alongside the config, so the buttons and the gateway cannot disagree about what a preset means:
GET /api/v1/collections/:name/access-control
→ { "accessControl": {…}, "explicit": true, "scopePresets": [ … ] }
#Collection presets (at creation)
Rather than hand-write a policy for every new collection, you pick an access preset at creation time that declares intent. Each preset layers record-level grants onto the non-admin roles. (super_admin and admin always get manage — the create handler bootstraps that separately, so it is not part of a preset.)
| Preset | editor | viewer | api | public | Use for |
|---|---|---|---|---|---|
private |
— | — | — | — | A private vault. Admin-only; the secure default. |
team |
read, create, update, delete | read | — | — | A shared team space. |
authenticated_read |
read, create, update, delete | read | read | — | team, plus any signed-in principal (API keys) can read. |
public_read |
read, create, update, delete | read | — | read | team, plus anonymous visitors can read. |
Pass accessPreset on POST /api/v1/collections:
curl -X POST https://your-api.example.com/api/v1/collections \
-H "Authorization: Bearer sk-your-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "articles",
"accessPreset": "public_read",
"fields": [ ... ]
}'
If you omit accessPreset, the collection falls back to the collections.default_access setting (which itself defaults to private). An unrecognised value is coerced to private rather than failing open.
A preset only sets the starting grants. You can refine any collection afterwards from the Access panel (or POST /:name/access-control) — the preset is a convenience, not a lock.
#Testing access — Effective Access
To answer "what can this user / role / crew actually do?", open System → Access → Effective Access (or call GET /api/v1/access/simulate?userId=… / ?role=… / ?crewId=…). It evaluates the real permission engine and returns, per resource, the effective create/read/update/delete plus the reason (role grant and any row scope). It is read-only.
Crew and user nodes in the access tree also report how many items are shared with them, broken down by level, with expired shares excluded. Between the role grants and that count, everything a principal can reach is accounted for — before this, a share was access with no representation anywhere in the tool.
#Troubleshooting: "Why can (or can't) X access Y?"
When access surprises you, reach for the Access Inspector at System → Access — a read-only screen over the management API under /api/v1/access, which evaluates the same permission engine the gateway enforces, so its answers always match reality. Every endpoint (except the self-serve mode of /explain, below) is gated by the system/access-inspector permission: grant it only to people who should audit access. Reading takes read; the one write here — acknowledging a finding — takes update, so somebody trusted to look at the access picture cannot also silence what it reports.
The screen's six tabs map onto the endpoints below: Overview (findings and per-collection exposure), Explain, Who can…, Map (the access graph), Activity (recent access-control changes), and Effective Access (the simulator). Its sibling under System → Logs → Access answers the opposite question — not who can, but who did.
A practical debugging checklist:
- Start with
/explainfor the specific principal + resource that's misbehaving — it tells you which gate said no. - Widen with
/whoto see the blast radius: how many principals can already do this? - Scan
/findingsfor misconfigurations (public write, admin keys, crews with no admin, …). - Use
/summary,/tree, and/digestto map exposure, explore the graph, and see what changed recently.
| Endpoint | Answers |
|---|---|
GET /api/v1/access/explain?principal=&action=&resource= |
"Why can/can't this principal do this?" — a verdict plus a gate-by-gate trace. |
GET /api/v1/access/who?action=&resource= |
"Who can do this?" — counts of people and roles. |
GET /api/v1/access/summary |
The exposure of every collection at a glance. |
GET /api/v1/access/findings |
A lint pass flagging risky config (with POST …/findings/ack, which takes update, to mark intended ones). |
GET /api/v1/access/tree?node=<type>:<id> |
Lazily explore the access graph, one level at a time. |
GET /api/v1/access/digest?since= |
Recent access-control changes from the audit log. |
#GET /api/v1/access/explain — why can/can't this principal do this?
Returns a verdict plus a gate-by-gate trace: authn → rbac → crew_scope → row_rules. The failing gate is the reason. principal is one of user:<id>, role:<name>, apikey:<id>, or public; action is read / create / update / delete.
curl "https://your-api.example.com/api/v1/access/explain?principal=user:u_123&action=update&resource=collections/products" \
-H "Authorization: Bearer sk-your-api-key"
A principal that holds only read_self on system/access-inspector (rather than the full read) still gets a self-serve /explain — but the principal is locked to the caller and the trace is redacted, so it cannot probe anyone else's access or reveal policy structure.
#GET /api/v1/access/who — who can do this?
Returns counts of the people and roles that can perform an action on a resource (for a public grant, that's everyone). Use it to gauge the blast radius before or after a change.
curl "https://your-api.example.com/api/v1/access/who?action=delete&resource=collections/products" \
-H "Authorization: Bearer sk-your-api-key"
#GET /api/v1/access/summary — exposure of every collection
One row per collection: a visibility tier plus create/read/update/delete people counts. The fastest way to spot a collection that's more open than you thought.
curl "https://your-api.example.com/api/v1/access/summary" \
-H "Authorization: Bearer sk-your-api-key"
The tier and counts.<action>.everyone answer who — everyone: true means an
unbounded audience, because the public role holds the action and anonymous
callers are not a number you can count. Which records they reach is a
separate axis, and it is reported separately:
| Field | Means |
|---|---|
everyone: true |
Anyone may ask. Nothing about how much comes back. |
everyoneScoped: true |
Every public grant for that action carries a row condition — only matching records. |
everyoneScoped absent |
At least one grant is unconditional: every record. |
everyoneScoped is only ever set alongside everyone, and only for
read / update / delete — a create has no existing rows for a condition to
match, so the flag would mean nothing there. Its absence is the wider reading,
so a client that has never heard of the field keeps reporting the larger exposure.
In the dashboard this is the Public read · matching records badge.
#GET /api/v1/access/findings — lint the configuration
A lint pass that turns risky-looking facts into findings, each with a severity, a plain-English sentence, and a deep link into the inspector. Findings can be acknowledged as intended (POST /api/v1/access/findings/ack with { "key": …, "subject": … }) so they stop nagging.
| Rule key | Severity | Flags |
|---|---|---|
public_write |
critical | Anyone on the internet can create / update / delete a resource without signing in. |
api_key_admin |
critical | An API key holds super_admin/admin (or admin_access) — a leak grants admin. |
api_key_no_expiry |
warn | An API key has no expiry, so it stays valid until manually revoked. |
broad_delete |
warn | A non-admin role can delete a resource. |
crew_no_manager |
warn | A crew has no active manager — no one can manage its members or invitations. |
public_read |
info | Anyone on the internet can read a resource without signing in. The sentence says whether that is every record or only the ones a row condition matches. |
super_admin_count |
info | More accounts hold super_admin than the alert threshold (findings.super_admin_max, default 3). |
stale_invitation |
info | A crew invitation has been pending past the stale threshold (findings.invitation_stale_days, default 30). |
curl "https://your-api.example.com/api/v1/access/findings" \
-H "Authorization: Bearer sk-your-api-key"
#GET /api/v1/access/tree — explore the access graph
Returns one lazily-expanded level of the access graph (children, edge labels, and counts). Omit node for the root; then drill in with node=<type>:<id> where type is collection, role, user, crew, or apikey.
curl "https://your-api.example.com/api/v1/access/tree?node=role:editor" \
-H "Authorization: Bearer sk-your-api-key"
#GET /api/v1/access/digest — recent changes
Recent access-control events from the audit log (role, grant, key, policy, settings, and ban changes). Pass since=<unix> (defaults to the last 7 days) and an optional limit (max 200).
curl "https://your-api.example.com/api/v1/access/digest?since=1719792000" \
-H "Authorization: Bearer sk-your-api-key"
#Key rules
- Permissions are cached in KV. Changes take effect within seconds as the cache refreshes (the schema version is bumped on every access change).
super_adminhas full, unfiltered access across the workspace.- A collection a role can't read is hidden from its collection list, not just its records.
- Expanded relations respect the target collection's read permission. If a user can read
postsbut notusers, expandingauthorreturnsnull. - Soft-deleted records follow the same permissions.
- Two settings shape defaults across the workspace:
collections.default_access(the fallback access preset for new collections — see Collection presets, defaultprivate) andsecurity.crew_isolation(whether admins can manage crews they don't belong to — see Crew isolation modes, defaultadmin_manage).
#Gotchas
- Setting a role's scope to
noneremoves the collection from that role's view entirely — including the list. Give at least the roles that need it anallorownscope. ownscope only makes sense for authenticated users; thepublicrole has no identity to own anything, so give itnoneorall.- Column-level restrictions apply to API responses only — the data still exists in D1. If you must not store it, don't collect it.
- API keys follow their assigned role's permissions. Scope the key's role tightly at creation time.
#Allowlist: roles must be explicitly granted
The per-collection access map is an allowlist, and it interacts with role-level permissions in ways that surprise people. Both of these are true at once:
- A role absent from a collection's policy is denied — regardless of any
collections/<name>grant it holds elsewhere. Omission means "no access", not "unchanged". - A
run_as/automation role can end up with less access than an anonymous caller.public: allgrants only thepublicrole; an authenticated custom role does not inherit it. So a flow running asrole:notifiercan be denied a world-readable collection unlessnotifieris itself listed with a read scope.
Two consequences worth internalising:
public: alldoes not grant authenticated custom roles. Every role that needs access must appear in the policy with its own scope. If you tighten a collection and forget arun_asrole that reads it, its flows start failing withPermission denied: read on collection "<name>"— a runtime-only failure.- The Access panel and the Role editor are two wholesale-replace writers that clobber each other. Saving a collection's access-control regenerates every
collections/<name>permission row from that policy (replace by collection), dropping any grant the Role editor authored for a role the policy omits. Saving a role's permission set replaces that role's rows (replace by role), dropping acollections/<name>grant unless you re-include it. Manage a collection's per-role access from one of the two, not both.
Audit what's actually enforced. GET /api/v1/collections/:name/access-control returns the exact policy currently live (an unset policy reads back as the normalized default), so you never have to assume your last apply is still in force. The Access Inspector — GET /api/v1/access/{who,explain,simulate,findings} — answers "what can role X do on collection Y, and why" against the same permission engine.