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";