After many years in consulting, how we build our own authorizations platform using KeyCloak.

Authn VS Authz

First of all, we have to define with a high precision where the authentication stops and where authorization starts.

Sometimes you can see posts about :

  • ABAC : Attribute-based access control
  • RBAC : Role-based access control
  • UBAC : User-based access control
  • CBAC : Context-based access control

All of these are part of « authorizations », what an authenticated user (« who ») can do, depending on user attributes that could come from his « user profile ».

Now, add 2 new components :

  • the resource to access
  • An operation on the resource (read, write…)

… and you are totally out of user attributes, are you going to create roles or a list of attributes attached to a user that contains all resources ? It can not work.

UMA : an extension to OAuth2

https://blog.please-open.it/uma/

https://www.keycloak.org/docs/latest/authorization_services/#_service_overview

Authorization Services provide extensions to OAuth2 to allow access tokens to be issued based on the processing of all policies associated with the resource(s) or scope(s) being requested.

Resource and Scope.

Check an authorization

With our tool uma2-bash-client :

./uma2-bash-client.sh --operation get_authorization_resource\
--uma2-configuration-endpoint https://app.please-open.it/auth/realms/5ae55f12-1515-47c8-9678-c740b0c852fc/.well-known/uma2-configuration\
--resource 847016ce-bd6f-4ee0-873b-64ebbfc0888f\
--scope read\
--audience uma-client\
--access-token $ACCESS_TOKEN
  • access token : who
  • resource : what
  • scope : the operation

If the user has the given authorization, it returns a token. If not :

{
  "error": "access_denied",
  "error_description": "not_authorized"
}

Limits

Policies

https://www.keycloak.org/docs/latest/authorization_services/#_policy_overview

Allow users on resources, ok, easy :

How we build our own Authorizations platform using KeyCloak

For custom rules ? Javascript policy !

https://www.keycloak.org/docs/latest/authorization_services/#_policy_js

We thought this approach is not good. Why ? Code is used for rules that should come from data (see below).

Predictability

Linked to the previous point : how can you list authorizations for a user if some are computed on runtime ? What we can call « Authorizations As Code » is not the right approach, it exists only for a reason : lack of flexibility on the resource model.

A fixed model

name, owner, type, scopes. That’s all, you have to deal with this representation.

Ok, with many use cases it works : « Alice has read access to this file », « Alice can have access from monday to friday ».

Now, another use case we had with a client : « Alice must have access to the photos taken 30 km around Paris ».

Or this one : « Alice has access to all files created between 01-01-2022 to 12-31-2022 ».

How do you deal with those cases ? Maybe a Javascript code ? No, you do not have access to enough data for computing the authorization. It does not work.

Datamodel instead of workaround

Authentication is a technical part that meets a need : answer the question « who » for a multi user application. We consider authentication as an essential technical part such as « store files ». The spec (and the associated user story) remain the same : « we want the user to authenticate by using XXX factors and get a token ».

Authorizations are specific for your apps, depending on the problem you try to address. For the same reason that « no code » tools can not solve all the problems, we are in the same situation. The data model provided by all products are not expandable, some of those introduce « Authorizations As Code » to prevent this lack.

An authorization is :

  • a resource type
  • its context (geolocation, creation date …)
  • a user
  • a scope

For us, each resource must be declared and authorizations distributed to users. This is useful to get a list of authorizations for a user, a resource or a resource type.

Thanks to postgres, we now have access to a JSONB type so we created this table :

fieldtype
resource_idNAME
resource_typevarchar(50)
user_idNAME
scopevarchar(50)
authzJSONB
{
    "resource_id" : "83f5b0a9-2697-41b7-b556-08aaf0d481fb",
    "resource_type" : "geolocated-photo",
    "user_id: "7c9be12d-20bf-403b-9302-6c7b8c4eb413",
    "scope": "READ",
    "authz": {
        "minLat": 43.5011988,
        "maxLat": 44.5011988,
        "minLng": 1.5020551,
        "maxLng": 3.5020551,
    }
}

In the example above, the use case is : we have a camera that takes photos with a geolocation. My user is allowed to view only some of them, based on the location. Outside the rectangle defined by latitude and longitude, the user can not access pictures.

Another way will be : create a resource for each photo, and give access to the user.

In this use case, the resource is the camera.

Requests

With this representation, we have enough informations for 2 types of requests :

  • a single request with user_id and resource_id. If there is a single result, the authorization is given to the user. With this result based on the resource, any application can check an advanced rule (for example, based on the GPS position).
  • other types of requests, depending on the use case, with :
    • at least the resource_type and the user_id
    • any constraint, based on the « authz » field

The rule is in the request, from the application that makes the check. There is no « execution », only a single SQL request on existing data. Each rule is defined inside the authorization platform.

With this model, for each resource type, a data model that fits perfectly with the application and the use case.

For filtering the authz object, we use jsonpath expressions.

Example :

My resource (a camera) took a photo with a latitude and longitude stored in metadata. I want to know if the user can have access to this picture, so I build a json path expression that looks like this :

$.[?(@.minLat > lat && @.maxLat < lat && @.minLng > lng && @.maxLng < lng)]

then url encoded in the request :

/rpc/authorization?resource_id=83f5b0a9-2697-41b7-b556-08aaf0d481fb&resource_type=geolocated-photo&scope=READ&custom_filter=%24.%5B%3F%28%40.minLat%20%3E%20lat%20%26%26%20%40.maxLat%20%3C%20lat%20%26%26%20%40.minLng%20%3E%20lng%20%26%26%20%40.maxLng%20%3C%20lng%29%5D

Header : Authorization JWT_TOKEN

I have a result, so the user is allowed to access this picture. Great isn’t it ? 🙂

Implementation

As usual for those projects that only manipulate data from a postgres database, we use postgrest.

Check a look how we deploy it on Clever-Cloud : https://blog.please-open.it/postgrest-clever-cloud/

Datamodel

A single table with all authorizations :

CREATE TABLE api.authorizations (
  id          UUID    PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id       NAME    NOT NULL,
  resource_id  NAME    NOT NULL,
  resource_type varchar(50) NOT NULL,
  scope       varchar(50) NOT NULL,
  authz         JSONB,
  CONSTRAINT fk_auth_type
      FOREIGN KEY(resource_type) 
      REFERENCES api.auth_types(id)
);

2 other tables we use for authorization types declaration and checks :

CREATE TABLE api.auth_types (
  id          varchar(50)    PRIMARY KEY NOT NULL,
  description text
);

CREATE TYPE field_type AS ENUM ('object', 'array', 'string', 'number', 'boolean');

CREATE TABLE api.auth_types_fields (
  id          UUID    PRIMARY KEY DEFAULT uuid_generate_v4(),
  auth_type_id varchar(50) NOT NULL,
  field_name  varchar(50) NOT NULL,
  field_type  FIELD_TYPE NOT NULL,
  CONSTRAINT fk_auth_type
      FOREIGN KEY(auth_type_id) 
      REFERENCES api.auth_types(id)
);

Web ui

With the generated apis from Postgrest, we built this little webui. First, declare an authorization type :

How we build our own Authorizations platform using KeyCloak

A resource :

How we build our own Authorizations platform using KeyCloak
How we build our own Authorizations platform using KeyCloak

Checks on APIs

To be sure that all the created resource match the declared type, a trigger is designed for this task :

CREATE OR REPLACE FUNCTION check_authorization_model()
  RETURNS TRIGGER 
  LANGUAGE PLPGSQL
  AS
$$
DECLARE
   _key   text;
   _value text;
   _size int;
   _type text;
   _length int;
BEGIN
  _size := COUNT(key) FROM jsonb_each(NEW.authz);
  _length := COUNT(id) FROM api.auth_types_fields WHERE auth_type_id = NEW.resource_type;
  IF _size <> _length THEN
    RAISE EXCEPTION 'invalid input data size';
  END IF;

  FOR _key, _value IN
       SELECT * FROM jsonb_each_text(NEW.authz)
    LOOP
       _size := COUNT(id) FROM api.auth_Types_fields WHERE auth_type_id = NEW.resource_type AND field_name = _key AND field_type = CAST (jsonb_typeof(NEW.authz->_key) AS field_type);
      IF _size = 0 THEN
          RAISE EXCEPTION 'unable to validate data on key % with type %', _key, jsonb_typeof(NEW.authz->_key);
      END IF;
    END LOOP;

    RETURN NEW;
END;
$$;

CREATE TRIGGER insert_authorization BEFORE INSERT
   ON api.authorizations
   FOR EACH ROW
  EXECUTE PROCEDURE check_authorization_model();

Indeed, the « ROW LEVEL SECURITY » is enabled :

CREATE POLICY subscription_policy ON api.authorizations FOR SELECT TO standard
  USING (user_id = current_setting('request.jwt.claims', true)::json->>'sub');

Get authorization APIs

2 options :

  • use the native way of requesting the authorizations table. More flexible, unreadable requests and inability to have required fields.

http://127.0.0.1:3000/authorizations?resource_id=eq.83f5b0a9-2697-41b7-b556-08aaf0d481fb&type=eq.geolocated-photo&scope=eq.read

  • define some customs APIs with functions.

http://127.0.0.1:3000/rpc/authorization?id=83f5b0a9-2697-41b7-b556-08aaf0d481&type=geolocated-photo&scope=read

We have defined only one function that fits our needs. This function must be extended or redefined for special use cases or more explicit API (instead of using jsonpath filters for example)

CREATE OR REPLACE FUNCTION api.authorization(resource_type varchar(50), resource_id varchar(50), scope varchar(50), custom_filter jsonpath default NULL )
    RETURNS TABLE (
  authz         JSONB
    ) 
    language plpgsql
AS $$
BEGIN
  IF custom_filter IS NULL THEN
  RETURN query 
    SELECT a.authz FROM api.authorizations a WHERE a.resource_type=$1 AND a.scope=$3 AND a.resource_id=$2 AND a.user_id = current_setting('request.jwt.claims', true)::json->>'sub';
  END IF;
    RETURN query 
    SELECT jsonb_path_query(a.authz, $4) AS authz FROM api.authorizations a WHERE a.resource_type=$1 AND a.scope=$3 AND a.resource_id=$2 AND a.user_id = current_setting('request.jwt.claims', true)::json->>'sub';
END;
$$;

https://postgrest.org/en/stable/references/api/stored_procedures.html

Use case : serve static files

Note : that is how we serve log files for our Keycloak as a Service

The resource_type declared in the authorization platform does not have any optional fields, only resource_id, user_id and scope.

With nginx, we serve files simply with :

        location / {
          alias /files/;
          autoindex on;
          autoindex_format json;
      }

With openresty (Nginx extended with LUA) and the openid connect plugin, an authentication layer to Nginx is added simply. After the authentication layer, we added our own code for authorization checks.

function string:endswith(suffix)
    return self:sub(-#suffix) == suffix
end

local opts = {
    introspection_endpoint = "http://keycloak:8080/realms/files/protocol/openid-connect/token/introspect",
    client_id = "files",
    client_secret = "---",
    auth_accept_token_as = "header"
}
-- call authenticate for OpenID Connect user authentication
local res, err = require("resty.openidc").introspect(opts)
if err then
    ngx.status = 403
    ngx.say(err)
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- Check authorization
file=ngx.var.request_uri

scope="READ"
type="files"
-- if we list a directory
if ngx.var.request_uri:endswith"/" then
    scope="LIST"
end

local httpc = require("resty.http").new()

local response, err = httpc:request_uri("http://server:3000/rpc/authorization?resource_id="..file.."&scope="..scope.."&resource_type="..type, {
headers = {
    ["Authorization"] = ngx.req.get_headers()['Authorization'],
},
method = "GET",
})
if not response then
    ngx.status = 403
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

local json = require('cjson')
local tab = json.decode(response.body)

if (table.getn(tab) == 0) then
    ngx.status = 403
    ngx.exit(ngx.HTTP_FORBIDDEN)
end          

Conclusion about this prototype

Simple by the code (only 140 lines), it took months for us to have the good approach : DATA !

Most of the time, we recommend building custom authorization solutions.

Why ?

Generic solutions do not answer the exposed problem. Some of them say « you can do anything with our solution » and it means « you have to write your own code on our platform ». Writing code ? No, the need is a custom data model.

Please share it and challenge us : How we build our own Authorizations platform using KeyCloak !

Mathieu PASSENAUD
Les derniers articles par Mathieu PASSENAUD (tout voir)