introduce expiration option for admin events

This commit is contained in:
Thomas Peter 2022-06-30 11:39:34 +02:00 committed by Hynek Mlnařík
parent a6137b9b86
commit 19d69169b1
11 changed files with 213 additions and 17 deletions

View file

@ -31,14 +31,25 @@ import org.keycloak.events.admin.AuthDetails;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.jpa.entities.RealmAttributeEntity;
import org.keycloak.models.jpa.entities.RealmAttributes;
import org.keycloak.models.jpa.entities.RealmEntity;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.InvalidationHandler;
import org.keycloak.timer.ScheduledTask;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.Query;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Root;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -261,4 +272,21 @@ public class JpaEventStoreProvider implements EventStoreProvider {
adminEvent.setAuthDetails(authDetails); adminEvent.setAuthDetails(authDetails);
} }
protected void clearExpiredAdminEvents() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<RealmAttributeEntity> cr = cb.createQuery(RealmAttributeEntity.class);
Root<RealmAttributeEntity> root = cr.from(RealmAttributeEntity.class);
cr.select(root).where(cb.and(cb.equal(root.get("name"),RealmAttributes.ADMIN_EVENTS_EXPIRATION),cb.greaterThan(root.get("value"),Long.valueOf(0))));
Map<Long, List<RealmAttributeEntity>> realms = em.createQuery(cr).getResultStream().collect(Collectors.groupingBy(attribute -> Long.valueOf(attribute.getValue())));
long current = Time.currentTimeMillis();
realms.entrySet().forEach(entry -> {
List<String> realmIds = entry.getValue().stream().map(RealmAttributeEntity::getRealm).map(RealmEntity::getId).collect(Collectors.toList());
int currentNumDeleted = em.createQuery("delete from AdminEventEntity where realmId in :realmIds and time < :eventTime")
.setParameter("realmIds", realmIds)
.setParameter("eventTime", current - (Long.valueOf(entry.getKey()) * 1000))
.executeUpdate();
logger.tracef("Deleted %d admin events for the expiration %d", currentNumDeleted, entry.getKey());
});
}
} }

View file

@ -18,16 +18,19 @@
package org.keycloak.events.jpa; package org.keycloak.events.jpa;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.events.EventStoreProvider; import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventStoreProviderFactory; import org.keycloak.events.EventStoreProviderFactory;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.InvalidationHandler;
import org.keycloak.storage.datastore.PeriodicEventInvalidation;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
public class JpaEventStoreProviderFactory implements EventStoreProviderFactory { public class JpaEventStoreProviderFactory implements EventStoreProviderFactory, InvalidationHandler {
public static final String ID = "jpa"; public static final String ID = "jpa";
private int maxDetailLength; private int maxDetailLength;
@ -57,4 +60,10 @@ public class JpaEventStoreProviderFactory implements EventStoreProviderFactory {
return ID; return ID;
} }
@Override
public void invalidate(KeycloakSession session, InvalidableObjectType type, Object... params) {
if(type == PeriodicEventInvalidation.JPA_EVENT_STORE) {
((JpaEventStoreProvider) session.getProvider(EventStoreProvider.class)).clearExpiredAdminEvents();
}
}
} }

View file

@ -51,4 +51,6 @@ public interface RealmAttributes {
String WEBAUTHN_POLICY_AVOID_SAME_AUTHENTICATOR_REGISTER = "webAuthnPolicyAvoidSameAuthenticatorRegister"; String WEBAUTHN_POLICY_AVOID_SAME_AUTHENTICATOR_REGISTER = "webAuthnPolicyAvoidSameAuthenticatorRegister";
String WEBAUTHN_POLICY_ACCEPTABLE_AAGUIDS = "webAuthnPolicyAcceptableAaguids"; String WEBAUTHN_POLICY_ACCEPTABLE_AAGUIDS = "webAuthnPolicyAcceptableAaguids";
String ADMIN_EVENTS_EXPIRATION = "adminEventsExpiration";
} }

View file

@ -0,0 +1,40 @@
/*
* Copyright 2016 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.
*/
package org.keycloak.services.scheduled;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventStoreProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.InvalidationHandler;
import org.keycloak.storage.datastore.PeriodicEventInvalidation;
import org.keycloak.timer.ScheduledTask;
public class ClearExpiredAdminEvents implements ScheduledTask {
protected static final Logger logger = Logger.getLogger(ClearExpiredAdminEvents.class);
@Override
public void run(KeycloakSession session) {
long currentTimeMillis = Time.currentTimeMillis();
session.invalidate(PeriodicEventInvalidation.JPA_EVENT_STORE);
long took = Time.currentTimeMillis() - currentTimeMillis;
logger.debugf("ClearExpiredEvents finished in %d ms", took);
}
}

View file

@ -27,6 +27,7 @@ import org.keycloak.models.utils.PostMigrationEvent;
import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderEvent; import org.keycloak.provider.ProviderEvent;
import org.keycloak.provider.ProviderEventListener; import org.keycloak.provider.ProviderEventListener;
import org.keycloak.services.scheduled.ClearExpiredAdminEvents;
import org.keycloak.services.scheduled.ClearExpiredClientInitialAccessTokens; import org.keycloak.services.scheduled.ClearExpiredClientInitialAccessTokens;
import org.keycloak.services.scheduled.ClearExpiredEvents; import org.keycloak.services.scheduled.ClearExpiredEvents;
import org.keycloak.services.scheduled.ClearExpiredUserSessions; import org.keycloak.services.scheduled.ClearExpiredUserSessions;
@ -102,6 +103,7 @@ public class LegacyDatastoreProviderFactory implements DatastoreProviderFactory,
TimerProvider timer = session.getProvider(TimerProvider.class); TimerProvider timer = session.getProvider(TimerProvider.class);
if (timer != null) { if (timer != null) {
timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredEvents(), interval), interval, "ClearExpiredEvents"); timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredEvents(), interval), interval, "ClearExpiredEvents");
timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredAdminEvents(), interval), interval, "ClearExpiredAdminEvents");
timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredClientInitialAccessTokens(), interval), interval, "ClearExpiredClientInitialAccessTokens"); timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredClientInitialAccessTokens(), interval), interval, "ClearExpiredClientInitialAccessTokens");
timer.schedule(new ScheduledTaskRunner(sessionFactory, new ClearExpiredUserSessions()), interval, ClearExpiredUserSessions.TASK_NAME); timer.schedule(new ScheduledTaskRunner(sessionFactory, new ClearExpiredUserSessions()), interval, ClearExpiredUserSessions.TASK_NAME);
UserStorageSyncManager.bootstrapPeriodic(sessionFactory, timer); UserStorageSyncManager.bootstrapPeriodic(sessionFactory, timer);

View file

@ -0,0 +1,7 @@
package org.keycloak.storage.datastore;
import org.keycloak.provider.InvalidationHandler;
public enum PeriodicEventInvalidation implements InvalidationHandler.InvalidableObjectType {
JPA_EVENT_STORE,
}

View file

@ -135,8 +135,16 @@ public class MapEventStoreProvider implements EventStoreProvider {
if (id != null && authEventsTX.read(id) != null) { if (id != null && authEventsTX.read(id) != null) {
throw new ModelDuplicateException("Event already exists: " + id); throw new ModelDuplicateException("Event already exists: " + id);
} }
String realmId = event.getRealmId();
adminEventsTX.create(modelToEntity(event, includeRepresentation)); MapAdminEventEntity entity = modelToEntity(event,includeRepresentation);
if (realmId != null) {
RealmModel realm = session.realms().getRealm(realmId);
Long expiration = realm.getAttribute("adminEventsExpiration",0L);
if (realm != null && expiration > 0) {
entity.setExpiration(Time.currentTimeMillis() + (expiration * 1000));
}
}
adminEventsTX.create(entity);
} }
@Override @Override

View file

@ -64,6 +64,7 @@ import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.scheduled.ClearExpiredUserSessions; import org.keycloak.services.scheduled.ClearExpiredUserSessions;
import org.keycloak.services.util.CookieHelper; import org.keycloak.services.util.CookieHelper;
import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.datastore.PeriodicEventInvalidation;
import org.keycloak.testsuite.components.TestProvider; import org.keycloak.testsuite.components.TestProvider;
import org.keycloak.testsuite.components.TestProviderFactory; import org.keycloak.testsuite.components.TestProviderFactory;
import org.keycloak.testsuite.components.amphibian.TestAmphibianProvider; import org.keycloak.testsuite.components.amphibian.TestAmphibianProvider;
@ -315,9 +316,11 @@ public class TestingResourceProvider implements RealmResourceProvider {
public Response clearExpiredEvents() { public Response clearExpiredEvents() {
EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class);
eventStore.clearExpiredEvents(); eventStore.clearExpiredEvents();
session.invalidate(PeriodicEventInvalidation.JPA_EVENT_STORE);
return Response.noContent().build(); return Response.noContent().build();
} }
/** /**
* Query events * Query events
* <p> * <p>

View file

@ -19,10 +19,14 @@ package org.keycloak.testsuite.events;
import org.junit.After; import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test; import org.junit.Test;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.models.jpa.entities.RealmAttributes;
import org.keycloak.representations.idm.AdminEventRepresentation; import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.AuthDetailsRepresentation; import org.keycloak.representations.idm.AuthDetailsRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import java.text.ParseException; import java.text.ParseException;
@ -30,7 +34,9 @@ import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @author <a href="mailto:giriraj.sharma27@gmail.com">Giriraj Sharma</a> * @author <a href="mailto:giriraj.sharma27@gmail.com">Giriraj Sharma</a>
@ -192,6 +198,52 @@ public class AdminEventStoreProviderTest extends AbstractEventsTest {
Assert.assertEquals(2, testing().getAdminEvents(null, null, null, null, null, null, null, null, null, null, null).size()); Assert.assertEquals(2, testing().getAdminEvents(null, null, null, null, null, null, null, null, null, null, null).size());
} }
@Test
public void expireOld() {
Assume.assumeTrue("Map storage event store provider does not support changing expiration of existing events", keycloakUsingProviderWithId(EventStoreProvider.class, "jpa"));
testing().onAdminEvent(create(System.currentTimeMillis() - 30000, realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
testing().onAdminEvent(create(System.currentTimeMillis() - 20000, realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
testing().onAdminEvent(create(System.currentTimeMillis(), realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
testing().onAdminEvent(create(System.currentTimeMillis(), realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
testing().onAdminEvent(create(System.currentTimeMillis() - 30000, realmId2, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
testing().onAdminEvent(create(System.currentTimeMillis(), realmId2, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "error"), false);
// Set expiration of events for realmId .
RealmRepresentation realm = realmsResouce().realm(REALM_NAME_1).toRepresentation();
Map<String, String> attributes = realm.getAttributes();
attributes.put(RealmAttributes.ADMIN_EVENTS_EXPIRATION,"10");
realm.setAttributes(attributes);
realmsResouce().realm(REALM_NAME_1).update(realm);
// The first 2 events from realmId will be deleted
testing().clearExpiredEvents();
Assert.assertEquals(4, testing().getAdminEvents(null, null, null, null, null, null, null, null, null, null, null).size());
// Set expiration of events for realmId2 as well
RealmRepresentation realm2 = realmsResouce().realm(REALM_NAME_2).toRepresentation();
Map<String, String> attributes2 = realm2.getAttributes();
attributes2.put(RealmAttributes.ADMIN_EVENTS_EXPIRATION,"10");
realm2.setAttributes(attributes2);
realmsResouce().realm(REALM_NAME_2).update(realm2);
// The first event from realmId2 will be deleted now
testing().clearExpiredEvents();
Assert.assertEquals(3, testing().getAdminEvents(null, null, null, null, null, null, null, null, null, null, null).size());
// set time offset to the future. The remaining 2 events from realmId and 1 event from realmId2 should be expired now
setTimeOffset(150);
testing().clearExpiredEvents();
Assert.assertEquals(0, testing().getAdminEvents(REALM_NAME_1, null, null, null, null, null, null, null, null, null, null).size());
// Revert expirations
attributes.put(RealmAttributes.ADMIN_EVENTS_EXPIRATION,"0");
realm.setAttributes(attributes);
realmsResouce().realm(REALM_NAME_1).update(realm);
attributes2.put(RealmAttributes.ADMIN_EVENTS_EXPIRATION,"0");
realm2.setAttributes(attributes2);
realmsResouce().realm(REALM_NAME_2).update(realm2);
}
@Test @Test
public void handleCustomResourceTypeEvents() { public void handleCustomResourceTypeEvents() {
testing().onAdminEvent(create(realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "my-custom-resource", "error"), false); testing().onAdminEvent(create(realmId, OperationType.CREATE, realmId, "clientId", "userId", "127.0.0.1", "/admin/realms/master", "my-custom-resource", "error"), false);

View file

@ -2438,14 +2438,16 @@ module.controller('RealmSMTPSettingsCtrl', function($scope, Current, Realm, real
} }
}); });
module.controller('RealmEventsConfigCtrl', function($scope, eventsConfig, RealmEventsConfig, RealmEvents, RealmAdminEvents, realm, serverInfo, $location, Notifications, TimeUnit, Dialog) { module.controller('RealmEventsConfigCtrl', function($scope, eventsConfig, RealmEventsConfig, RealmEvents, RealmAdminEvents, realm, serverInfo, $location, Notifications, TimeUnit, Dialog, Realm) {
$scope.realm = realm; $scope.realm = realm;
$scope.eventsConfig = eventsConfig; $scope.eventsConfig = eventsConfig;
$scope.eventsConfig.expirationUnit = TimeUnit.autoUnit(eventsConfig.eventsExpiration); $scope.eventsConfig.expirationUnit = TimeUnit.autoUnit(eventsConfig.eventsExpiration);
$scope.eventsConfig.eventsExpiration = TimeUnit.toUnit(eventsConfig.eventsExpiration, $scope.eventsConfig.expirationUnit); $scope.eventsConfig.eventsExpiration = TimeUnit.toUnit(eventsConfig.eventsExpiration, $scope.eventsConfig.expirationUnit);
$scope.realm.attributes.adminEventsExpirationUnit = TimeUnit.autoUnit(realm.attributes.adminEventsExpiration);
$scope.realm.attributes.adminEventsExpiration = TimeUnit.toUnit(realm.attributes.adminEventsExpiration, $scope.realm.attributes.adminEventsExpirationUnit);
$scope.eventListeners = Object.keys(serverInfo.providers.eventsListener.providers); $scope.eventListeners = Object.keys(serverInfo.providers.eventsListener.providers);
$scope.eventsConfigSelectOptions = { $scope.eventsConfigSelectOptions = {
@ -2460,34 +2462,66 @@ module.controller('RealmEventsConfigCtrl', function($scope, eventsConfig, RealmE
'tags': serverInfo.enums['eventType'] 'tags': serverInfo.enums['eventType']
}; };
var oldCopy = angular.copy($scope.eventsConfig);
$scope.changed = false; $scope.changed = false;
var oldCopy = angular.copy($scope.eventsConfig);
$scope.configChanged = false;
$scope.$watch('eventsConfig', function() { $scope.$watch('eventsConfig', function() {
if (!angular.equals($scope.eventsConfig, oldCopy)) { if (!angular.equals($scope.eventsConfig, oldCopy)) {
$scope.configChanged = true;
$scope.changed = true; $scope.changed = true;
} }
}, true); }, true);
$scope.attributesChanged = false;
var oldAttributes = angular.copy($scope.realm.attributes)
$scope.$watch('realm.attributes', function() {
if($scope.realm.attributes.adminEventsExpiration != oldAttributes.adminEventsExpiration || $scope.realm.attributes.adminEventsExpirationUnit != oldAttributes.adminEventsExpirationUnit)
$scope.attributesChanged = true;
$scope.changed = true;
},true);
$scope.save = function() { $scope.save = function() {
$scope.changed = false; $scope.changed = false;
var successFunction = function(){
var copy = angular.copy($scope.eventsConfig)
delete copy['expirationUnit'];
copy.eventsExpiration = TimeUnit.toSeconds($scope.eventsConfig.eventsExpiration, $scope.eventsConfig.expirationUnit);
RealmEventsConfig.update({
id : realm.realm
}, copy, function () {
$location.url("/realms/" + realm.realm + "/events-settings"); $location.url("/realms/" + realm.realm + "/events-settings");
Notifications.success("Your changes have been saved to the realm."); Notifications.success("Your changes have been saved to the realm.");
}); };
var updateAttributes = function (){
$scope.attributesChanged = false;
var realmCopy = angular.copy($scope.realm)
delete realmCopy.attributes['adminEventsExpirationUnit'];
realmCopy.attributes.adminEventsExpiration = TimeUnit.toSeconds($scope.realm.attributes.adminEventsExpiration, $scope.realm.attributes.adminEventsExpirationUnit);
Realm.update({id: realm.realm},realmCopy,successFunction)
};
if($scope.configChanged) {
var copy = angular.copy($scope.eventsConfig)
delete copy['expirationUnit'];
copy.eventsExpiration = TimeUnit.toSeconds($scope.eventsConfig.eventsExpiration, $scope.eventsConfig.expirationUnit);
RealmEventsConfig.update({
id: realm.realm
}, copy, function () {
$scope.configChanged = false;
if($scope.attributesChanged){
updateAttributes()
}else{
successFunction()
}
});
} else if($scope.attributesChanged){
updateAttributes()
}
}; };
$scope.reset = function() { $scope.reset = function() {
$scope.eventsConfig = angular.copy(oldCopy); $scope.eventsConfig = angular.copy(oldCopy);
$scope.changed = false; $scope.changed = false;
$scope.realm.attributes.adminEventsExpiration = oldAttributes.adminEventsExpiration;
$scope.realm.attributes.adminEventsExpirationUnit = oldAttributes.adminEventsExpirationUnit;
$scope.attributesChanged = false;
}; };
$scope.clearEvents = function() { $scope.clearEvents = function() {

View file

@ -91,7 +91,18 @@
<button class="btn btn-danger" type="submit" data-ng-click="clearAdminEvents()" >{{:: 'clear-admin-events' | translate}}</button> <button class="btn btn-danger" type="submit" data-ng-click="clearAdminEvents()" >{{:: 'clear-admin-events' | translate}}</button>
</div> </div>
</div> </div>
<div class="form-group" data-ng-show="eventsConfig.adminEventsEnabled">
<label class="col-md-2 control-label" for="expiration">{{:: 'expiration' | translate}}</label>
<kc-tooltip>{{:: 'events.expiration.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" data-ng-model="realm.attributes.adminEventsExpiration" id="adminEventsExpiration" name="adminEventsExpiration" min="0"/>
<select class="form-control" name="adminEventsExpirationUnit" data-ng-model="realm.attributes.adminEventsExpirationUnit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
</div>
</fieldset> </fieldset>
<div class="form-group" data-ng-show="access.manageEvents"> <div class="form-group" data-ng-show="access.manageEvents">