Repository navigation
Replies: 4 comments
|
Thanks for putting this together. I’m building a paid-content plugin with an editor panel and a few admin pages, so I have a couple of thoughts from that side. I agree with keeping public members out of scope. In my plugin, a reader’s access follows their Stripe subscription and is checked on each read. There’s no role to assign. Keeping reader accounts separate from staff roles makes sense to me. I have two questions about plugin editor panels and collection-scoped roles. My plugin adds a “Paid access” panel to the entry editor, where you set who can read the post. It saves through a plugin route, and I’d want whoever can publish that post to be able to set its access rule.
Both requests would use existing core permissions. I agree with leaving plugin-defined permissions out of scope. |
|
Design PR #4156 proposes |
|
Thanks for putting together such a thoughtful proposal. I'm evaluating Emdash for a multilingual knowledge and learning platform, and custom roles with collection-scoped permissions would address a lot of what we'd need. I had a few questions about possible future extensions. Apologies if I've missed something in the RFC or if any of these would conflict with its design.
I noticed the proposal currently assumes one role per user, with additive roles listed as an alternative under the open questions. Would it make sense to support multiple roles, or at least leave the data model open to that possibility? For example, someone might be a translator for one set of collections, an editor for another, and a publisher for a third. If each combination requires a separate custom role, I wonder whether this could lead to quite a few roles over time. WordPress, for comparison, supports multiple roles per user internally, as well as direct user capabilities. I'm not necessarily suggesting that Emdash needs both, but additive roles seem like they might be useful.
I saw this mentioned under future possibilities, and it would be particularly valuable for multilingual publishing. For example, a translator might be allowed to edit Spanish translations of articles and lessons without being able to modify the English originals or publish their own changes. Would the proposed collection-scoped permission model be reasonably extensible to include a locale constraint later, or would that require a more substantial redesign?
I also noticed the open question about restricting contributors to their own drafts. That seems important if contributors aren't all part of the same trusted editorial team. Ideally, an author could see their own drafts, while an assigned editor or reviewer could access the drafts they're responsible for. I realize this overlaps with the separate co-owners proposal and potentially a more comprehensive editorial workflow. I'm not sure how much belongs in RBAC itself, but it would be good if the current design could accommodate that kind of access later.
I'm also wondering about custom permissions defined by plugins. For example, a course-management plugin might introduce actions like reordering lessons, assigning reviewers, or approving translations, which don't necessarily map neatly to existing core permissions. Would there eventually be a way for plugins to register their own namespaced permissions and have them appear in the same role-management interface? Related to this, I noticed the discussion about collection-scoped permissions on plugin routes and editor panels. That seems important if plugins are expected to extend Emdash's content-editing capabilities. I certainly wouldn't want any of these considerations to hold up the initial implementation. The proposed collection-level RBAC would already be a substantial improvement. I'm mainly curious whether these scenarios fit naturally into the proposed architecture, even if they are addressed in separate RFCs later. Thanks again for the work on this! |
Uh oh!
There was an error while loading. Please reload this page.
Summary
This RFC proposes custom roles: a site defines its own roles from the existing permissions and can limit a role's content permissions to chosen collections. It develops #824, which asks for "user A can edit posts in collection X but not Y" and proposes a tuple-based model. It also replaces the per-collection
min_roleI suggested in a comment there. The five built-in roles, their levels and every permission name stay as they are. On a site that defines no custom role, the built-in roles hold the permissions they hold in EmDash 1.0 and no request issues an additional query.The work has three phases:
Public member accounts, multi-tenancy, field-level permissions, plugin-defined permissions and approval workflows are out of scope.
Example
Every name and shape in this section is illustrative.
A site has
posts,pagesandjobscollections. One person maintains the job postings and should edit and publish entries injobsand nothing else. The seed file declares a role for that person:{ "roles": [ { "slug": "jobs-editor", "label": "Jobs editor", "permissions": ["media:read", "media:upload", "taxonomies:read"], "collections": { "jobs": [ "content:read", "content:read_drafts", "content:create", "content:edit_any", "content:delete_any", "content:publish_any" ] } } ] }A user with this role gets these results:
POST /_emdash/api/content/jobsPOST /_emdash/api/content/jobs/{id}/publishGET /_emdash/api/content/postsPUT /_emdash/api/content/posts/{id}content_updatewithcollection: "posts"POST /_emdash/api/menusThe admin sidebar lists Jobs as the only collection for this user.
The admin learns what to show from
GET /_emdash/api/auth/me. In EmDash 1.0 that person would be an Editor, and the endpoint returns the role number:{ "id": "01JZ3W9Q0V5T8K2M4N6P7R8S9T", "email": "sam@example.com", "name": "Sam", "role": 40, "avatarUrl": null, "isFirstLogin": false }With the
jobs-editorrole the response also carries the effective permissions:{ "id": "01JZ3W9Q0V5T8K2M4N6P7R8S9T", "email": "sam@example.com", "name": "Sam", "role": 10, "roleSlug": "jobs-editor", "permissions": ["media:read", "media:upload", "taxonomies:read"], "collections": { "jobs": [ "content:read", "content:read_drafts", "content:create", "content:edit_own", "content:edit_any", "content:delete_own", "content:delete_any", "content:publish_own", "content:publish_any" ] }, "avatarUrl": null, "isFirstLogin": false }rolestays in the response and holds the compatibility level described under Compatibility level in the Detailed Design.On the server, the content update route in EmDash 1.0 checks ownership and permission:
With this RFC the same helper also receives the collection:
#2332 asks for a role equal to Subscriber plus editing byline information. As a custom role, that is the Subscriber permissions and one more:
{ "slug": "byline-editor", "label": "Byline editor", "permissions": [ "content:read", "media:read", "taxonomies:read", "comments:read", "menus:read", "bylines:read", "widgets:read", "sections:read", "search:read", "auth:manage_own_credentials", "bylines:manage" ] }Background & Motivation
Four places compare the role number instead of using the permission map
packages/auth/src/rbac.tsmaps 50 permission names to a minimum role level, and the check isuser.role >= Permissions[permission].AGENTS.mdstates that authorization is permission-based and that this map is authoritative. Most REST routes follow that rule, but four areas do not:Role.ADMINdirectly, 19 checks in 12 route files. Eight permissions in the map are never checked by core,users:read,users:inviteandusers:manageamong them.requireRolewith a role level at 53 call sites, beside the permission names that the matching REST routes use. The two can drift apart, and fix(core): require edit permission on the source for the MCP content_duplicate tool #3433 fixed one such case incontent_duplicate.user.role >= 30, and the client toolbar script makes the same comparison on the role number from/auth/me.GET /_emdash/api/auth/mereturns the role number and no permissions, and the admin package does not depend on@emdash-cms/auth. Instead, it declares 25 numeric role constants in 13 files and decides what to show by comparing against them.Several open reports follow from the admin and the server deciding separately:
plugins:manage, so an Editor gets 403 and the admin shows "No forms yet" instead of a permission error #3033: plugin pages are listed for every role, while a plugin route without apermissionrequiresplugins:manage.The Contributor role shows the same split. On a 1.0.1 site a Contributor creates an entry (201) and is refused on the next save (403), while the editor shows Save, Publish now and Schedule. The map grants
content:createfrom Contributor andcontent:edit_ownfrom Author. The authentication guide describes the role as "Create content (needs approval to publish)".What a ladder of five levels cannot express
Each level holds every permission of the levels below it, and every content permission applies to every collection. Two kinds of request fall outside that:
For the person in the Example who should edit only the job postings, the built-in roles alone offer two answers: a role that can edit every collection, or a second site.
#2358 reports the same limit for tokens: a token that can load the admin needs the
adminscope, and no step exists between read scopes and full access.What any change has to keep
The role integer and the permission names are public surface in 1.x:
locals.user.role, asctx.user.rolein plugin routes, asactor.rolein content hooks, in the role options of the Cloudflare Access and Atmosphere providers (roleMapping,defaultRole), and asminRoleon plugin editor panels and list columns.toRoleLevelthrows on any value other than the five levels, and the auth adapter calls it for every user row it loads. A deployed version cannot load a user whoserolecolumn holds a sixth value.So the design only adds: it renames, renumbers and removes nothing.
Goals
Non-Goals
users. This RFC covers the people who work in the admin.Prior Art
Content management systems follow four approaches to authorization:
EmDash keeps its schema in the database and runs plugins in a sandbox, so the second approach fits: a role is data, and a check reads a set. Drupal and Craft generate permission names per content type or section (
edit any $type_id contentin NodePermissions.php,saveEntries:<section uid>in UserPermissions.php). This RFC reaches the same granularity with a collection scope on a grant, so the permission names that plugins already reference stay as they are.Field-level read restrictions have a recurring failure: a restricted field leaks through filtering, sorting or relation traversal. Strapi GHSA-rjg2-95x7-8qmx (CVE-2026-27886) and Payload GHSA-9g87-32v6-3c2r and GHSA-fpww-c55p-cjv6 are instances. In Directus GHSA-97xr-jchp-xm3c, clients gained unrestricted item access after a failed authentication message. The check in this RFC denies whenever there is no actor.
When Ghost needed a role that cannot publish, it added Contributor and left Author unchanged for backwards compatibility (TryGhost/Ghost#9314). The same rule applies here: no built-in role is narrowed.
Detailed Design
This section describes the design at interface level. Field-by-field definitions belong in the RFC document that follows an accepted Discussion.
Vocabulary
rbac.ts, such ascontent:edit_own. Core owns the set. Existing names do not change, and new names are additions.content:*permission applies to all collections or to a list of collection slugs.Check
One function decides, sketched as
can(actor, permission, resource?), whereresourcecarries an optionalcollectionand an optionalownerId.hasPermission,requirePerm,requireOwnerPermandrequireAnyPermstay and call it._anypermission includes the matching_ownpermission. The built-in ladder guarantees this. A custom role is a free set, andcanActOnOwnasks for the_ownpermission when the actor owns the item, so the check adds_ownwherever_anyis granted.Roles and storage
A custom role is a row in a new system table. Users, invites and allowed signup domains gain a nullable reference to it.
id,slug,label,descriptiongrantsusers.role_idNULLmeans the built-in role inusers.role, which is the state of every existing user.users.roleA user has one role. Someone who needs what two roles grant gets a third role that combines them.
Anonymous requests load no user and therefore no role. Built-in roles resolve from the map in memory. The row of a custom role has to arrive without an extra round trip, and Unresolved Questions names two ways to do that.
Compatibility level
Code that reads the integer keeps working: third-party plugins,
minRoleon plugin panels, and a version deployed before the migration. For a user with a custom role,users.roleholds the highest built-in level whose permissions the custom role fully contains for all collections. A custom role that contains no built-in role is stored as Subscriber, the lowest of the five levels.menus:managebyline-editorfrom the examplejobs-editorfrom the exampleCode that reads only the integer therefore never grants more than the role holds, with one exception. A role narrower than Subscriber, such as
jobs-editor, appears to that code as a Subscriber, which can read published content in every collection.Effective permissions in the admin
/auth/mereturns the actor's unscoped permissions and its per-collection permissions, as in the example. The admin gets one hook, sketched asuseCan(permission, resource?), which replaces the numeric comparisons in the sidebar, the command palette, page-level checks and action buttons. The server stays the security boundary; the payload only keeps the admin from offering what the server will refuse.Content routes, lists and MCP
Content routes run their check in the route file, where the collection is already a URL parameter, so the check takes it from there. The revision routes and bulk tagging have no collection in the URL and take it from the loaded revision or from each item.
Collection scope needs no row filter. A list request for a collection that the role may not read is refused. Reads that cross collections, such as search, the calendar and the relation routes, apply the scope to each collection they touch.
MCP tools call the same check with the same permission names as their REST counterparts and pass the tool's
collectionargument.requireRolegoes away. Where a tool's role level differs from the REST permission in EmDash 1.0, phase 1 settles each difference on its own, with the REST permission as the default.media_createis one: the tool asks for Author, a level that the AI tools guide and the MCP server reference document, and the REST route that confirms the same upload asks formedia:upload, which a Contributor holds.Plugins
Nothing changes in the plugin contract. A route names a core permission and defaults to
plugins:manage. The dispatcher passes the actor, so a custom role that holds the named permission can call the route.ctx.user.roleandactor.rolecarry the compatibility level.Plugin pages become visible by permission. A Block Kit page loads through the plugin's
adminroute, so the admin can show the page when the actor holds that route's permission. #3034 raised the problem and weighs an explicit field on the page against deriving visibility from the route. A role level says nothing about a custom role, so either choice has to be stated as a permission name.Tokens
The rule stays: effective access is the token's scopes intersected with the role's permissions.
scopesForRolederives the allowed scopes from the actor's grants and no longer from a minimum level, and has to yield the same result for built-in roles. Clamping at issuance and at refresh stays.Identity providers
roleMappinganddefaultRolein the Cloudflare Access configuration anddefaultRolein the Atmosphere configuration accept a role slug in addition to a number.Managing roles
/_emdash/api/admin/roleslist, create, update and delete custom roles.emdash role, lists, creates, updates and deletes custom roles. Likeemdash menuandemdash taxonomy, it goes through the REST API.rolessection, so a site can keep its roles in version control.emdash export-seedwrites it.users:read,users:inviteorusers:manage.Security Model
users.role. With the compatibility level it grants at most the built-in role the custom role contains, or Subscriber. This covers a rolling deploy and a rollback.content:read_draftskeep receiving 404 for unpublished entries. The empty-owner rule, the last-admin guard, the ban on changing one's own role and scope clamping at token refresh stay.Testing Strategy
role >= Permissions[permission]returns. This pins the check itself to the current map for the built-in roles.users.rolenever grants more than the custom role, apart from the Subscriber floor.Drawbacks
minRolefrom custom roles that hold the underlying permissions.Alternatives
One minimum role per collection
I proposed this in a comment on #824: a nullable
min_roleon the collection, checked next to the permission. It needs one column and no new concept, but it is one-dimensional. It can raise a collection above a level, which separates one group from the rest. Whoever reaches the higher threshold also passes the lower one, though, so it cannot express two departments that must not edit each other's collections. It does not cover #2332 either. This RFC replaces that proposal.Built-in role plus a collection list
A user keeps a built-in level and gains a list of collections. This keeps the order total and is smaller than roles as data. However, it covers the collection cases and not #2332, and the first request for "Editor without one permission" would need a second migration.
Generated permission names per collection
Generated names such as
edit any article contentwould work as they do in Drupal and Craft. In EmDash, however, the permission names are a closed type that installed plugins reference, so generated names would change a public contract. A scope on the grant reaches the same result.Access functions in site code
Payload and Keystone let site code decide each operation. EmDash collections are created at runtime in the admin, and roles need to be managed there too. Rules written in site code cannot be managed from the admin.
Veto hooks in a site plugin
content:beforeSaveandcontent:beforePublishreceive the actor and the collection, so a site plugin can already refuse saves and publications per user and collection. That covers writes only. Lists, reads and the admin still show every collection to the role, and deletion, restore and discarding a draft run no hook that both knows the actor and can refuse. The rule also lives in plugin code, outside the roles an Admin manages.Policies with filters, and relationship tuples
Filter-based policies and the tuple model that #824 opens with can express row-level and delegated access. Both decide per row, so every query that reads content has to carry their constraint. The custom-role and collection requests under Background do not need that. The delegation case in #824 does, and this RFC leaves it open.
Adoption Strategy
role_idisNULL.NULLrole reference as a built-in role.permissionon each route, as the plugin documentation already describes, and avoid comparingctx.user.role.Implementation Plan
/auth/me.useCanin the admin. Plugin page visibility by permission. The equivalence test.Each phase is a separate set of pull requests, and phase 1 can be accepted without the others. I can implement phases 1 and 2.
Unresolved Questions
content:read_draftssees all drafts, revisions and trashed entries of a collection. A role for external users, such as the one Feature Request: Declarative Admin Sidebar Navigation (Grouping & Role Guards) #2317 describes, may need to see only its own entries. A sketch:content:read_draftskeeps meaning all drafts, and an additional permission limits list and get to entries whoseauthor_idis the actor. The column is indexed, and the filter is one predicate. It touches list queries, so it is left out of phases 1 to 3.create,updateanddeleteshould be separate for more resources than content, that is an additive change to the map that custom roles would use directly.plugins:manage,schema:manage,settings:manage,backups:manageand the transfer permissions each need a decision on whether holding them amounts to full control of the site.jobs-editor, gets no inline editing. Whether edit mode should ask for a permission, and for which collections, is open.Future Possibilities
mcp:tools:<pluginId>token scope.All reactions