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:

  1. Role (RBAC)what a role may do to a resource (create / read / update / delete / transfer). This is the ceiling, and nothing below raises it.
  2. Reachwhich records that applies to: all of them, or only the ones the caller owns.
  3. Ownership — what "their own" means. Since ownership can be handed over it is owned_by, falling back to created_by for a record nobody has moved. See Ownership.
  4. Shares — one record handed to a person, a crew, or everyone. Adds records to someone's reach; never adds an action.
  5. Visibility rules — a standing condition on the collection: anyone may view records where status is published. Adds records for a whole audience.
  6. 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:

  1. The request is authenticated (session token or API key).
  2. The user's role is resolved.
  3. The collection's permission for the action is loaded (KV-cached).
  4. A row-level WHERE clause is injected from the permission's item_filter.
  5. The query executes against D1.
  6. 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_admin short-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 a crew_column, or the legacy mode: "crew_scoped") is answered with 400 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 }
    }
  }'

sharing is 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 — and off REVOKES every share the collection already has.

Everything else here IS a wholesale replace: a role missing from roles loses its grants. Read the current config with GET /:name/access-control and send it back with your changes.

PUT /:name/policy is 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-control is the only way to write a collection's access config. Translate a legacy body by naming the roles directly: read.allow becomes each role's access (all, own or none), and write.allow anything other than none becomes write: true on editor.

GET /:name/policy is 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_admin still 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 public or api can read it is the WHO axis, and no button about row scope will decide it for you.
  • They only set editor and viewer. 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.

Note

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:

  1. Start with /explain for the specific principal + resource that's misbehaving — it tells you which gate said no.
  2. Widen with /who to see the blast radius: how many principals can already do this?
  3. Scan /findings for misconfigurations (public write, admin keys, crews with no admin, …).
  4. Use /summary, /tree, and /digest to 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: authnrbaccrew_scoperow_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"
Note

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 whoeveryone: 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_admin has 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 posts but not users, expanding author returns null.
  • 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, default private) and security.crew_isolation (whether admins can manage crews they don't belong to — see Crew isolation modes, default admin_manage).

#Gotchas

  • Setting a role's scope to none removes the collection from that role's view entirely — including the list. Give at least the roles that need it an all or own scope.
  • own scope only makes sense for authenticated users; the public role has no identity to own anything, so give it none or all.
  • 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: all grants only the public role; an authenticated custom role does not inherit it. So a flow running as role:notifier can be denied a world-readable collection unless notifier is itself listed with a read scope.

Two consequences worth internalising:

  1. public: all does 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 a run_as role that reads it, its flows start failing with Permission denied: read on collection "<name>" — a runtime-only failure.
  2. 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 a collections/<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.