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 :
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 :
field | type |
---|---|
resource_id | NAME |
resource_type | varchar(50) |
user_id | NAME |
scope | varchar(50) |
authz | JSONB |
{
"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 :
A resource :
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 !
- How to enrich native metrics in KeyCloak - 21 août 2024
- Keycloak Authenticator explained - 7 mars 2024
- Keycloak OIDC authentication with N8N workflow - 1 décembre 2023