When using the Identity Provider Keycloak1, the primary data sources for identities are the internal database and user federation via LDAP and Kerberos.
There is a user storage service provider interface2 providing the possibility to develop Keycloak plugins supporting other datasource types.
This storage provider can import users, realm- and client roles from the pre-existing database into a Keycloak realm. The target roles that users will be mapped to must have been created beforehand in the realm; missing roles will lead to warning messages in the Keycloak log.
The examples in this proposal assume a pre-existing database userdb
with the
following tables:
-
users
contains rows of identities with the following attributes:- a (preferred) username,
- a creation datetime information,
- the first name of the user (can be empty),
- the last name of the user (can be empty),
- an email address of the user (can be empty) and
- a salted password hash (if NULL, no password is associated with the user).
-
client_roles
contains rows of client-IDs and client role names. -
A mapping table assigns zero or more client roles to each user.
-
realm_roles
contains rows of realm role names. -
A mapping table assigns zero or more realm roles to each user.
The configuration and the example SQL data presented here demonstrate this setup for a PostGreSQL database (tested with postgres 15), but they can be modified to support MariaDB, MySQL and other database types.
The storage provider presented in this document attaches a pre-existing relational database of identities and role mappings to a Keycloak realm. It uses the Java Persistence API and the Hibernate object-relational mapping framework3 as proposed by the Keycloak "Quickstarts" examples4.
apt install maven
mvn package
To build for a specific version of Keycloak, for example 23.0.5
:
mvn -Dkeycloak.version=23.0.5 package
At runtime, the storage provider is configured by a Quarkus properties file that states the user database connection information. A template is included in this project:
cp conf/quarkus.properties.example conf/quarkus.properties
Edit the properties file to reflect the user database connection; for example,
to connect to a PostGreSQL server reachable at "dbserver" on standard port
5432, open a database "users" as postgres user "user1" with password secret
:
...
quarkus.datasource.user-store.db-kind=postgresql
quarkus.datasource.user-store.username=user1
quarkus.datasource.user-store.password=secret
quarkus.datasource.user-store.jdbc.url=jdbc:postgresql://dbserver/users
Notes:
This project features some hardcoded behavior in
src/main/resources/META-INF/persistence.xml,
because not all required Hibernate settings can currently be made using the
properties file. Consider the following Hibernate properties that are set in
persistence.xml
:
-
hibernate.dialect
This setting fixes the query language to a specific value (for example "PostgreSQLDialect") and is set in "persistence.xml" and not in "quarkus.properties" file for reasons that are currently intrinsic to Quarkus. Making this configurable exceeds the scope of this demonstration. Effectively, to support a different database, "user-storage-test.jar" has to be rebuilt. -
hibernate.hbm2ddl.auto
The proposed setting of "none" instructs hibernate not to check, drop, update or create any database table schemas; the database and tables are expected to be set up as described below in this document. This is the recommended setting for a production environment, where schema migrations are managed using external means. -
hibernate.show_sql
The proposed setting of "true" instructs hibernate to print generated SQL statements as it executes them, which is useful when debugging the database schema. In a production environment, this behavior might be unwanted for performance reasons. -
jakarta.persistence.transactionType
A setting of "JTA" is required to enable transactions across multiple XA datasources.
Note: There are other Hibernate settings, such as the fetching strategy, which could be relevant in a production setting but are not demonstrated here.
To use a different database driver:
- Change
hibernate.dialect
in src/main/resources/META-INF/persistence.xml
To use MariaDB, set the dialect to:
...
<properties>
<property name="hibernate.dialect" value="org.hibernate.dialect.MariaDBDialect" />
...
- Change database driver and JDBC URL in conf/quarkus.properties
To use a MariaDB database:
# Keycloak currently includes the "mariadb" JDBC driver
quarkus.datasource.user-store.db-kind=mariadb
...
quarkus.datasource.user-store.jdbc.url=jdbc:mariadb://db.example.test/userdb
scp target/user-storage-test.jar keycloak:/opt/keycloak/providers
scp conf/quarkus.properties keycloak:/opt/keycloak/conf
ssh keycloak /opt/keycloak/bin/kc.sh build
Note: The following sections describe the creation of a PostGreSQL user and database and show the SQL statement for table definitions and example data.
If you do not want to follow the instructions step by step, you can also apply an example SQL file containing all required SQL statements:
Copy the SQL file to the PostGreSQL server:
scp sql/postgres/userdb.sql db.example.test:/tmp
On the PostGreSQL server:
su - postgres
psql < /tmp/userdb.sql
Note: You can skip to section 3.5 if you have created the database using the SQL file "userdb.sql".
On the PostGreSQL server:
su - postgres
createuser userdb
psql
Note: The following actions are all executed in "psql".
Create the database and connect to it:
CREATE DATABASE userdb WITH ENCODING='UTF8' OWNER=userdb;
\c userdb
Create the database table for user entities that will specify one identity per row, containing user attributes supported by this storage provider:
CREATE TABLE public.users (
id character varying(255) NOT NULL,
createdtimestamp bigint,
email character varying(255),
email_verified boolean DEFAULT false,
firstname character varying(255),
lastname character varying(255),
password_hash character varying(255),
username character varying(255)
);
ALTER TABLE ONLY public.users
OWNER TO userdb;
ALTER TABLE ONLY public.users
ADD CONSTRAINT u_pkey PRIMARY KEY (id);
Create the table of client role entities which will contain one client role per row, stating the client ID and the name of the role:
CREATE TABLE public.client_roles (
id character varying(255) NOT NULL,
client character varying(255),
role character varying(255)
);
ALTER TABLE ONLY public.client_roles
OWNER TO userdb;
ALTER TABLE ONLY public.client_roles
ADD CONSTRAINT c_pkey PRIMARY KEY (id);
Create the mapping table that will establish a many-to-many relation between users and client roles:
CREATE TABLE public.users_to_client_roles (
user_id character varying(255) NOT NULL,
client_role_id character varying(255) NOT NULL
);
ALTER TABLE ONLY public.users_to_client_roles
OWNER TO userdb;
ALTER TABLE ONLY public.users_to_client_roles
ADD CONSTRAINT u2c_pkey
PRIMARY KEY(user_id, client_role_id);
Add foreign-key relations between users, mappings and client roles:
ALTER TABLE ONLY public.users_to_client_roles
ADD CONSTRAINT m2c_fkey
FOREIGN KEY(client_role_id)
REFERENCES public.client_roles(id);
ALTER TABLE ONLY public.users_to_client_roles
ADD CONSTRAINT u2m2c_fkey
FOREIGN KEY(user_id)
REFERENCES public.users(id);
Create the table of realm role entities which will contain one realm role per row, stating the name of the role:
CREATE TABLE public.realm_roles (
id character varying(255) NOT NULL,
role character varying(255)
);
ALTER TABLE ONLY public.realm_roles
OWNER TO userdb;
ALTER TABLE ONLY public.realm_roles
ADD CONSTRAINT r_pkey PRIMARY KEY (id);
Create the mapping table that will establish a many-to-many relation between users and realm roles:
CREATE TABLE public.users_to_realm_roles (
user_id character varying(255) NOT NULL,
realm_role_id character varying(255) NOT NULL
);
ALTER TABLE ONLY public.users_to_realm_roles
OWNER TO userdb;
ALTER TABLE ONLY public.users_to_realm_roles
ADD CONSTRAINT u2r_pkey
PRIMARY KEY(user_id, realm_role_id);
Add foreign-key relations between users, mappings and realm roles:
ALTER TABLE ONLY public.users_to_realm_roles
ADD CONSTRAINT m2r_fkey
FOREIGN KEY(realm_role_id)
REFERENCES public.realm_roles(id);
ALTER TABLE ONLY public.users_to_realm_roles
ADD CONSTRAINT u2m2r_fkey
FOREIGN KEY(user_id)
REFERENCES public.users(id);
The following record in the users table declares a user with preferred username
"mmustermann", password B1Systems!
and example email and profile attributes:
INSERT INTO public.users VALUES (
'1',
CAST(EXTRACT(EPOCH FROM NOW()) * 1000 AS bigint),
'm.mustermann@example.test',
'Max',
'Mustermann',
'$6$xyz$C/vOVAshxi1VfgblFW220kcBCpZ7lihIohmNInE5M6wCQdxDaleG6LZzzGiRJ8sOWQHGwtYuz.8kcfZntV1OY/',
'mmustermann'
);
Notes:
- The column "createdTimestamp" expects the datetime value as milliseconds since the epoch.
- The password
B1Systems!
has been hashed with the SHA-512 algorithm using a salt value ofxyz
.
The following record makes a client role "admin" of a client "testclient" available:
INSERT INTO public.client_roles VALUES (
'1', 'testclient', 'admin'
);
Define a mapping between user "mmustermann" and the client role "testclient.admin":
INSERT INTO public.users_to_client_roles VALUES (
'1', '1'
);
The following record makes a realm role "testrole" available:
INSERT INTO public.realm_roles VALUES (
'1', 'testrole'
);
Define a mapping between user "mmustermann" and the realm role "testrole":
INSERT INTO public.users_to_realm_roles VALUES (
'1', '1'
);
Note: The remaining steps can also be done using the admin GUI of Keycloak.
To start using "kcadm.sh" for setting up a realm, attaching the provider and creating the test client, first log in to the realm with a management account:
/opt/keycloak/bin/kcadm.sh config credentials \
--server https://www.example.test/keycloak \
--realm master \
--user admin --password 'secret'
kcadm.sh create components \
-r master \
-s name="user-storage-test" \
-s providerId="user-storage-test" \
-s providerType="org.keycloak.storage.UserStorageProvider"
Create a role "testrole" in realm "master":
kcadm.sh create roles \
-r master \
-s name=testrole
Create an OIDC client "testclient" in realm "master":
echo '{
"clientId": "testclient"
}' | kcadm.sh create clients -r master -f -
This results in an ID value for the client:
Created new client with id '9d97b37c-77a9-4cd8-bf6e-2ee5f0028429'
Create a role "admin" in client "testclient" using the ID value from the previous command:
kcadm.sh create -r master \
clients/9d97b37c-77a9-4cd8-bf6e-2ee5f0028429/roles \
-s name=admin
If a mapped entity in database table client_roles
specifies a client or a
role name that does not exist in the federated realm, a log message of warning
level will be produced:
User mmustermann requests client role testclient.admin, but client
testclient does not exist; client role not assigned.
- Author: Tilman Kranz <kranz@b1-systems.de>
- Copyright 2024 B1 Systems GmbH <info@b1-systems.de>
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.