Added a representation that includes an organization and user model

Closes #34013

Signed-off-by: Robert Rieser <Robert.Rieser@degoya.studio>
Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
Co-authored-by: Robert Rieser <Robert.Rieser@degoya.studio>
This commit is contained in:
Pedro Igor 2024-11-12 06:12:51 -03:00 committed by GitHub
parent a3549f465e
commit 5c9f1837d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 258 additions and 18 deletions

View File

@ -17,6 +17,8 @@
package org.keycloak.representations.idm;
import java.util.Map;
/**
* @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc.
*/
@ -30,6 +32,7 @@ public class AdminEventRepresentation {
private String resourcePath;
private String representation;
private String error;
private Map<String, String> details;
public long getTime() {
return time;
@ -94,4 +97,12 @@ public class AdminEventRepresentation {
public void setError(String error) {
this.error = error;
}
public Map<String, String> getDetails() {
return details;
}
public void setDetails(Map<String, String> details) {
this.details = details;
}
}

View File

@ -13,6 +13,9 @@ include::topics/templates/document-attributes.adoc[]
:release_header_latest_link: {releasenotes_link_latest}
include::topics/templates/release-header.adoc[]
== {project_name_full} 26.0.6
include::topics/26_0_6.adoc[leveloffset=2]
== {project_name_full} 26.0.5
include::topics/26_0_5.adoc[leveloffset=2]

View File

@ -0,0 +1,4 @@
= Admin events might include now additional details about the context when the event is fired
In this release, admin events might hold additional details about the context when the event is fired. When upgrading you should
expect the database schema being updated to add a new column `DETAILS_JSON` to the `ADMIN_EVENT_ENTITY` table.

View File

@ -11,6 +11,10 @@ import {
Chip,
ChipGroup,
DatePicker,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Flex,
FlexItem,
Form,
@ -92,6 +96,24 @@ const DisplayDialog = ({
);
};
const DetailCell = (event: AdminEventRepresentation) => (
<DescriptionList isHorizontal className="keycloak_eventsection_details">
{event.details &&
Object.entries(event.details).map(([key, value]) => (
<DescriptionListGroup key={key}>
<DescriptionListTerm>{key}</DescriptionListTerm>
<DescriptionListDescription>{value}</DescriptionListDescription>
</DescriptionListGroup>
))}
{event.error && (
<DescriptionListGroup key="error">
<DescriptionListTerm>error</DescriptionListTerm>
<DescriptionListDescription>{event.error}</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
);
export const AdminEvents = () => {
const { adminClient } = useAdminClient();
@ -250,8 +272,16 @@ export const AdminEvents = () => {
</DisplayDialog>
)}
<KeycloakDataTable
className="keycloak__events_table"
key={key}
loader={loader}
detailColumns={[
{
name: "details",
enabled: (event) => event.details !== undefined,
cellRenderer: DetailCell,
},
]}
isPaginated
ariaLabelKey="adminEvents"
toolbarItem={

View File

@ -9,4 +9,5 @@ export default interface AdminEventRepresentation {
resourcePath?: string;
resourceType?: string;
time?: number;
details?: Record<string, any>;
}

View File

@ -28,35 +28,35 @@ import jakarta.persistence.Table;
@Entity
@Table(name="ADMIN_EVENT_ENTITY")
public class AdminEventEntity {
@Id
@Column(name="ID", length = 36)
private String id;
@Column(name="ADMIN_EVENT_TIME")
private long time;
@Column(name="REALM_ID")
private String realmId;
@Column(name="OPERATION_TYPE")
private String operationType;
@Column(name="RESOURCE_TYPE", length = 64)
private String resourceType;
@Column(name="AUTH_REALM_ID")
private String authRealmId;
@Column(name="AUTH_CLIENT_ID")
private String authClientId;
@Column(name="AUTH_USER_ID")
private String authUserId;
@Column(name="IP_ADDRESS")
private String authIpAddress;
@Column(name="RESOURCE_PATH")
private String resourcePath;
@ -66,6 +66,9 @@ public class AdminEventEntity {
@Column(name="ERROR")
private String error;
@Column(name="DETAILS_JSON")
private String detailsJson;
public String getId() {
return id;
}
@ -161,4 +164,12 @@ public class AdminEventEntity {
public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}
public String getDetailsJson() {
return detailsJson;
}
public void setDetailsJson(String detailsJson) {
this.detailsJson = detailsJson;
}
}

View File

@ -174,7 +174,7 @@ public class JpaEventStoreProvider implements EventStoreProvider {
}
return event;
}
private AdminEventEntity convertAdminEvent(AdminEvent adminEvent, boolean includeRepresentation) {
AdminEventEntity adminEventEntity = new AdminEventEntity();
adminEventEntity.setId(adminEvent.getId() == null ? UUID.randomUUID().toString() : adminEvent.getId());
@ -189,10 +189,17 @@ public class JpaEventStoreProvider implements EventStoreProvider {
adminEventEntity.setResourcePath(adminEvent.getResourcePath());
adminEventEntity.setError(adminEvent.getError());
if (includeRepresentation) {
adminEventEntity.setRepresentation(adminEvent.getRepresentation());
}
try {
adminEventEntity.setDetailsJson(mapper.writeValueAsString(adminEvent.getDetails()));
} catch (IOException ex) {
logger.error("Failed to write log details", ex);
}
return adminEventEntity;
}
@ -210,20 +217,28 @@ public class JpaEventStoreProvider implements EventStoreProvider {
adminEvent.setResourcePath(adminEventEntity.getResourcePath());
adminEvent.setError(adminEventEntity.getError());
if (adminEventEntity.getRepresentation() != null) {
adminEvent.setRepresentation(adminEventEntity.getRepresentation());
}
try {
Map<String, String> details = mapper.readValue(adminEventEntity.getDetailsJson(), mapType);
adminEvent.setDetails(details);
} catch (IOException ex) {
logger.error("Failed to read log details", ex);
}
return adminEvent;
}
private static void setAuthDetails(AdminEventEntity adminEventEntity, AuthDetails authDetails) {
adminEventEntity.setAuthRealmId(authDetails.getRealmId());
adminEventEntity.setAuthClientId(authDetails.getClientId());
adminEventEntity.setAuthUserId(authDetails.getUserId());
adminEventEntity.setAuthIpAddress(authDetails.getIpAddress());
}
private static void setAuthDetails(AdminEvent adminEvent, AdminEventEntity adminEventEntity) {
AuthDetails authDetails = new AuthDetails();
authDetails.setRealmId(adminEventEntity.getAuthRealmId());

View File

@ -191,6 +191,7 @@ public class JpaOrganizationProvider implements OrganizationProvider {
}
user.joinGroup(group, metadata);
OrganizationModel.OrganizationMemberJoinEvent.fire(organization, user, session);
} finally {
if (current == null) {
session.getContext().setOrganization(null);
@ -430,6 +431,8 @@ public class JpaOrganizationProvider implements OrganizationProvider {
}
}
OrganizationModel.OrganizationMemberLeaveEvent.fire(organization, member, session);
return true;
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ * Copyright 2024 Red Hat, Inc. and/or its affiliates
~ * and other contributors as indicated by the @author tags.
~ *
~ * 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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="keycloak" id="26.0.6-34013">
<addColumn tableName="ADMIN_EVENT_ENTITY">
<column name="DETAILS_JSON" type="NCLOB" />
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@ -84,5 +84,6 @@
<include file="META-INF/jpa-changelog-24.0.2.xml"/>
<include file="META-INF/jpa-changelog-25.0.0.xml"/>
<include file="META-INF/jpa-changelog-26.0.0.xml"/>
<include file="META-INF/jpa-changelog-26.0.6.xml"/>
</databaseChangeLog>

View File

@ -17,6 +17,9 @@
package org.keycloak.events.admin;
import java.util.HashMap;
import java.util.Map;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -25,7 +28,7 @@ public class AdminEvent {
private String id;
private long time;
private String realmId;
private String realmName;
@ -44,7 +47,9 @@ public class AdminEvent {
private String representation;
private String error;
private Map<String, String> details;
public AdminEvent() {}
public AdminEvent(AdminEvent toCopy) {
this.id = toCopy.getId();
@ -57,6 +62,7 @@ public class AdminEvent {
this.resourcePath = toCopy.getResourcePath();
this.representation = toCopy.getRepresentation();
this.error = toCopy.getError();
this.details = toCopy.getDetails() == null ? null : new HashMap<>(toCopy.getDetails());
}
/**
@ -84,7 +90,7 @@ public class AdminEvent {
public void setTime(long time) {
this.time = time;
}
/**
* Returns the id of the realm
*
@ -216,4 +222,12 @@ public class AdminEvent {
public void setResourceTypeAsString(String resourceType) {
this.resourceType = resourceType;
}
public Map<String, String> getDetails() {
return details;
}
public void setDetails(Map<String, String> details) {
this.details = details;
}
}

View File

@ -308,6 +308,7 @@ public class ModelToRepresentation {
rep.setResourcePath(adminEvent.getResourcePath());
rep.setRepresentation(adminEvent.getRepresentation());
rep.setError(adminEvent.getError());
rep.setDetails(adminEvent.getDetails());
return rep;
}

View File

@ -22,6 +22,8 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.keycloak.provider.ProviderEvent;
public interface OrganizationModel {
String ORGANIZATION_ATTRIBUTE = "kc.org";
@ -47,6 +49,54 @@ public interface OrganizationModel {
}
}
interface OrganizationMembershipEvent extends ProviderEvent {
OrganizationModel getOrganization();
UserModel getUser();
KeycloakSession getSession();
}
interface OrganizationMemberJoinEvent extends OrganizationMembershipEvent {
static void fire(OrganizationModel organization, UserModel user, KeycloakSession session) {
session.getKeycloakSessionFactory().publish(new OrganizationModel.OrganizationMemberJoinEvent() {
@Override
public UserModel getUser() {
return user;
}
@Override
public OrganizationModel getOrganization() {
return organization;
}
@Override
public KeycloakSession getSession() {
return session;
}
});
}
}
interface OrganizationMemberLeaveEvent extends OrganizationMembershipEvent {
static void fire(OrganizationModel organization, UserModel user, KeycloakSession session) {
session.getKeycloakSessionFactory().publish(new OrganizationModel.OrganizationMemberLeaveEvent() {
@Override
public UserModel getUser() {
return user;
}
@Override
public OrganizationModel getOrganization() {
return organization;
}
@Override
public KeycloakSession getSession() {
return session;
}
});
}
}
String getId();
void setName(String name);

View File

@ -165,6 +165,15 @@ public class JBossLoggingEventListenerProvider implements EventListenerProvider
sanitize(sb, adminEvent.getError());
}
if (adminEvent.getDetails() != null) {
for (Map.Entry<String, String> e : adminEvent.getDetails().entrySet()) {
sb.append(", ");
sb.append(StringUtil.sanitizeSpacesAndQuotes(e.getKey(), null));
sb.append("=");
sanitize(sb, e.getValue());
}
}
if(logger.isTraceEnabled()) {
setKeycloakContext(sb);
}

View File

@ -94,6 +94,8 @@ public class OrganizationMemberResource {
adminEvent.operation(OperationType.CREATE).resource(ResourceType.ORGANIZATION_MEMBERSHIP)
.representation(ModelToRepresentation.toRepresentation(organization))
.resourcePath(session.getContext().getUri())
.detail(UserModel.USERNAME, user.getUsername())
.detail(UserModel.EMAIL, user.getEmail())
.success();
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(user.getId()).build()).build();
}
@ -172,6 +174,8 @@ public class OrganizationMemberResource {
adminEvent.operation(OperationType.DELETE).resource(ResourceType.ORGANIZATION_MEMBERSHIP)
.representation(ModelToRepresentation.toRepresentation(organization))
.resourcePath(session.getContext().getUri())
.detail(UserModel.USERNAME, member.getUsername())
.detail(UserModel.EMAIL, member.getEmail())
.success();
return Response.noContent().build();
}

View File

@ -37,6 +37,8 @@ import org.keycloak.services.ServicesLogger;
import org.keycloak.util.JsonSerialization;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.utils.StringUtil;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@ -264,6 +266,20 @@ public class AdminEventBuilder {
return this;
}
public AdminEventBuilder detail(String key, String value) {
if (StringUtil.isBlank(value)) {
return this;
}
if (adminEvent.getDetails() == null) {
adminEvent.setDetails(new HashMap<>());
}
adminEvent.getDetails().put(key, value);
return this;
}
public AdminEvent getEvent() {
return adminEvent;
}

View File

@ -1030,7 +1030,13 @@ public class UserResource {
try {
if (user.isMemberOf(group)){
user.leaveGroup(group);
adminEvent.operation(OperationType.DELETE).resource(ResourceType.GROUP_MEMBERSHIP).representation(ModelToRepresentation.toRepresentation(group, true)).resourcePath(session.getContext().getUri()).success();
adminEvent.operation(OperationType.DELETE)
.resource(ResourceType.GROUP_MEMBERSHIP)
.representation(ModelToRepresentation.toRepresentation(group, true))
.resourcePath(session.getContext().getUri())
.detail(UserModel.USERNAME, user.getUsername())
.detail(UserModel.EMAIL, user.getEmail())
.success();
}
} catch (ModelIllegalStateException e) {
logger.error(e.getMessage(), e);
@ -1057,7 +1063,13 @@ public class UserResource {
if (!RoleUtils.isDirectMember(user.getGroupsStream(),group)){
user.joinGroup(group);
adminEvent.operation(OperationType.CREATE).resource(ResourceType.GROUP_MEMBERSHIP).representation(ModelToRepresentation.toRepresentation(group, true)).resourcePath(session.getContext().getUri()).success();
adminEvent.operation(OperationType.CREATE)
.resource(ResourceType.GROUP_MEMBERSHIP)
.representation(ModelToRepresentation.toRepresentation(group, true))
.resourcePath(session.getContext().getUri())
.detail(UserModel.USERNAME, user.getUsername())
.detail(UserModel.EMAIL, user.getEmail())
.success();
}
}

View File

@ -23,8 +23,12 @@ import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.AuthDetailsRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmEventsConfigRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
@ -35,6 +39,7 @@ import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
@ -112,6 +117,30 @@ public class AdminEventTest extends AbstractEventTest {
assertThat(details.getIpAddress(), is(notNullValue()));
}
@Test
public void testEventDetails() {
String userId = createUser("user5");
UserRepresentation userRep = testRealmResource().users().get(userId).toRepresentation();
RealmRepresentation realmRep = testRealmResource().toRepresentation();
realmRep.setOrganizationsEnabled(true);
testRealmResource().update(realmRep);
OrganizationRepresentation orgRep = new OrganizationRepresentation();
orgRep.setName("test-org");
orgRep.setAlias(orgRep.getName());
orgRep.addDomain(new OrganizationDomainRepresentation(orgRep.getName()));
testRealmResource().organizations().create(orgRep).close();
orgRep = testRealmResource().organizations().getAll().get(0);
testRealmResource().organizations().get(orgRep.getId()).members().addMember(userId).close();
List<AdminEventRepresentation> events = events();
assertThat(events().size(), is(equalTo(4)));
AdminEventRepresentation event = events.get(0);
assertThat(event.getRealmId(), is(equalTo(realmName())));
assertThat(event.getOperationType(), is(equalTo("CREATE")));
assertThat(event.getResourceType(), is(equalTo(ResourceType.ORGANIZATION_MEMBERSHIP.name())));
assertThat(event.getDetails(), is(equalTo(Map.of(UserModel.USERNAME, userRep.getUsername(), UserModel.EMAIL, userRep.getEmail()))));
}
@Test
public void retrieveAdminEventTest() {
createUser("user1");