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

LayerWhereWhat happens
Route metadataroutes.cljc:auth/roles declares which roles can access a route
Page renderingpages.cljcRenders a "Forbidden" page if the user's role isn't allowed
Sidebar filteringsidebar.cljcHides navigation items the user can't access
CQRS middlewarecqrs.cljReturns 403 if the user's role doesn't match :command/required-roles or :query/required-roles
Handler-level checksorganisation/handlers.cljPer-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

  1. Backend: get-account query includes :user-account/role by calling org/get-user-role on the authenticated user
  2. Frontend state: The account response is transacted into DataScript at [:db/ident :saas.auth/values] via the ::persist-account action
  3. Subscription: subs/user-role reads :user-account/role from 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 :url are checked against routes/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:

  1. Resolves the user's role from Datomic via org/get-user-role
  2. If the role isn't in the required set, throws a 403 Forbidden response
  3. If :required-roles is 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:

  1. Command/query exists — 404 if not found
  2. Authentication — 401 if :requires-authentication? is true and no claims
  3. Role check — 403 if :required-roles is set and user's role doesn't match
  4. Schema validation — 400 if params don't conform to :parameters schema

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)

  1. Add :auth/roles #{...} to the route in routes.cljc
  2. Done — sidebar filtering and forbidden page are automatic

Backend-only restriction (protect an API endpoint)

  1. Add :command/required-roles or :query/required-roles to the command/query in cqrs.clj
  2. Done — middleware handles the 403 response

Full-stack restriction (recommended for sensitive features)

  1. Add :auth/roles to the route in routes.cljc (frontend)
  2. Add :command/required-roles / :query/required-roles to the command/query in cqrs.clj (backend)
  3. 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}))))))
  • Organisations — schema, partitions, member management
  • CQRS — command/query system and middleware
  • UI Pages — creating pages with route restrictions
  • Authentication — token flow and user identity