Security

Permissions & site-sync

Role-based permissions on the admin/MCP surfaces, and how published content reaches your site.

Role-based permissions

The administrative and MCP surfaces gate every operation with a role-based permission on top of the collections entitlement. Permissions are injected by the gateway from the signed-in session (via the x-user-permissions header) and enforced by the collections worker:

Permission Grants
collections:read View collections and entries
collections:write Create, update, delete, reorder, bulk-delete collections and entries
collections:entries.publish Publish / unpublish entries

Publish/unpublish is kept as a separate permission from write on purpose: an editor role can author and edit content without being able to make it live. The same guard applies whether the mutation arrives over REST (dashboard) or MCP (agents) — both go through the identical middleware and service layer, so an agent is bound by the same rules as a human.

  • Missing permission → 403 Forbidden
  • Missing tenant context → 400 Bad Request

Permissions map

Operation Permission REST route MCP tool
List / get collections collections:read GET /admin/collections… list_collections, get_collection
List / get entries collections:read GET /admin/collections/:id/entries (list), GET /admin/entries/:id (get) list_entries, get_entry
Create / update / delete collection collections:write POST/PUT/DELETE /admin/collections… create_collection, update_collection, delete_collection
Create / update / delete entry collections:write POST/PUT/DELETE /admin/entries… create_entry, update_entry, delete_entry
Reorder / bulk delete collections:write PATCH /admin/entries/reorder, DELETE /admin/entries/bulk reorder_entries, bulk_delete_entries
Upload image/file collections:write POST /admin/uploads
Publish / unpublish collections:entries.publish POST /admin/entries/:id/publish | /unpublish publish_entry, unpublish_entry

Site-sync

When published content changes, collections notifies site-sync so your static site can be rebuilt. This is automatic and fire-and-forget: a site-sync failure never fails the publish that triggered it.

How it avoids wasted rebuilds:

  1. One interception point. A middleware notifies site-sync after any successful write to /admin/* or /mcp/* — so a newly added route can't be forgotten. Batch operations emit exactly one notification per request, no matter how many rows they touch.
  2. Cost-free over-notification. It fires even on publicly-invisible changes (e.g. saving a draft). That's safe because site-sync independently recomputes the tenant's content version before spending a build.
  3. Content-version check. GET /internal/content-version?tenantId=… (internal, service-binding only) returns a digest of everything the tenant currently serves publicly: the count and max-updated_at of published entries and active collections. Unpublishing or deleting removes a row without moving the max timestamp, so counting matters as much as the timestamp — otherwise "content came down but the site still shows it" would be missed.

Net effect: editing a draft, or publishing then immediately unpublishing, produces no rebuild; nothing is wasted. Genuinely new public content triggers a rebuild.

The internal mesh

Three classes of traffic reach the collections worker, and each is distinguished deliberately:

  • Public (/c/*) — gateway-key only; the gateway injects x-gateway-key. No JWT/tenant; the collection id is the credential.
  • Admin + MCP (/admin/*, /mcp/*) — gateway-key plus tenant + role permissions.
  • Internal (/internal/*) — worker-to-worker, authenticated with a service token that is service-specific: the collections worker calls two different internal services and uses a credential for each that would not work on the other.

NOTE

In local development or any environment where site-sync isn't bound, notifications are simply skipped — an unbound service is a valid state, not an error.

Collections · SecurityEdit on GitHub