Skip to content
This repository has been archived by the owner on Mar 8, 2021. It is now read-only.

proposal: get user's roles based on current session #80

Open
sritchie opened this issue Nov 13, 2013 · 8 comments
Open

proposal: get user's roles based on current session #80

sritchie opened this issue Nov 13, 2013 · 8 comments

Comments

@sritchie
Copy link

Hey Chas,

I'm trying to figure out how to write an app (paddleguru.com) that defines roles based on the particular resource being requested. I might be the admin of race A, but not race B.

I think the easiest way to make this work would be to allow :roles to be a function of the request. Alternatively, with the current code, I can bind the request dynamically and access it from inside the no-arg function.

Do you have a suggestion for a different way to tackle this case?

@cemerick
Copy link
Owner

What you're describing are effectively ACLs, for which Friend doesn't directly provide any support (largely because the particulars are often so different from application to application that I've not yet thought it reasonable to add an implementation of them).

You can certainly build this functionality by e.g. putting all of your authorization checks into one piece of middleware that fronts the HTTP resources that need this fine-grained access control. This middleware can then throw authorization failure exceptions (see friend/throw-unauthorized) when appropriate, which will bounce authenticated users to your 403 page, or prompt a login, etc. In general, :roles wouldn't be involved, since there's no real correspondence between e.g. ::role-a and race A (unless you want to have a role per race, which seems fairly wrong).

I hope that helps?

@sritchie
Copy link
Author

Yeah, that sounds like it makes sense. Here are the helper functions I came up with:

(ns paddleguru.util.friend
  "Helper utilities for the friend authorization and authentication
library, by cemerick."
  (:refer-clojure :exclude (identity))
  (:require [paddleguru.models.user :as user]
            [cemerick.friend :as friend :refer [identity
                                                current-authentication
                                                throw-unauthorized]]))

;; ## Route Building Helpers
;;
;; The following functions make it easy to build routes with custom
;; friend validations.

(defn roles
  "Returns an authorization function that checks if the authenticated
  user has the specified roles. (This is the usual friend behavior.)"
  [roles]
  (fn [id]
    (friend/authorized? roles id)))

(defn unauthorized!
  "Throws the proper unauthorized! slingshot error if authentication
  fails. This error is picked up upstream by friend's middleware."
  [handler req]
  (throw-unauthorized (identity req)
                      {::wrapped-handler handler}))

(defn wrap-authorize
  "Custom ring middleware to help with friend authorization. Takes a
  handler and a predicate of one argument (the request), and only
  allows access through to the handler if the predicate returns
  true. If the predicate fails, Friend will throw a validation error
  internally.

  the authorized? function takes the request as its argument. If you
  want to get the identity out, use the `identity` function inside of
  cemerick.friend, or current-authentication. Both are helpful."
  [handler authorized?]
  (fn [request]
    (if (authorized? request)
      (handler request)
      (unauthorized! handler request))))

(defn wrap-authenticated
  "Takes a handler and wraps it with a check that the current user is
  authenticated."
  [handler]
  (fn [req]
    (if (identity req)
      (handler req)
      (unauthorized! handler req))))

(defn super-admin
  "Wraps the supplied handler (returned by the supplied no-arg func)
   in a super-admin? authorization check."
  [f]
  (-> (constantly (f))
      (wrap-authorize (roles #{::user/super-admin}))))

(def admins-only
  "Friend predicate that returns true if the request is pegged to a regatta
  admin, false otherwise."
  (fn [req]
    (user/regatta-admin-session?
     (:params req)
     (:username (friend/current-authentication req)))))

(defn admin-route
  "Takes a handler and wraps it in an authorization layer that only
  lets through requests from verified admins."
  [handler]
  (fn [req]
    (wrap-authorize handler admins-only)))

;; ## Workflow Helpers
;;
;; Here are some nice functions for developing custom authentication
;; workflows using friend.

(defn on-success
  "When a workflow is run, it returns either an authorized user
  document OR a ring response noting that the auth failed. If a login
  isn't actually happening, Friend authenticates the user via their
  session.

  `on-success` returns a new workflow that runs the old workflow, then
  calls the supplied function if the workflow is applied
  successfully."
  [workflow f]
  (fn [& args]
    (when-let [res (apply workflow args)]
      (when (friend/auth? res)
        (f))
      res)))

@sritchie
Copy link
Author

And then here's my integration with Liberator. This extends liberator to allow for base behaviors in the map. Along with that I've got a base Friend resource that inserts the proper checks into the :handle-unauthorized decision point. I'll write this up in more detail tomorrow, but wanted to send it over to you for a first look.

(ns paddleguru.util.liberator
  "Helpful extensions to liberator."
  (:require [compojure.route :as route]
            [liberator.core :as l]
            [liberator.representation :as rep]
            [paddleguru.models.user :as user]
            [paddleguru.util.friend :as friend]
            [paddleguru.views.shared :as shared]))

;; ## Basic Helpers
;;
;; These could easily make their way back into liberator.

(defn flatten-resource
  "Accepts a map (or a sequence, which gets turned into a map) of
  resources; if the map contains the key :base, the kv pairs from THAT
  map are merged in to the current map. If there are clashes, the new
  replaces the old by default.

  Combat this by supplying a :merge-with function in the map. This
  binary function will be used to resolve clashes using
  \"merge-with\"."
  [kvs]
  (let [m (if (map? kvs)
            kvs
            (apply hash-map kvs))
        trim #(dissoc % :base :merge-with)]
    (if-let [base (:base m)]
      (let [combined (combine base)
            trimmed  (trim m)]
        (if-let [merge-fn (:merge-with m)]
          (merge-with merge-fn combined trimmed)
          (merge combined trimmed)))
      (trim m))))

(defn resource
  "Functional version of defresource. Takes any number of kv pairs,
  returns a resource function."
  [& kvs]
  (fn [request]
    (l/run-resource request
                    (flatten-resource kvs))))

(defmacro defresource
  "The same as liberator's defresource, except it allows for a base
  resource and a merge-with function."
  [name & kvs]
  (if (vector? (first kvs))
    (let [[args & kvs] kvs]
      `(defn ~name [~@args]
         (resource ~@kvs)))
    `(defn ~name [req#]
       (l/run-resource req# ~(flatten-resource kvs)))))

;; ## Friend Integration

(def guru-base
  "Base for all guru resources."
  (let [not-found (comp rep/ring-response
                        (route/not-found shared/status-404-page))]
    {:handle-not-acceptable not-found
     :handle-not-found not-found}))

(def friend-resource
  "Base resource that will handle authentication via friend's
  mechanisms. Provide an authorization function and you'll be good to
  go."
  {:base guru-base
   :handle-unauthorized
   (fn [req]
     (friend/unauthorized! (-> req :resource :allowed?)
                           req))})

;; ## Base Resources
;;
;; These functions and vars provide base resources that make it easier
;; to define new liberator resources within the paddleguru codebase.

(defn friend-auth
  "Returns a base resource that authenticates using the supplied
  auth-fn. Authorization failure will trigger Friend's default
  unauthorized response."
  [auth-fn] {:base friend-resource
             :authorized? auth-fn})

(defn with-base
  "Merges the supplied base map into the supplied resource
  map (knocking out any existing base)"
  [base resource]
  (assoc resource :base base))

(defn role-auth
  "Returns a base resource that authenticates users against the
  supplied set of roles."
  [role-input]
  {:base friend-resource
   :authorized? (friend/roles role-input)})

(def regatta-admin-resource
  "Base resource that guarantees the supplied resource will be
   authenticated against regatta admins only."
  (friend-auth friend/admins-only))

(def super-admin-resource
  "Base resource that guarantees the supplied resource will be
   authenticated against super admins only."
  (role-auth #{::user/super-admin}))

@cemerick
Copy link
Owner

I don't see where you're actually going to the point of setting up ACLs (which, maybe you don't actually need?). In any case, these look like a sane bunch of helpers. Also, thanks for paving through what's necessary for liberator-friend interop. Looking forward to your friend-liberator library. :-D

@sritchie
Copy link
Author

This is the spot:

(def admins-only
  "Friend predicate that returns true if the request is pegged to a regatta
  admin, false otherwise."
  (fn [req]
    (user/regatta-admin-session?
     (:params req)
     (:username (friend/current-authentication req)))))

(defn admin-route
  "Takes a handler and wraps it in an authorization layer that only
  lets through requests from verified admins."
  [handler]
  (fn [req]
    (wrap-authorize handler admins-only)))

Determine based on the route and current auth if the user is visiting a regatta they're administering, and authorize (or authenticate) accordingly.

@Frozenlock
Copy link

I find myself in need of something similar.

I want to have multiple projects, where each user can be a member or an admin of the project.
I thought of maybe giving multiple roles to the users, like :member-project-A, :admin-project-B, but this doesn't seem right. I also see that cemerick already criticized the idea, so I'll stay away from it. ;-)

May I ask how you implemented your user/regatta-admin-session? ?
I would especially like to see what you are doing with the :params.

@sritchie
Copy link
Author

sritchie commented Apr 3, 2014

Sure, it's nothing fancy... I just use my regatta title to look up the regatta in the DB and check some attributes. Here's a version using schema:

(s/defn regatta-admin? :- s/Bool
  [title :- regatta/Title
   username :- (s/maybe UserName)]
  (let [admin-set (set (:admins (regatta/get-regatta title)))]
    (contains? admin-set username)))

(s/defn regatta-admin-session? :- s/Bool
  "Returns true if the supplied params reference a regatta admin,
  false otherwise."
  ([params]
     (regatta-admin-session? params (logged-in-user)))
  ([params user :- UserName]
   (regatta-admin? (:regatta-title params) user)))

@Frozenlock
Copy link

It's quite simple alright. I think I was over-thinking it.

Thank you very much!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants