Liking cljdoc? Tell your friends :D

Clojars Project

Roles and permissions

This simple library tries to fill in the gap between OAuth2 authorization and role-based access control.

Code has been separated from Cerber OAuth2 Provider implementation and published as optional add-on which hopefully makes scopes and roles easier to match.

Terminology

Terminology used in this doc bases on Apache Shiro: http://shiro.apache.org/terminology.html

Anatomy of Permission

Permission implemented by this library consists of two parts: a domain and list of comma-separated actions, both joined with colon, like user:read or user:read,write.

This imposes 3 additional cases:

  • wildcard action: any action on given domain is allowed, eg: user:*, or simply user
  • wildcard domain: given action on any domain is allowed, eg: *:write
  • wildcard permission: any action on any domain is allowed: *:*, or simply *

Anatomy of Role

Role is a collection of permissions. Technically, it is represented by a qualified keyword, eg. :user/default or :admin/all:

{:user/all      #{"user:read" "user:write"}
 :project/read  #{"project:read"}}

Roles may also map to wildcard actions and other roles (explicit- or wildcarded ones).

{:admin/all     "*"                          ;; maps to wildcard permission
 :admin/company #{:user/* :project/*}        ;; maps to other roles from user and project domains
 :project/all   #{"project:*" "timeline:*"}} ;; maps to wildcard-action permissions

Usage

Once permissions and roles are defined and bound together with carefully crafted mapping, how to make them showing up in a request?

A wrap-permissions middleware is an answer. It bases on a context set up by companion middleware - wrap-authorized exposed by Cerber API and populates subject's roles and permissions.

Let's walk through routes configuration based on popular Compojure to see how it works.

Cerber's OAuth2 routes go first:

(require '[cerber.handlers])

(defroutes oauth2-routes
  (GET  "/authorize" [] cerber.handlers/authorization-handler)
  (POST "/approve"   [] cerber.handlers/client-approve-handler)
  (GET  "/refuse"    [] cerber.handlers/client-refuse-handler)
  (POST "/token"     [] cerber.handlers/token-handler)
  (GET  "/login"     [] cerber.handlers/login-form-handler)
  (POST "/login"     [] cerber.handlers/login-submit-handler))

Routes that should have roles and permission populated go next:

(require '[cerber.oauth2.context :as ctx])

(defroutes user-routes
  (GET "/users/me" [] (fn [req]
                        {:status 200
                         :body {:client (::ctx/client req)
                                :user   (::ctx/user req)}})))

Now, the crucial step is to apply both wrap-authorized and wrap-permissions middlewares:

(require '[cerber.roles]
(require '[cerber.handlers]
(require '[compojure.core :refer [routes wrap-routes]]
(require '[ring.middleware.defaults :refer [api-defaults wrap-defaults]])

(defn api-routes
  [roles scopes->roles]
  (wrap-defaults
   (routes oauth2-routes (-> user-routes
                             (wrap-routes cerber.roles/wrap-permissions roles scopes->roles)
                             (wrap-routes cerber.handlers/wrap-authorized)))
   api-defaults))

Last step is to initialize routes with roles and scopes-to-roles mapping, here assuming that OAuth2 client may have any of resources:read, resources:write or resource:manage scopes assigned:

(def roles (cerber.roles/init-roles
             {;; admin can do everything with photos and comments
              :user/admin #{"photos:*" "comments:*"}
              
              ;; registered user can read and write to photos and comments
              :user/all #{"photos:read" "photos:write" "comments:read" "comments:write"}
              
              ;; unregistered user can only read photos and comments
              :user/unregistered #{"photos:read" "comments:read"}}))

(def scopes->roles {"resources:read"   #{:user/unregistered}
                    "resources:write"  #{:users/all}
                    "resources:manage" #{:user/admin}})

(def app-routes
  (routes (api-routes roles scopes->roles) oauth2-routes))

How it works?

Looking at example above it's clear that entire mechanism boils down to 3 elements:

  • roles, for performance reasons unrolled by init-roles to contain no nested entries.
  • scopes->roles map which says how to translate an OAuth2 client's scope into a set of roles.
  • a middleware which takes roles and scopes->roles and calculates corresponding roles/permissions.

One unknown is how middleware populates roles and permissions bearing in mind that two scenarios may happen:

  1. Request is a cookie-based user-originated one.

    In this scenario, subject initialized and stored in context by cerber's wrap-authorized middleware keeps its own roles and permissions calculated upon the roles.

  2. Request is a token-based client-originated one.

    In this scenario OAuth2 client requests on behalf of user with approved set of scopes. Scopes are translated into roles (based on scopes->roles mapping) and intersected with user's own roles. This is to avoid a situation where client's scopes may translate into roles exceeding user's own roles. Calculated permissions are also intersected with user's permissions to avoid potential elevation of priviledges.

API

(init-roles [roles-map])

Initializes roles-to-permissions mapping.

Initialized mapping has no longer nested roles (they get unrolled with corresponding permissions).

(make-permission str)

Builds a Permission based on string consisting of domain and actions, separated by colon, like "user:read,write". Permission may be exact one, have actions or domain (or both) wildcarded.

Wildcard is denoted by "*", and means any, so "document:*" permission can be read as any action on document.

(implied-by? [permission permissions])

Returns resource permission if it's implied (has access to) by the set of permissions. Returns falsey otherwise.

(has-role? [subject role])

Returns matching role if it's been found in subject's set of :roles. Returns falsey otherwise.

(has-permission [subject permission])

Returns resource permission if it's implied by subject's set of :permissions. Returns falsey otherwise.

(intersect-permissions [coll1 coll2])

Intersects 2 sets of permissions calculating their common domains and actions. For example, intersection of following permissions:

 [\"*:read,write\"] and [\"doc:read,create\"]

results in ["doc:read"].

(roles->permissions [roles mapping])

Returns set of permissions based on collection of roles and mapping returned by init-roles function.

(def subject {:roles #{:user/read :user/write}
              :permissions #{(make-permission "project:read")
                             (make-permission "contacts:*")}}

(has-permission subject "contacts:write"))
(has-permission subject "contacts:read,write"))

(has-role user :user/write)

(implied-by? "document:read" 
             (intersect-permissions [(make-permission "document:read,write")
                                     (make-permission "workspace:create")
                                     (make-permission "document:delete,create")]
                                    [(make-permission "document:read,write")]))

Set up for local development

This library uses clojure deps and revolt to set up comfortable local environment:

    clj -A:dev -p nrepl,watch,rebel

...and connect to the REPL.

License

Eclipse Public License - v 2.0

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close