
As I'm building out the core functionality of my current application, the next thing I really wanted to add were a few Authorization handlers for the buddy-auth system I started using with WebAuthN Authentication. The WebAuthN started with a simple Is this user logged in? handler:
(defn is-authenticated?
"Function to check the provided request data for a ':user' key, and if it's
there, then we can assume that the user is valid, and authenticated with the
passkey. This :user is on the request because the wrap-user middleware put
it there based on the :session data containing the :identity element and we
looked up the user from that."
[{user :user :as req}]
(uuid? (:id user)))
In order for this to work properly, we needed to make a wrap-user middleware so that if we had a logged in user in the session data, then we would place it in the request for compojure to pass along to all the other middleware, and the routes themselves. This wasn't too hard:
(defn wrap-user
"This middleware is for looking at the :identity in the session data, and
picking up the complete user from their :email and placing it on the request
as :user so that it can be used by all the other endpoints in the system."
[handler]
(fn [{session :session :as req}]
(handler (assoc req :user (get-user (:email (:identity session)))))))
and this middleware used a function, get-user to load the complete user object from the database based on the email of the user. It's not all that hard, but there are some tricks about the persistence of the Passkey Authenticator object that have to be serialized, and I've already written about that a bit.
And this works perfectly because the WebAuthN workflow deposits the :identity data in the session, and since it's stored server-side, it's safe, and with ring session state persisted in redis, we have this survive restarts, and shared amongst instances in a load balancer. But what about something a little more specific? Like, say we have an endpoint that returns the details of an order, but only if the user has been permission to see the order?
This combines Roll-Based Access Control (RBAC), and Attribute-Based Access Control (ABAC) - and while some will say you only need one, that's really not the best way to build a system because there are times when you need some of both to make the solution as simple as possible.
In any case, this is what we need to add:
- For a user, can they actually see the order in the database? This can be a question of the schema and data model, but there will likely be a way to determine if the user was associated with the order, and if so, then they can see it, and if not, then they can't.
- Most endpoint conventions have the identifier as the last part of the URL - the Path, as it is referred to. We will need to be able to easily extract the Path from the URL, or URI, in the request, and then use that as the identifier of the order.
- Put these into an authentication handler for buddy-auth.
For the first, I made a simple function to see if the user can see the order:
(defn get-user-order
"Function to take a user id and order id and return the user/order
info, if any exists, for this user and this order. We then need to
look up the user-order, and return the appropriate map - if one exists."
[uid oid]
(if (and (uuid? uid) (uuid? oid))
(db/query ["select * from users_orders
where user_id = ? and order_id = ?" uid oid]
:row-fn kebab-keys-deep :result-set-fn first)))
For the second, we can simply look at the :uri in the request, and split it up on the /, and then take the last one:
(defn uri-path
"When dealing with Buddy Authentication handlers, it's often very useful
to be able to get the 'path' from the request's uri and return it. The
'path' is defined to be:
https://google.com/route/to/path
and is the last part of the url *before* the query params. This is very
often a uuid of an object that we need to get, as it's the one being
requested by the caller."
[{uri :uri :as req}]
(if (not-empty uri)
(last (split uri "/"))))
For the last part, we put these together, and we have a buddy-auth authorization handler:
(defn can-see-order?
"Function to take a request, and pull out the :user from the wrapping
middleware, and pick the last part of the :uri as that will be the
:order-id from the URL. We then need to look up the user-order, and
see if this user can see this order."
[{user :user :as req}]
(if-let [hit (get-user-order (:id user) (->uuid (uri-path req)))]
(not-nil? (some #{"OPERATOR"} (:roles hit)))))
in this function we see that we are referring to :roles on the user-order, and that's because we have built up the cross-reference table in the database to look like:
CREATE TABLE IF NOT EXISTS users_orders (
id uuid NOT NULL,
version INTEGER NOT NULL,
as_of TIMESTAMP WITH TIME zone NOT NULL,
by_user uuid,
user_id uuid NOT NULL,
roles jsonb NOT NULL DEFAULT '[]'::jsonb,
title VARCHAR,
description VARCHAR,
order_id uuid NOT NULL,
created_at TIMESTAMP WITH TIME zone NOT NULL,
PRIMARY KEY (id, version, as_of)
);
The key parts are the user_id and order_id - the mapping is many-to-many, so we have to have a cross-reference table to handle that association. Along with these, we have some metadata about the reference: the title of the User with regard to this order, the description of the relationship, and even the roles the User will have with regards to this order.
The convention we have set up is that of the roles contains the string OPERATOR, then they can see the order. The Postgres JSONB field is ideal for this as it allows for a simple array of strings, and it fits right into the data model.
With all this, we can then make a buddy-auth access rule that looks like:
{:pattern #"^/orders/[-0-9a-fA-F]{36}"
:request-method :get
:handler {:and [is-authenticated? can-see-order?]}}
and the endpoints that match that pattern will have to pass both the handlers and we have exactly what we wanted without having to place any code in the actual routes or functions to handle the authorization. Nice. 🙂