Role-Based Access Control (RBAC)
ShipClojure implements defence-in-depth role-based access control that spans both the frontend and backend. Roles are defined declaratively on routes and CQRS commands/queries, then enforced automatically at multiple layers.
Overview
Enforcement Layers
| Layer | Where | What happens |
|---|---|---|
| Route metadata | routes.cljc | :auth/roles declares which roles can access a route |
| Page rendering | pages.cljc | Renders a "Forbidden" page if the user's role isn't allowed |
| Sidebar filtering | sidebar.cljc | Hides navigation items the user can't access |
| CQRS middleware | cqrs.clj | Returns 403 if the user's role doesn't match :command/required-roles or :query/required-roles |
| Handler-level checks | organisation/handlers.clj | Per-handler permission logic (e.g., user-can-manage-org?) for finer-grained control |
Role Hierarchy
Membership roles are defined as Datomic enum idents:
:membership.role/owner → Full control (can delete org, manage all members)
:membership.role/admin → Can manage members and settings
:membership.role/member → Standard access (read-only for management features)
There is no implicit hierarchy — each layer checks for explicit set membership. If a route requires #{:membership.role/owner :membership.role/admin}, a :membership.role/member is denied.
How the User's Role Reaches the Frontend
- Backend:
get-accountquery includes:user-account/roleby callingorg/get-user-roleon the authenticated user - Frontend state: The account response is transacted into DataScript at
[:db/ident :saas.auth/values]via the::persist-accountaction - Subscription:
subs/user-rolereads:user-account/rolefrom that entity
;; subs.cljc
(defn user-role [db]
(:user-account/role (ds/entity db [:db/ident :saas.auth/values])))
Restricting a Route (Frontend)
1. Add :auth/roles to the Route
In src/cljc/saas/routes.cljc, add the :auth/roles key to the route data with a set of allowed roles:
["/settings"
["/organisation" {:name ::organisation-settings
:label "Organisation"
:auth/required? true
:auth/roles #{:membership.role/owner :membership.role/admin}}]]
2. How It's Enforced
The routes/required-roles function looks up the :auth/roles set for any route name:
(routes/required-roles ::routes/organisation-settings)
;; => #{:membership.role/owner :membership.role/admin}
(routes/required-roles ::routes/profile-settings)
;; => nil (unrestricted)
In pages.cljc, get-render-f checks this before rendering:
(defn get-render-f [{:keys [db]}]
(let [route-name (:route/name (ds/entity db [:route/id :current-route]))
required (routes/required-roles route-name)]
(if (and required (not (role-allowed? required (subs/user-role db))))
forbidden ;; renders the "Access Denied" page
(or (get-in by-route-name [route-name :route/render])
not-found))))
If the user lacks the required role, they see the Forbidden page — an "Access Denied" screen with a link back to the dashboard.
3. Sidebar Filtering
The dashboard sidebar automatically hides items that link to role-restricted routes. This happens in sidebar.cljc:
;; In render-sidebar:
(let [user-role (subs/user-role db)
all-items (or (::sidebar-items state) (:dashboard/sidebar-items c/config))
sidebar-items (filter-items-by-role user-role all-items)
...]
...)
filter-items-by-role walks the sidebar item tree recursively:
- Leaf items with a keyword
:urlare checked againstroutes/required-roles - Parent items whose sub-items are all filtered out are also removed
- Items with string URLs, no URL (labels/titles), or unrestricted routes always pass through
This means adding :auth/roles to a route automatically hides it from the sidebar for users without the required role — no sidebar configuration changes needed.
Restricting a Command or Query (Backend)
1. Add :command/required-roles or :query/required-roles
In src/clj/saas/web/cqrs.clj, add the required roles to the command or query definition:
{:command/kind :command/delete-organisation
:command/description "Delete the current organisation"
:command/requires-authentication? true
:command/required-roles #{:membership.role/owner}
:command/parameters [:map [:organisation/id :uuid]]
:command/handler org-handlers/delete-organisation}
{:query/kind :query/get-audit-log
:query/description "Get the organisation's audit log"
:query/requires-authentication? true
:query/required-roles #{:membership.role/owner :membership.role/admin}
:query/parameters [:map]
:query/handler org-handlers/get-audit-log}
2. How It's Enforced
The CQRS validation middleware (wrap-command-validation / wrap-query-validation) runs check-required-roles! after verifying authentication:
(defn- check-required-roles! [req required-roles]
(when (seq required-roles)
(let [db (get-in req [:nexus/state :db])
user-id (some-> req :claims :sub u/uuid)
role (when (and db user-id) (org/get-user-role db user-id))]
(when-not (contains? required-roles role)
(http-response/forbidden! {:message "Insufficient role permissions"
:cause :authorization/insufficient-role})))))
The check:
- Resolves the user's role from Datomic via
org/get-user-role - If the role isn't in the required set, throws a 403 Forbidden response
- If
:required-rolesis nil or empty, the check is skipped (no restriction)
Middleware Execution Order
For both commands and queries, the validation middleware runs checks in this order:
- Command/query exists — 404 if not found
- Authentication — 401 if
:requires-authentication?is true and no claims - Role check — 403 if
:required-rolesis set and user's role doesn't match - Schema validation — 400 if params don't conform to
:parametersschema
Adding Role Restrictions: Complete Checklist
When restricting a feature by role, you may need changes at multiple layers:
Frontend-only restriction (hide UI, show forbidden page)
- Add
:auth/roles #{...}to the route inroutes.cljc - Done — sidebar filtering and forbidden page are automatic
Backend-only restriction (protect an API endpoint)
- Add
:command/required-rolesor:query/required-rolesto the command/query incqrs.clj - Done — middleware handles the 403 response
Full-stack restriction (recommended for sensitive features)
- Add
:auth/rolesto the route inroutes.cljc(frontend) - Add
:command/required-roles/:query/required-rolesto the command/query incqrs.clj(backend) - Optionally add handler-level checks for finer-grained logic (e.g., "can't remove the last owner")
Handler-level checks (for complex business rules)
Some operations need more nuance than set membership. For example, remove-member checks not just that the user is an admin/owner, but also that they're not removing the last owner. These checks live in the handler itself:
(cond
(not (org/user-can-manage-org? db user-id))
[[:http-response/bad-request
{:message "Only owners and administrators can remove members"
:cause :remove-member/insufficient-permissions}]]
(and is-owner? (<= owner-count 1))
[[:http-response/bad-request
{:message "Cannot remove the last owner of an organisation"
:cause :remove-member/last-owner}]]
:else
[[:db/transact [[:db.fn/retractEntity (:db/id membership)]]]
[:http/respond {:status 200 :body {:message "Member removed successfully"}}]])
Testing
Testing frontend page gating
;; test/cljc/saas/spa/pages_test.cljc
(deftest get-render-f-role-restricted-routes-test
(testing "Returns forbidden for role-restricted route when user is a regular member"
(let [f (pages/get-render-f {:db (db-with-route-and-role
::routes/organisation-settings
:membership.role/member)})]
(is (= forbidden f))))
(testing "Returns page render fn when user is admin"
(let [f (pages/get-render-f {:db (db-with-route-and-role
::routes/organisation-settings
:membership.role/admin)})]
(is (not= forbidden f)))))
Testing sidebar filtering
;; test/cljc/saas/ui/components/sidebar_test.cljc
(deftest filter-items-by-role-restricted-routes-test
(testing "Restricted route is hidden for a member"
(let [items [{:id :org :label "Organisation" :url ::routes/organisation-settings}]
result (sidebar/filter-items-by-role :membership.role/member items)]
(is (= [] result))))
(testing "Restricted route is visible for an admin"
(let [items [{:id :org :label "Organisation" :url ::routes/organisation-settings}]
result (sidebar/filter-items-by-role :membership.role/admin items)]
(is (= 1 (count result))))))
Testing CQRS role enforcement
;; test/clj/saas/web/cqrs_test.clj
(deftest check-required-roles-test
(testing "Throws 403 when user role doesn't match required roles"
(let [req (tu/unit-req {:txs [user-tx org-tx]
:claims {:sub user-id}})]
(is (thrown-with-msg? ExceptionInfo #"Insufficient role"
(check-required-roles! req #{:membership.role/owner}))))))
Related Documentation
- Organisations — schema, partitions, member management
- CQRS — command/query system and middleware
- UI Pages — creating pages with route restrictions
- Authentication — token flow and user identity