This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 684f6c882 UNOMI-1031: Fail closed on an empty authorized-IP set for 
restricted event types (#942)
684f6c882 is described below

commit 684f6c882fe82f546ee9344b43fc18d08ec03e2d
Author: Serge Huber <[email protected]>
AuthorDate: Mon Nov 2 14:18:56 2026 +0100

    UNOMI-1031: Fail closed on an empty authorized-IP set for restricted event 
types (#942)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/unomi/api/services/EventService.java    |   9 +-
 .../graphql/commands/ProcessEventsCommand.java     |  20 ++--
 .../graphql/commands/ProcessEventsCommandTest.java |  20 +++-
 .../org/apache/unomi/itests/ContextServletIT.java  | 106 ++++++++++++++++++---
 .../asciidoc/migrations/migrate-3.0-to-4.0.adoc    |   4 +
 manual/src/main/asciidoc/multitenancy.adoc         |   7 ++
 .../rest/service/impl/RestServiceUtilsImpl.java    |   9 +-
 .../RestServiceUtilsImplProfileBindingTest.java    |  49 ++++++++++
 .../services/impl/events/EventServiceImpl.java     |  21 +++-
 .../services/impl/events/EventServiceImplTest.java |  31 ++++++
 10 files changed, 248 insertions(+), 28 deletions(-)

diff --git a/api/src/main/java/org/apache/unomi/api/services/EventService.java 
b/api/src/main/java/org/apache/unomi/api/services/EventService.java
index d6634bce2..68e036f40 100644
--- a/api/src/main/java/org/apache/unomi/api/services/EventService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/EventService.java
@@ -62,8 +62,13 @@ public interface EventService {
     int send(Event event);
 
     /**
-     * Checks whether the tenant may send the given event.
-     * Restricted event types must be explicitly allowed per tenant.
+     * Checks whether the tenant may receive the given event from a client.
+     * Restricted event types (the globally restricted ones plus the tenant's 
own restricted event
+     * types) are accepted only from a source IP in the tenant's authorized 
IPs: a tenant with no
+     * authorized IP, or a {@code null} source IP, refuses them. Other event 
types are allowed. A
+     * {@code null} event, a {@code null} tenant id or an unknown tenant 
refuses every event.
+     * This method does not consider the caller's privileges; callers that 
trust the caller (system or
+     * tenant administrator) skip it.
      *
      * @param event event to test
      * @param tenantId tenant identifier
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/ProcessEventsCommand.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/ProcessEventsCommand.java
index 5d4147f58..554af13bc 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/ProcessEventsCommand.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/ProcessEventsCommand.java
@@ -21,6 +21,7 @@ import graphql.schema.GraphQLInputObjectField;
 import graphql.schema.GraphQLInputObjectType;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.services.ProfileService;
@@ -166,7 +167,8 @@ public class ProcessEventsCommand extends 
BaseCommand<Integer> {
         final EventService eventService = 
serviceManager.getService(EventService.class);
 
         if (!isEventAllowedForCurrentTenant(event, eventService,
-                serviceManager.getService(ExecutionContextManager.class))) {
+                serviceManager.getService(ExecutionContextManager.class),
+                serviceManager.getService(SecurityService.class))) {
             LOGGER.debug("Event type {} is not authorized for this tenant, 
skipping it", event.getEventType());
             return;
         }
@@ -180,14 +182,20 @@ public class ProcessEventsCommand extends 
BaseCommand<Integer> {
     }
 
     /**
-     * Same restricted-event-type check as REST {@code EventService#send} 
callers. GraphQL does not
-     * currently thread a client IP through this command, so the source IP is 
{@code null}: a
-     * restricted type is refused when the tenant has an authorized-IP list, 
and unrestricted types
-     * still pass.
+     * Same restricted-event-type check as the REST event endpoints
+     * ({@link EventService#isEventAllowedForTenant}). A tenant or system
+     * administrator can already change any profile through the private API, 
so it is not gated.
+     * GraphQL does not thread a client IP through this command, so for any 
other caller (public API
+     * key) the source IP is {@code null}: a restricted type is always 
refused, and unrestricted types
+     * still pass. Without a tenant in the execution context, every event is 
refused.
      */
     static boolean isEventAllowedForCurrentTenant(final Event event,
                                                   final EventService 
eventService,
-                                                  final 
ExecutionContextManager executionContextManager) {
+                                                  final 
ExecutionContextManager executionContextManager,
+                                                  final SecurityService 
securityService) {
+        if (securityService.hasSystemAccess()) {
+            return true;
+        }
         final ExecutionContext executionContext = executionContextManager != 
null
                 ? executionContextManager.getCurrentContext() : null;
         final String tenantId = executionContext != null ? 
executionContext.getTenantId() : null;
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/ProcessEventsCommandTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/ProcessEventsCommandTest.java
index a387637c9..8f12da647 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/ProcessEventsCommandTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/ProcessEventsCommandTest.java
@@ -19,6 +19,7 @@ package org.apache.unomi.graphql.commands;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.ExecutionContext;
 import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.ExecutionContextManager;
 import org.junit.jupiter.api.Test;
@@ -30,6 +31,8 @@ import java.util.Date;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -44,6 +47,8 @@ class ProcessEventsCommandTest {
     private ExecutionContextManager executionContextManager;
     @Mock
     private ExecutionContext executionContext;
+    @Mock
+    private SecurityService securityService;
 
     @Test
     void allowsEventWhenTenantGatePasses() {
@@ -52,7 +57,7 @@ class ProcessEventsCommandTest {
         when(executionContext.getTenantId()).thenReturn(TENANT_ID);
         when(eventService.isEventAllowedForTenant(event, TENANT_ID, 
null)).thenReturn(true);
 
-        assertTrue(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, executionContextManager));
+        assertTrue(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, executionContextManager, securityService));
         verify(eventService).isEventAllowedForTenant(event, TENANT_ID, null);
     }
 
@@ -63,7 +68,7 @@ class ProcessEventsCommandTest {
         when(executionContext.getTenantId()).thenReturn(TENANT_ID);
         when(eventService.isEventAllowedForTenant(event, TENANT_ID, 
null)).thenReturn(false);
 
-        assertFalse(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, executionContextManager));
+        assertFalse(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, executionContextManager, securityService));
         verify(eventService).isEventAllowedForTenant(event, TENANT_ID, null);
     }
 
@@ -72,10 +77,19 @@ class ProcessEventsCommandTest {
         Event event = viewEvent();
         when(eventService.isEventAllowedForTenant(event, null, 
null)).thenReturn(false);
 
-        assertFalse(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, null));
+        assertFalse(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, null, securityService));
         verify(eventService).isEventAllowedForTenant(event, null, null);
     }
 
+    @Test
+    void trustedCallerSkipsTenantGate() {
+        Event event = new Event("updateProperties", null, new Profile(), null, 
null, null, new Date());
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        assertTrue(ProcessEventsCommand.isEventAllowedForCurrentTenant(event, 
eventService, executionContextManager, securityService));
+        verify(eventService, never()).isEventAllowedForTenant(any(), any(), 
any());
+    }
+
     private static Event viewEvent() {
         return new Event("view", null, new Profile(), null, null, null, new 
Date());
     }
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index 850b04929..19435b7e9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -70,7 +70,9 @@ import static org.junit.Assert.*;
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
 public class ContextServletIT extends BaseIT {
+
     private final static Logger LOGGER = 
LoggerFactory.getLogger(ContextServletIT.class);
+    private static final Set<String> LOOPBACK_IPS = new 
HashSet<>(Arrays.asList("127.0.0.1", "::1"));
 
     private final static String CONTEXT_URL = "/cxs/context.json";
 
@@ -474,19 +476,101 @@ public class ContextServletIT extends BaseIT {
         props.put("update", toUpdate);
         updateEvent.setProperties(props);
 
-        ContextRequest probe = new ContextRequest();
-        probe.setSessionId(sessionId);
-        probe.setEvents(Collections.singletonList(updateEvent));
-        HttpPost probeRequest = new HttpPost(getFullUrl(CONTEXT_URL));
-        addPublicTenantAuth(probeRequest);
-        probeRequest.addHeader("Cookie", established.getCookieHeaderValue());
-        probeRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(probe), 
ContentType.APPLICATION_JSON));
-        executeContextJSONRequest(probeRequest, sessionId);
-
-        shouldBeTrueUntilEnd("Other profile must not be updated by public 
updateProperties",
-                () -> profileService.load(otherId),
+        // Authorize the caller's IP, so the probe passes the protected-event 
IP check and reaches
+        // the profile ownership check this test is about.
+        setTestTenantAuthorizedIPs(LOOPBACK_IPS);
+        try {
+            waitForOwnProfileUpdateAccepted(sessionId, 
established.getCookieHeaderValue(),
+                    established.getContextResponse().getProfileId(), "OWN");
+
+            ContextRequest probe = new ContextRequest();
+            probe.setSessionId(sessionId);
+            probe.setEvents(Collections.singletonList(updateEvent));
+            HttpPost probeRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+            addPublicTenantAuth(probeRequest);
+            probeRequest.addHeader("Cookie", 
established.getCookieHeaderValue());
+            probeRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(probe), 
ContentType.APPLICATION_JSON));
+            executeContextJSONRequest(probeRequest, sessionId);
+
+            shouldBeTrueUntilEnd("Other profile must not be updated by public 
updateProperties",
+                    () -> profileService.load(otherId),
+                    p -> p.getProperty("firstName") == null,
+                    DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+        } finally {
+            setTestTenantAuthorizedIPs(Collections.emptySet());
+        }
+    }
+
+    @Test
+    public void testPublicHttp_protectedEventNeedsAuthorizedIP() throws 
Exception {
+        String sessionId = "protected-ip-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        String profileId = established.getContextResponse().getProfileId();
+        keepTrying("Profile not found", () -> profileService.load(profileId), 
Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // The test tenant authorizes no IP: a protected event from a public 
caller is refused,
+        // even on its own profile.
+        sendOwnProfileUpdate(sessionId, established.getCookieHeaderValue(), 
profileId, "REFUSED");
+        shouldBeTrueUntilEnd("Protected event must be refused while the tenant 
authorizes no IP",
+                () -> profileService.load(profileId),
                 p -> p.getProperty("firstName") == null,
                 DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+
+        setTestTenantAuthorizedIPs(LOOPBACK_IPS);
+        try {
+            waitForOwnProfileUpdateAccepted(sessionId, 
established.getCookieHeaderValue(), profileId, "ACCEPTED");
+        } finally {
+            setTestTenantAuthorizedIPs(Collections.emptySet());
+        }
+    }
+
+    /**
+     * Sets the authorized IPs of the test tenant. The tenant is shared by the 
whole suite (PerSuite),
+     * so a test that sets them must put the default (empty) back in a {@code 
finally}.
+     */
+    private void setTestTenantAuthorizedIPs(Set<String> authorizedIPs) {
+        Tenant settings = tenantService.getTenant(testTenant.getItemId());
+        settings.setAuthorizedIPs(new HashSet<>(authorizedIPs));
+        tenantService.updateTenantSettings(settings);
+    }
+
+    /** Re-sends an update of the caller's own profile until it is applied, 
i.e. until the tenant's authorized IPs are in effect. */
+    private void waitForOwnProfileUpdateAccepted(String sessionId, String 
cookie, String profileId, String firstName)
+            throws InterruptedException {
+        keepTrying("Protected event must be accepted from an authorized IP", 
() -> {
+            try {
+                sendOwnProfileUpdate(sessionId, cookie, profileId, firstName);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+            return profileService.load(profileId);
+        }, p -> firstName.equals(p.getProperty("firstName")), 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+    }
+
+    private void sendOwnProfileUpdate(String sessionId, String cookie, String 
profileId, String firstName) throws Exception {
+        Event updateEvent = new Event();
+        updateEvent.setEventType("updateProperties");
+        updateEvent.setScope(TEST_SCOPE);
+        Map<String, Object> props = new HashMap<>();
+        props.put("targetId", profileId);
+        props.put("targetType", "profile");
+        props.put("update", Collections.singletonMap("properties.firstName", 
firstName));
+        updateEvent.setProperties(props);
+
+        ContextRequest request = new ContextRequest();
+        request.setSessionId(sessionId);
+        request.setEvents(Collections.singletonList(updateEvent));
+        HttpPost post = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(post);
+        post.addHeader("Cookie", cookie);
+        post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(request), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(post, sessionId);
     }
 
     @Test
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc 
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
index 3c2a6a090..1cde93606 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
@@ -470,6 +470,10 @@ In addition to tenant API keys, Unomi 4.0 hardens several 
contracts that can bre
 |Known defaults such as `karaf` / `karaf` were common in samples
 |Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no 
shipped known defaults).
 
+|Protected events from public callers
+|Accepted only from a configured third-party server (`X-Unomi-Peer` key and IP)
+|Accepted only from an IP in the tenant's `authorizedIPs`; an empty list 
refuses them. Tenant private key and administrator callers are not IP-checked. 
See <<_multitenancy,Multi-tenancy>>.
+
 |Groovy Actions / Router import-export REST
 |Often callable with tenant private key
 |Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator.
diff --git a/manual/src/main/asciidoc/multitenancy.adoc 
b/manual/src/main/asciidoc/multitenancy.adoc
index 9acd5c9aa..44dbf840d 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -73,6 +73,13 @@ curl -X POST http://localhost:8181/cxs/tenants \
 
 `publicCorsAllowedOrigins` is optional: it lists the websites that may use the 
visitor cookie with this tenant's key (see 
<<_public_tracker_tenant_origins,Websites listed by each tenant>>). An invalid 
entry is refused with `400`.
 
+Protected event types (`sessionCreated`, `sessionReassigned`, 
`profileUpdated`, `ruleFired`, `updateProperties`, plus
+any extra types listed in the tenant's `restrictedEventTypes`) are accepted 
from a public API key caller only when the
+caller IP is in the tenant's `authorizedIPs` (addresses or CIDR ranges). An 
empty `authorizedIPs` list, the default,
+refuses them from every public caller; update the tenant to set it. The 
GraphQL API passes no caller IP, so public
+GraphQL callers can never send protected events. Callers using the tenant 
private key or administrator credentials are
+not IP-checked.
+
 The create response includes the tenant with **masked** API key metadata only 
(plaintext secrets are not returned and cannot be recovered from storage):
 
 [source,json]
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index cc01aea51..2e87437dd 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -467,7 +467,7 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
                     Event eventToSend = new Event(event.getEventType(), 
eventsRequestContext.getSession(), eventsRequestContext.getProfile(), 
event.getScope(),
                             event.getSource(), event.getTarget(), 
event.getProperties(), eventsRequestContext.getTimestamp(), 
event.isPersistent());
                     
eventToSend.setFlattenedProperties(event.getFlattenedProperties());
-                    // Check if single-tenant compatibility mode is enabled 
and handle V2-style event authorization
+                    // Check if single-tenant compatibility mode is enabled 
and handle Unomi 2 / 3 style event authorization
                     if 
(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()) {
                         if (!isEventAllowedInV2CompatibilityMode(event, 
eventsRequestContext.getRequest())) {
                             LOGGER.debug("Event {} not authorized in 
single-tenant compatibility mode from IP {}", event.getEventType(), 
eventsRequestContext.getRequest().getRemoteAddr());
@@ -476,8 +476,11 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
                             continue;
                         }
                     } else {
-                        // Normal V3 event authorization
-                        if (!eventService.isEventAllowedForTenant(event, 
tenantId, eventsRequestContext.getRequest().getRemoteAddr())) {
+                        // Normal multi-tenant event authorization. A tenant 
or system administrator can already
+                        // change any profile through the private endpoints, 
so the IP gate on
+                        // protected events only applies to public callers.
+                        if (!securityService.hasSystemAccess()
+                                && 
!eventService.isEventAllowedForTenant(event, tenantId, 
eventsRequestContext.getRequest().getRemoteAddr())) {
                             LOGGER.debug("Tenant is not authorized to send 
event {} from IP {}", event.getEventType(), 
eventsRequestContext.getRequest().getRemoteAddr());
                             //Don't count the event that failed
                             
eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() 
- 1);
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
index 41a225b55..2486478d4 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -16,6 +16,11 @@
  */
 package org.apache.unomi.rest.service.impl;
 
+import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.phase.PhaseInterceptorChain;
+import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Persona;
 import org.apache.unomi.api.PersonaSession;
 import org.apache.unomi.api.PersonaWithSessions;
@@ -24,6 +29,7 @@ import org.apache.unomi.api.Session;
 import org.apache.unomi.api.security.CompatPeerPrincipal;
 import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.TenantPrincipal;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.PrivacyService;
@@ -1028,6 +1034,49 @@ class RestServiceUtilsImplProfileBindingTest {
         return anonymousProfile;
     }
 
+    @Test
+    void performEventsRequest_trustedCallerSkipsRestrictedEventCheck() throws 
Exception {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        EventsRequestContext ctx = performUpdatePropertiesEvent();
+
+        verify(eventService, never()).isEventAllowedForTenant(any(), any(), 
any());
+        verify(eventService).send(any(Event.class));
+        assertEquals(1, ctx.getProcessedItems());
+    }
+
+    @Test
+    void performEventsRequest_publicCallerRefusedByRestrictedEventCheck() 
throws Exception {
+        when(request.getRemoteAddr()).thenReturn("203.0.113.7");
+        when(eventService.isEventAllowedForTenant(any(Event.class), 
eq("tenant-1"), eq("203.0.113.7"))).thenReturn(false);
+
+        EventsRequestContext ctx = performUpdatePropertiesEvent();
+
+        verify(eventService, never()).send(any(Event.class));
+        assertEquals(0, ctx.getProcessedItems());
+    }
+
+    /** Sends one updateProperties event as a caller of tenant {@code 
tenant-1}. */
+    private EventsRequestContext performUpdatePropertiesEvent() throws 
Exception {
+        Subject subject = new Subject();
+        subject.getPrincipals().add(new TenantPrincipal("tenant-1"));
+        Message message = new MessageImpl();
+        message.put(org.apache.cxf.security.SecurityContext.class, new 
RolePrefixSecurityContextImpl(subject, "ROLE_"));
+        Field currentMessage = 
PhaseInterceptorChain.class.getDeclaredField("CURRENT_MESSAGE");
+        currentMessage.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        ThreadLocal<Message> threadMessage = (ThreadLocal<Message>) 
currentMessage.get(null);
+        threadMessage.set(message);
+        try {
+            EventsRequestContext ctx = new EventsRequestContext(new Date(), 
new Profile("profile-1"), null, request, response);
+            Event event = new Event();
+            event.setEventType("updateProperties");
+            return 
restServiceUtils.performEventsRequest(Collections.singletonList(event), ctx, 
mock(SecurityContext.class));
+        } finally {
+            threadMessage.remove();
+        }
+    }
+
     private static void setField(Object target, String fieldName, Object 
value) throws Exception {
         Field field = target.getClass().getDeclaredField(fieldName);
         field.setAccessible(true);
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
index 493cc03ca..e3e59faaf 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
@@ -112,6 +112,9 @@ public class EventServiceImpl implements EventService {
 
     private TracerService tracerService;
 
+    /** Tenants already warned that they authorize no IP, so the warning is 
logged once per tenant. */
+    private final Set<String> tenantsWarnedWithoutAuthorizedIPs = 
ConcurrentHashMap.newKeySet();
+
     public void setPredefinedEventTypeIds(Set<String> predefinedEventTypeIds) {
         this.predefinedEventTypeIds = predefinedEventTypeIds;
     }
@@ -166,7 +169,7 @@ public class EventServiceImpl implements EventService {
             // If tenant has defined restrictions, check if this event type is 
restricted
             if (tenantRestrictions.contains(event.getEventType())) {
                 // Event is restricted by tenant, proceed to IP check
-                return checkIPAuthorization(tenant, sourceIP);
+                return checkIPAuthorization(tenant, event.getEventType(), 
sourceIP);
             }
         }
 
@@ -174,15 +177,27 @@ public class EventServiceImpl implements EventService {
         // check global restrictions
         if (restrictedEventTypeIds.contains(event.getEventType())) {
             // Event is restricted globally, proceed to IP check
-            return checkIPAuthorization(tenant, sourceIP);
+            return checkIPAuthorization(tenant, event.getEventType(), 
sourceIP);
         }
 
         // Event is not restricted by either tenant or global settings
         return true;
     }
 
-    private boolean checkIPAuthorization(Tenant tenant, String sourceIP) {
+    private boolean checkIPAuthorization(Tenant tenant, String eventType, 
String sourceIP) {
         Set<String> authorizedIPs = tenant.getAuthorizedIPs();
+        // Fail closed like the 3.0 third-party check: a tenant that 
authorized no IP accepts
+        // protected events from nobody. IPValidationUtils treats an empty set 
as "no restriction".
+        if (authorizedIPs == null || authorizedIPs.isEmpty()) {
+            if (tenantsWarnedWithoutAuthorizedIPs.add(tenant.getItemId())) {
+                LOGGER.warn("Refused restricted event type {} for tenant {}: 
the tenant has no authorizedIPs, so it accepts "
+                        + "restricted event types from no public caller. Set 
authorizedIPs on the tenant to allow them. "
+                        + "Logged once per tenant.", eventType, 
tenant.getItemId());
+            }
+            return false;
+        }
+        // Warn again if the tenant later empties its list.
+        tenantsWarnedWithoutAuthorizedIPs.remove(tenant.getItemId());
         return IPValidationUtils.isIpAuthorized(sourceIP, authorizedIPs);
     }
 
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
index a5092fd22..944bdf347 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
@@ -565,6 +565,37 @@ public class EventServiceImplTest {
         assertTrue(result);
     }
 
+    @Test
+    public void 
testIsEventAllowedForTenant_NoAuthorizedIPsRefusesRestrictedEvents() {
+        eventService.setRestrictedEventTypeIds(new 
HashSet<>(Arrays.asList("globalRestricted")));
+
+        Tenant tenant = new Tenant();
+        tenant.setItemId(TENANT_1);
+        tenant.setRestrictedEventTypes(new 
HashSet<>(Arrays.asList("tenantRestricted")));
+        tenantService.saveTenant(tenant);
+
+        for (String eventType : Arrays.asList("globalRestricted", 
"tenantRestricted")) {
+            Event event = createTestEvent();
+            event.setEventType(eventType);
+            assertFalse(eventService.isEventAllowedForTenant(event, TENANT_1, 
"127.0.0.1"),
+                    eventType + " must be refused when the tenant authorized 
no IP");
+            assertFalse(eventService.isEventAllowedForTenant(event, TENANT_1, 
null),
+                    eventType + " must be refused without a source IP");
+        }
+
+        Event unrestricted = createTestEvent();
+        unrestricted.setEventType("unrestricted");
+        assertTrue(eventService.isEventAllowedForTenant(unrestricted, 
TENANT_1, "127.0.0.1"),
+                "Unrestricted event should still be allowed");
+
+        tenant.setAuthorizedIPs(null);
+        tenantService.saveTenant(tenant);
+        Event globalRestricted = createTestEvent();
+        globalRestricted.setEventType("globalRestricted");
+        assertFalse(eventService.isEventAllowedForTenant(globalRestricted, 
TENANT_1, "127.0.0.1"),
+                "A null authorizedIPs must be refused like an empty one");
+    }
+
     @Test
     public void testEventAllowed() {
         String tenantId = "test_tenant";

Reply via email to