diff --git a/.gitignore b/.gitignore index a1c2a23..a465a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,38 @@ +# OS stuff +################### +.DS_Store + +# Intellij +################### +.idea +*.iml + +# Eclipse # +########### +.project +.settings +.classpath +# reverting this as e.g. /distribution/feature-packs/server-feature-pack/src/main/resources/content/bin/ +# should not be ignored +#bin/ +.factorypath + + +# NetBeans # +############ +nbactions.xml +nb-configuration.xml +catalog.xml +nbproject + +# VS Code # +########### +*.code-workspace + +# Maven # +######### +target + # Compiled class file *.class @@ -21,3 +56,4 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6977a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM quay.io/keycloak/keycloak:20.0.3 as builder + +ENV KC_DB=postgres +ENV KC_HTTP_RELATIVE_PATH=/auth + +COPY ./target/keycloak-spicedb-event-listener-2.0.0-jar-with-dependencies.jar /opt/keycloak/providers/keycloak-spicedb-event-listener-2.0.0.jar +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:20.0.3 + +COPY --from=builder /opt/keycloak/lib/quarkus/ /opt/keycloak/lib/quarkus/ +COPY --from=builder /opt/keycloak/providers/ /opt/keycloak/providers/ + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "--debug","start-dev"] \ No newline at end of file diff --git a/README.md b/README.md index a17e389..629fc24 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ -# kc-spicedb-events -An event listener for Keycloak, creating spiceDB relationship data for keycloak users and groups by listening on the events in keycloak and using the spiceDB java client. +# keycloak-spicedb-eventlistener +An event listener for Keycloak, creating spiceDB relationship data for keycloak users and groups by listening on the events in keycloak and using the spiceDB java client. + +Inspired by [this](https://github.com/embesozzi/keycloak-openfga-event-listener) implementation for openFGA + +**warning** +This is a highly experimental WIP PoC for now, so use at your own risk and definitely nowhere near production. It may likely be that it gets abandoned shortly. :warning: + + +# try it out: + +1) mvn clean install +2) docker build . -t dguhr/keycloak_spicedbtest +3) docker compose up + +4) create users and groups in keycloak +5) go to realm settings -> events and activate 'spicedb-events' +6) add users to groups. +7) use e.g. zed (the spicedb command line tool) to connect to the spiceDB instance and see that relations are written containing the username (form: userid_username) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be023c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,186 @@ +version: '3' + +volumes: + postgres_data: + driver: local + caddy_data: + driver: local + +services: + postgres: + image: postgres:11 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: password + ports: + - 5433:5432 + keycloak: + build: . + image: dguhr/keycloak_spicedbtest + environment: + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: password + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: password + KC_DB_URL_HOST: postgres + KC_DB_URL_DATABASE: keycloak + KC_DB_SCHEMA: public + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: password + KC_HOSTNAME_STRICT: 'false' + KC_HTTP_ENABLED: 'true' + KC_HOSTNAME_ADMIN: localhost + KC_HOSTNAME: localhost + # Keycloak SpiceDB Event Listener SPI configuration + KC_SPI_EVENTS_LISTENER_SPICEDB_EVENTS_SERVICE_HANDLER_NAME: FILE + # TODO evaluate if needed + KC_SPI_EVENTS_LISTENER_SPICEDB_EVENTS_CLIENT_ID: keycloak-producer + KC_SPI_EVENTS_LISTENER_SPICEDB_EVENTS_ADMIN_TOPIC: spicedb-topic + KC_SPI_EVENTS_LISTENER_SPICEDB_EVENTS_BOOTSTRAP_SERVERS: PLAINTEXT://kafka:19092 + KC_LOG_LEVEL: INFO, io.dguhr:debug + DEBUG_PORT: "*:8787" + ports: + - 8080:8080 + - 8443:8443 + - 8787:8787 # debug + depends_on: + - postgres + - spicedb + networks: + default: + aliases: + - keycloak +# zookeeper: +# image: confluentinc/cp-zookeeper:7.2.2 +# hostname: zookeeper +# container_name: zookeeper +# ports: +# - "2181:2181" +# environment: +# ZOOKEEPER_CLIENT_PORT: 2181 +# ZOOKEEPER_SERVER_ID: 1 +# kafka: +# image: confluentinc/cp-kafka:7.2.2 +# hostname: kafka +# container_name: kafka +# ports: +# - "9092:9092" +# - "19092:19092" +# - "29092:29092" +# environment: +# KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:19092 +# KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT +# KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL +# KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' +# KAFKA_DELETE_TOPIC_ENABLE: 'true' +# KAFKA_CREATE_TOPICS: openfga-topic:1.1 +# KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 +# KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 +# KAFKA_ADVERTISED_HOST_NAME: kafka +# depends_on: +# - zookeeper + +# removed for later use. + +# spicedb on cockrach start: cluster does not find members. commenting out, lets use postgres to try out. +# spicedb: +# image: "authzed/spicedb" +# command: "serve" +# restart: "always" +# ports: +# - "8080:8080" +# - "9090:9090" +# - "50051:50051" +# environment: +# SPICEDB_GRPC_PRESHARED_KEY: foobar +# SPICEDB_DATASTORE_ENGINE: cockroachdb +# SPICEDB_DATASTORE_CONN_URI: "postgresql://root:secret@crdb:26257/spicedb?sslmode=disable" +# SPICEDB_LOG_LEVEL: info +# SPICEDB_LOG_FORMAT: console +# depends_on: +# - "migrate" + +# migrate: +# image: "authzed/spicedb" +# command: "migrate head" +# restart: "on-failure:3" +# environment: +# - "SPICEDB_DATASTORE_ENGINE=cockroachdb" +# - "SPICEDB_DATASTORE_CONN_URI=postgresql://root:secret@crdb:26257/spicedb?sslmode=disable" +# - "SPICEDB_LOG_LEVEL=info" +# - "SPICEDB_LOG_FORMAT=console" +# depends_on: +# - "init_database" + +# init_database: +# image: "cockroachdb/cockroach" +# restart: "on-failure:3" +# command: "sql --insecure -e 'CREATE DATABASE IF NOT EXISTS spicedb;'" +# environment: +# - "COCKROACH_HOST=crdb:26257" +# depends_on: +# - "init_cluster" + +# init_cluster: +# image: "cockroachdb/cockroach" +# restart: "on-failure:3" +# command: "init --insecure" +# environment: +# # initialize cluster through node 1 +# - "COCKROACH_HOST=datastores-crdb-1:26257" +# depends_on: +# - "crdb" + +# crdb: +# image: "cockroachdb/cockroach" +# # in order to make the cluster form, the host name is -- +# # The setup will support --scale arg with any value +# command: "start --join=datastores-crdb-1,datastores-crdb-2,datastores-crdb-3 --insecure" +# ports: +# - "8080" +# - "26257" +# environment: +# - "POSTGRES_PASSWORD=secret" +# healthcheck: +# test: "curl --fail http://localhost:8080/health?ready=1 || exit 1" +# interval: "2s" +# retries: 3 +# start_period: "15s" +# timeout: "5s" + +#spicedb on postgres + spicedb: + image: "authzed/spicedb" + command: "serve" + restart: "always" + ports: + - "8081:8080" + - "9090:9090" + - "50051:50051" + environment: + - "SPICEDB_GRPC_PRESHARED_KEY=12345" + - "SPICEDB_DATASTORE_ENGINE=postgres" + - "SPICEDB_DATASTORE_CONN_URI=postgres://postgres:secret@sdb-database:5432/spicedb?sslmode=disable" + depends_on: + - "migrate" + + migrate: + image: "authzed/spicedb" + command: "migrate head" + restart: "on-failure" + environment: + - "SPICEDB_DATASTORE_ENGINE=postgres" + - "SPICEDB_DATASTORE_CONN_URI=postgres://postgres:secret@sdb-database:5432/spicedb?sslmode=disable" + depends_on: + - "sdb-database" + + sdb-database: + image: "postgres" + ports: + - "5432:5432" + environment: + - "POSTGRES_PASSWORD=secret" + - "POSTGRES_DB=spicedb" \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4ef9856 --- /dev/null +++ b/pom.xml @@ -0,0 +1,110 @@ + + 4.0.0 + io.dguhr.keycloak + keycloak-spicedb-event-listener + 2.0.0 + + + 11 + 11 + 20.0.3 + 3.3.1 + 5.8.1 + + + + + + org.keycloak + keycloak-parent + ${keycloak.version} + pom + import + + + + + + + + org.keycloak + keycloak-core + ${keycloak.version} + provided + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + + org.jboss.logging + jboss-logging + provided + + + + org.apache.kafka + kafka-clients + ${kafka.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + com.authzed.api + authzed + 0.4.0 + + + io.grpc + grpc-protobuf + 1.52.1 + + + io.grpc + grpc-stub + 1.52.1 + + + + + + + maven-assembly-plugin + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + \ No newline at end of file diff --git a/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProvider.java b/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProvider.java new file mode 100644 index 0000000..e6ceef5 --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProvider.java @@ -0,0 +1,52 @@ +package io.dguhr.keycloak; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dguhr.keycloak.service.ServiceHandler; +import io.dguhr.keycloak.event.SpiceDbEventParser; +import org.jboss.logging.Logger; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakSession; + +public class SpiceDbEventListenerProvider implements EventListenerProvider { + private static final Logger LOG = Logger.getLogger(SpiceDbEventListenerProvider.class); + private ObjectMapper mapper; + private ServiceHandler service; + private KeycloakSession session; + + public SpiceDbEventListenerProvider(ServiceHandler service, KeycloakSession session) { + LOG.info("[SpiceDbEventListener] SpiceDbEventListenerProvider initializing..."); + this.service = service; + this.session = session; + mapper = new ObjectMapper(); + } + + @Override + public void onEvent(Event event) { + LOG.info("[SpiceDbEventListener] onEvent type: " + event.getType().toString()); + LOG.info("[SpiceDbEventListener] Discarding event..."); + } + + @Override + public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) { + LOG.info("[SpiceDbEventListener] onEvent Admin received events"); + + try { + LOG.infof("[SpiceDbEventListener] admin event: " + mapper.writeValueAsString(adminEvent)); + SpiceDbEventParser spiceDbEventParser = new SpiceDbEventParser(adminEvent, session); + LOG.infof("[SpiceDbEventListener] event received: " + spiceDbEventParser); + service.handle(adminEvent.getId(), spiceDbEventParser.toTupleEvent()); + } catch (IllegalArgumentException e) { + LOG.warn(e.getMessage()); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void close() { + // ignore + } +} diff --git a/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProviderFactory.java b/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProviderFactory.java new file mode 100644 index 0000000..ba22bb2 --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/SpiceDbEventListenerProviderFactory.java @@ -0,0 +1,52 @@ +package io.dguhr.keycloak; + +import io.dguhr.keycloak.service.ServiceHandler; +import io.dguhr.keycloak.service.ServiceHandlerFactory; +import org.keycloak.Config.Scope; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class SpiceDbEventListenerProviderFactory implements EventListenerProviderFactory { + + private static final String PROVIDER_ID = "spicedb-events"; + private SpiceDbEventListenerProvider instance; + private String serviceHandlerName; + private Scope config; + + @Override + public EventListenerProvider create(KeycloakSession session) { + if (instance == null) { + ServiceHandler serviceHandler = ServiceHandlerFactory.create(serviceHandlerName, session, config); + serviceHandler.validateConfig(); + instance = new SpiceDbEventListenerProvider(serviceHandler, session); + } + return instance; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void init(Scope config) { + this.serviceHandlerName = config.get("serviceHandlerName"); + if (serviceHandlerName == null) { + throw new NullPointerException("Service handler name must not be null."); + } + + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory ksf) { + // ignore + } + + @Override + public void close() { + // ignore + } +} diff --git a/src/main/java/io/dguhr/keycloak/event/SpiceDbEventParser.java b/src/main/java/io/dguhr/keycloak/event/SpiceDbEventParser.java new file mode 100644 index 0000000..95dad43 --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/event/SpiceDbEventParser.java @@ -0,0 +1,320 @@ +package io.dguhr.keycloak.event; + +import com.authzed.api.v1.Core; +import com.authzed.api.v1.PermissionService; +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.authzed.api.v1.SchemaServiceGrpc; +import com.authzed.api.v1.SchemaServiceOuterClass; +import com.authzed.grpcutil.BearerToken; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.grpc.ManagedChannelBuilder; +import org.jboss.logging.Logger; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakSession; +import io.grpc.ManagedChannel; +import org.keycloak.models.UserModel; + +public class SpiceDbEventParser { + + public static final String EVT_RESOURCE_USERS = "users"; + public static final String EVT_RESOURCE_GROUPS = "groups"; + public static final String EVT_RESOURCE_ROLES_BY_ID = "roles-by-id"; + public static final String OBJECT_TYPE_USER = "user"; + public static final String OBJECT_TYPE_ROLE = "role"; + public static final String OBJECT_TYPE_GROUP = "group"; + + private AdminEvent event; + private KeycloakSession session; + + private static final Logger logger = Logger.getLogger(SpiceDbEventParser.class); + + public SpiceDbEventParser(AdminEvent event, KeycloakSession session) { + this.event = event; + this.session = session; + } + + /*** + * Convert the Keycloak event to Event Tuple following the OpenFGA specs + * The OpenFGA authorization model is more complex, nevertheless, here is a simplified version of the Authorization Model that fit our requirements' + * role + * |_ assignee --> user == Keycloak User Role Assignment + * |_ parent_group --> group == Keycloak Group Role Assignment + * |_ parent --> role == Keycloak Role to Role Assignment + * group + * |_ assignee --> user == Keycloak User Group Role Assignment + */ + public String toTupleEvent() { + if(getEventOperation().equals("")) { + return ""; + } + + // Get all the required information from the KC event + String evtObjType = getEventObjectType(); + String evtUserType = getEventUserType(); //rm + String evtUserId = evtUserType.equals(OBJECT_TYPE_ROLE) ? findRoleNameInRealm(getEventUserId()) : getEventUserId(); //rm + String evtObjectId = getEventObjectName(); + String evtOrgId = getOrgIdByUserId(evtUserId); + + logger.info("[SpiceDbEventListener] TYPE OF EVENT IS: " + event.getResourceTypeAsString()); + logger.info("[SpiceDbEventListener] ORG ID FOR USER IN EVENT IS: " + evtOrgId); + logger.info("[SpiceDbEventListener] EVENTS definition IS: " + evtObjType); + //logger.info("[SpiceDbEventListener] EVENTS user type IS: " + evtUserType); + logger.info("[SpiceDbEventListener] EVENTS user ID IS: " + evtUserId); + logger.info("[SpiceDbEventListener] EVENTS group value ID IS: " + evtObjectId); + logger.info("[SpiceDbEventListener] EVENT representation is: " + event.getRepresentation()); + + //TODO use the spicedb client + // Check if the type (objectType) and object (userType) is present in the authorization model + // So far, every relation between the type and the object is UNIQUE + // perhaps add this check and create it using the API if not exist? + //ObjectRelation objectRelation = model.filterByType(evtObjType).filterByObject(evtUserType); + + ManagedChannel channel = ManagedChannelBuilder + .forTarget("host.docker.internal:50051") // TODO: create local setup and make it configurable + .usePlaintext() // if not using TLS, replace with .usePlaintext() + .build(); + + SchemaServiceGrpc.SchemaServiceBlockingStub schemaService = SchemaServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new BearerToken("12345")); + String schema = getInitialSchema(); + + PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionService = PermissionsServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new BearerToken("12345")); //TODO configurable + + SchemaServiceOuterClass.ReadSchemaResponse schemaResponse = getOrCreateSchema(schemaService); //TODO refactor, maybe idempotent updates are a thing + + UserModel user = getUserByUserId(evtUserId); + + return writeSpiceDbRelationship(evtUserId, evtObjectId, permissionService, user); + } + + private static String writeSpiceDbRelationship(String evtUserId, String evtObjectId, PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionService, UserModel user) { + PermissionService.WriteRelationshipsRequest req = PermissionService.WriteRelationshipsRequest.newBuilder().addUpdates( + Core.RelationshipUpdate.newBuilder() + .setOperation(Core.RelationshipUpdate.Operation.OPERATION_CREATE) + .setRelationship( + Core.Relationship.newBuilder() + .setResource( + Core.ObjectReference.newBuilder() + .setObjectType("thelargeapp/group") + .setObjectId(evtObjectId) + .build()) + .setRelation("direct_member") + .setSubject( + Core.SubjectReference.newBuilder() + .setObject( + Core.ObjectReference.newBuilder() + .setObjectType("thelargeapp/user") + .setObjectId(evtUserId + "_" + user.getUsername()) + .build()) + .build()) + .build()) + .build()) + .build(); + + PermissionService.WriteRelationshipsResponse writeRelationResponse; + try { + writeRelationResponse = permissionService.writeRelationships(req); + } catch (Exception e) { + logger.warn("WriteRelationshipsRequest failed: ", e); + return ""; + } + logger.info("writeRelationResponse: " + writeRelationResponse); + return writeRelationResponse.getWrittenAt().getToken(); + } + + private static SchemaServiceOuterClass.ReadSchemaResponse getOrCreateSchema(SchemaServiceGrpc.SchemaServiceBlockingStub schemaService) { + SchemaServiceOuterClass.ReadSchemaRequest readRequest = SchemaServiceOuterClass.ReadSchemaRequest + .newBuilder() + .build(); + + SchemaServiceOuterClass.ReadSchemaResponse readResponse; + + try { + readResponse = schemaService.readSchema(readRequest); + } catch (Exception e) { + //ugly but hey.. + if(e.getMessage().contains("No schema has been defined")) { + logger.warn("No scheme there yet, creating initial one."); + writeSchema(schemaService, getInitialSchema()); + } + return getOrCreateSchema(schemaService); + + } + logger.info("Scheme found: " + readResponse.getSchemaText()); + return readResponse; + } + + private static String writeSchema(SchemaServiceGrpc.SchemaServiceBlockingStub schemaService, String schema) { + SchemaServiceOuterClass.WriteSchemaRequest request = SchemaServiceOuterClass.WriteSchemaRequest + .newBuilder() + .setSchema(schema) + .build(); + + SchemaServiceOuterClass.WriteSchemaResponse writeSchemaResponse; + try { + writeSchemaResponse = schemaService.writeSchema(request); + } catch (Exception e) { + logger.warn("WriteSchemaRequest failed", e); + throw new RuntimeException(e); + } + logger.info("writeSchemaResponse: " + writeSchemaResponse.toString()); + return null; + } + + /** + * Checks for group_membership events. + * + * @return object type or error + */ + public String getEventObjectType() { + switch (event.getResourceType()) { + //remove roles from the game for now. TODO: check if wanted. + //case user: write user relation to spicedb. at best when assuming org_id = context then write orgid/user:value + /*case REALM_ROLE_MAPPING: + case REALM_ROLE: + return OBJECT_TYPE_ROLE;*/ + case GROUP_MEMBERSHIP: + return OBJECT_TYPE_GROUP; + default: + logger.info("Event is not handled, id:" + event.getId() + " resource name: " + event.getResourceType().name()); + return ""; + } + } + + public String getOrgIdByUserId(String userId) { + logger.info("Searching org_id for userId: " + userId); + String orgId = getUserByUserId(userId).getFirstAttribute("org_id"); + logger.info("Found org_id: " + orgId + " for userId: " + userId); + return orgId; + } + + /** + * perhaps rename to getEventSubjectType? + * + * @return + */ + public String getEventUserType() { + switch (getEventResourceName()) { + case EVT_RESOURCE_USERS: + return OBJECT_TYPE_USER; + case EVT_RESOURCE_GROUPS: + return OBJECT_TYPE_GROUP; + case EVT_RESOURCE_ROLES_BY_ID: + return OBJECT_TYPE_ROLE; + default: + //throw new IllegalArgumentException("Resource type is not handled: " + event.getOperationType()); + logger.info("Event is not handled, id:" + event.getId() + " resource name: " + event.getResourceType().name()); + return ""; + } + } + + /** + * //TODO: eval + ext + * + * @return + */ + public String getEventOperation() { + switch (event.getOperationType()) { + case CREATE: + return "writes"; + case DELETE: + return "deletes"; + default: + logger.info("Event is not handled, id:" + event.getId() + " resource name: " + event.getResourceType().name()); + return ""; + } + } + + public String getEventAuthenticatedUserId() { + return this.event.getAuthDetails().getUserId(); + } + + public UserModel getUserByUserId(String userId) { + return session.users().getUserById(session.getContext().getRealm(), userId); + } + + public String getEventUserId() { + return this.event.getResourcePath().split("/")[1]; + } + + public String getEventResourceName() { + return this.event.getResourcePath().split("/")[0]; + } + + public Boolean isUserEvent() { + return getEventResourceName().equalsIgnoreCase(EVT_RESOURCE_USERS); + } + + public Boolean isRoleEvent() { + return getEventResourceName().equalsIgnoreCase(EVT_RESOURCE_ROLES_BY_ID); + } + + public Boolean isGroupEvent() { + return getEventResourceName().equalsIgnoreCase(EVT_RESOURCE_GROUPS); + } + + public String getEventObjectId() { + return getObjectByAttributeName("id"); + } + + public String getEventObjectName() { + return getObjectByAttributeName("name"); + } + + private String getObjectByAttributeName(String attributeName) { + ObjectMapper mapper = new ObjectMapper(); + String representation = event.getRepresentation().replaceAll("\\\\", ""); + try { + JsonNode jsonNode = mapper.readTree(representation); + if (jsonNode.isArray()) { + return jsonNode.get(0).get(attributeName).asText(); + } + return jsonNode.get(attributeName).asText(); + } catch (JsonMappingException e) { + throw new RuntimeException(e); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public String findRoleNameInRealm(String roleId) { + logger.debug("Finding role name by role id: " + roleId); + return session.getContext().getRealm().getRoleById(roleId).getName(); + } + + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("AdminEvent resourceType="); + sb.append(event.getResourceType()); + sb.append(", operationType="); + sb.append(event.getOperationType()); + sb.append(", realmId="); + sb.append(event.getAuthDetails().getRealmId()); + sb.append(", clientId="); + sb.append(event.getAuthDetails().getClientId()); + sb.append(", userId="); + sb.append(event.getAuthDetails().getUserId()); + sb.append(", ipAddress="); + sb.append(event.getAuthDetails().getIpAddress()); + sb.append(", resourcePath="); + sb.append(event.getResourcePath()); + if (event.getError() != null) { + sb.append(", error="); + sb.append(event.getError()); + } + return sb.toString(); + } + + private static String getInitialSchema() { + return "definition thelargeapp/group {\n" + + " relation direct_member: thelargeapp/user\n" + + " relation admin: thelargeapp/user\n" + + " permission member = direct_member + admin\n" + + "}\n" + + "definition thelargeapp/user {}"; + } +} diff --git a/src/main/java/io/dguhr/keycloak/service/FileServiceHandler.java b/src/main/java/io/dguhr/keycloak/service/FileServiceHandler.java new file mode 100644 index 0000000..0d1eb59 --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/service/FileServiceHandler.java @@ -0,0 +1,56 @@ +package io.dguhr.keycloak.service; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static java.nio.file.StandardOpenOption.APPEND; +import static java.nio.file.StandardOpenOption.CREATE; + +/** + * mostly for testing and debugging purposes. + */ +public class FileServiceHandler extends ServiceHandler { + + private static final Logger logger = Logger.getLogger(FileServiceHandler.class); + + public FileServiceHandler(KeycloakSession session, Config.Scope config){ + super(session, config); + validateConfig(); + } + + @Override + public void handle(String eventId, String eventValue) throws ExecutionException, InterruptedException, TimeoutException { + if (eventValue.equals("")) { + logger.info("no processable event, idling..."); + return; + } + + logger.info("[SpiceDbEventListener] File handler is writing event id: " + eventId + " with value: " + eventValue + " to file: " + getFileName()); + var filePath = System.getProperty("kc.home.dir"); + + Path p = Paths.get(filePath+"spicedb_export.txt"); + try { + Files.write(p, List.of(eventValue + System.lineSeparator()), CREATE, APPEND); + } catch (IOException e) { + logger.error("Not possible! nah! Path: " + p, e); + } + } + + private String getFileName() { + return "spicedb_export.txt"; + } + + @Override + public void validateConfig() { + + } +} diff --git a/src/main/java/io/dguhr/keycloak/service/KafkaServiceHandler.java b/src/main/java/io/dguhr/keycloak/service/KafkaServiceHandler.java new file mode 100644 index 0000000..958b68a --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/service/KafkaServiceHandler.java @@ -0,0 +1,79 @@ +package io.dguhr.keycloak.service; + +import org.apache.kafka.clients.producer.*; +import org.apache.kafka.common.serialization.StringSerializer; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.utils.StringUtil; + +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.jboss.logging.Logger; + +public class KafkaServiceHandler extends ServiceHandler { + private static final Logger logger = Logger.getLogger(KafkaServiceHandler.class); + private Producer producer; + + public KafkaServiceHandler(KeycloakSession session, Config.Scope config){ + super(session, config); + validateConfig(); + producer = createProducer(); + } + + protected static final String KAFKA_ADMIN_TOPIC = "adminTopic"; + protected static final String KAFKA_CLIENT_ID = "clientId"; + protected static final String KAFKA_BOOTSTRAP_SERVERS = "bootstrapServers"; + + @Override + public void handle(String eventId, String eventValue) throws ExecutionException, InterruptedException, TimeoutException { + logger.info("[SpiceDbEventListener] Kafka producer is sending event id: " + eventId + " with value: " + eventValue + " to topic: " + getAdminTopic()); + ProducerRecord record = new ProducerRecord<>(getAdminTopic(), eventId, eventValue); + Future metaData = producer.send(record); + RecordMetadata recordMetadata = metaData.get(30, TimeUnit.SECONDS); + logger.info("[SpiceDbEventListener] Received new metadata. \n" + + "Topic:" + recordMetadata.topic() + "\n" + + "Partition: " + recordMetadata.partition() + "\n" + + "Key:" + record.key() + "\n" + + "Offset: " + recordMetadata.offset() + "\n" + + "Timestamp: " + recordMetadata.timestamp()); + } + + @Override + public void validateConfig() { + StringBuilder message = new StringBuilder(); + message.append(StringUtil.isBlank(getAdminTopic()) ? String.format("Parameter % name must not be null", KAFKA_ADMIN_TOPIC) : ""); + message.append(StringUtil.isBlank(getClientId()) ? String.format("Parameter % name must not be null", KAFKA_CLIENT_ID) : ""); + message.append(StringUtil.isBlank(getBootstrapServers()) ? String.format("Parameter % name must not be null", KAFKA_BOOTSTRAP_SERVERS): ""); + if (message.length() > 0) { + throw new IllegalStateException(message.toString()); + } + } + + public Producer createProducer() { + Properties props = new Properties(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, getBootstrapServers()); + props.put(ProducerConfig.CLIENT_ID_CONFIG, getClientId()); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + + // fix Class org.apache.kafka.common.serialization.StringSerializer could not be found. + Thread.currentThread().setContextClassLoader(null); + return new KafkaProducer<>(props); + } + + public String getAdminTopic() { + return super.config.get(KAFKA_ADMIN_TOPIC); + } + + public String getBootstrapServers() { + return super.config.get(KAFKA_BOOTSTRAP_SERVERS); + } + + public String getClientId() { + return super.config.get(KAFKA_CLIENT_ID); + } + +} diff --git a/src/main/java/io/dguhr/keycloak/service/ServiceHandler.java b/src/main/java/io/dguhr/keycloak/service/ServiceHandler.java new file mode 100644 index 0000000..ec95421 --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/service/ServiceHandler.java @@ -0,0 +1,26 @@ +package io.dguhr.keycloak.service; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +public abstract class ServiceHandler { + + protected final KeycloakSession session; + protected final Config.Scope config; + + public ServiceHandler(KeycloakSession session, Config.Scope config ) { + this.session = session; + this.config = config; + } + + public abstract void handle(String eventID, String eventValue) throws ExecutionException, InterruptedException, TimeoutException; + + public abstract void validateConfig(); + + public void close() { + // close this instance of the event listener + } + +} diff --git a/src/main/java/io/dguhr/keycloak/service/ServiceHandlerFactory.java b/src/main/java/io/dguhr/keycloak/service/ServiceHandlerFactory.java new file mode 100644 index 0000000..e362f3d --- /dev/null +++ b/src/main/java/io/dguhr/keycloak/service/ServiceHandlerFactory.java @@ -0,0 +1,19 @@ +package io.dguhr.keycloak.service; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +public class ServiceHandlerFactory { + + public static ServiceHandler create(String serviceName, KeycloakSession session, Config.Scope config){ + switch (serviceName) { + case ("FILE"): + return new FileServiceHandler(session, config); + case ("KAFKA"): + return new KafkaServiceHandler(session, config); + case ("HTTP_CLIENT"): + throw new IllegalArgumentException("This service has not been implemented yet... " + serviceName); + default: + throw new IllegalArgumentException("The service " + serviceName + " is not implemented"); + } + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory new file mode 100644 index 0000000..8548ff2 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +io.dguhr.keycloak.SpiceDbEventListenerProviderFactory \ No newline at end of file