diff --git a/openaev-api/src/main/java/io/openaev/config/security/SecurityService.java b/openaev-api/src/main/java/io/openaev/config/security/SecurityService.java index 83617ecd5d8..78376543027 100644 --- a/openaev-api/src/main/java/io/openaev/config/security/SecurityService.java +++ b/openaev-api/src/main/java/io/openaev/config/security/SecurityService.java @@ -2,8 +2,11 @@ import static java.util.Optional.ofNullable; import static org.springframework.util.StringUtils.hasLength; +import static org.springframework.util.StringUtils.hasText; +import io.openaev.database.model.Tenant; import io.openaev.database.model.User; +import io.openaev.database.repository.TenantRepository; import io.openaev.database.repository.UserRepository; import io.openaev.service.UserMappingService; import io.openaev.service.UserService; @@ -14,9 +17,11 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class SecurityService { @@ -26,6 +31,7 @@ public class SecurityService { public static final String GROUPS_MANAGEMENT_SUFFIX = ".groups_management"; public static final String ALL_ADMIN_PATH_SUFFIX = ".all_admin"; public static final String AUDIENCE_PATH = ".audience"; + public static final String TENANT_ID_SUFFIX = ".tenant_id"; public static final String REGISTRATION_ID = "registration_id"; private final UserRepository userRepository; @@ -33,6 +39,7 @@ public class SecurityService { private final UserMappingService userMappingService; private final Environment env; private final UserEventService userEventService; + private final TenantRepository tenantRepository; public User userManagement( String emailAttribute, @@ -60,6 +67,7 @@ public User userManagement( String.class, ""); userMappingService.mapCurrentUserWithGroup(groupsManagementObject, user, groups); + attachTenant(registrationId, user); return this.userService.saveUser(user); } else { // If user exists, update it @@ -76,6 +84,7 @@ public User userManagement( String.class, ""); userMappingService.mapCurrentUserWithGroup(groupsManagementObject, currentUser, groups); + attachTenant(registrationId, currentUser); return this.userService.saveUser(currentUser); } } @@ -91,6 +100,26 @@ public String getAudience(@NotBlank final String registrationId) { // -- PRIVATE -- + /** Attaches the user to the tenant configured for the given SSO provider registration. */ + private void attachTenant(String registrationId, User user) { + String tenantId = + env.getProperty( + OPENAEV_PROVIDER_PATH_PREFIX + registrationId + TENANT_ID_SUFFIX, String.class, ""); + if (!hasText(tenantId)) { + return; + } + boolean alreadyAttached = user.getTenants().stream().anyMatch(t -> t.getId().equals(tenantId)); + if (alreadyAttached) { + return; + } + if (!tenantRepository.existsById(tenantId)) { + log.warn("SSO tenant ID '{}' configured but not found in database", tenantId); + return; + } + Tenant tenant = tenantRepository.getReferenceById(tenantId); + user.getTenants().add(tenant); + } + private List getAdminRoles(@NotBlank final String registrationId) { String rolesAdminConfig = OPENAEV_PROVIDER_PATH_PREFIX + registrationId + ROLES_ADMIN_PATH_SUFFIX; diff --git a/openaev-api/src/main/java/io/openaev/service/UserMappingService.java b/openaev-api/src/main/java/io/openaev/service/UserMappingService.java index 2d121809c8d..b6b47b0cba2 100644 --- a/openaev-api/src/main/java/io/openaev/service/UserMappingService.java +++ b/openaev-api/src/main/java/io/openaev/service/UserMappingService.java @@ -5,8 +5,10 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.openaev.database.model.Group; +import io.openaev.database.model.Tenant; import io.openaev.database.model.User; import io.openaev.database.repository.GroupRepository; +import io.openaev.database.repository.TenantRepository; import io.openaev.sso.GroupMapping; import jakarta.validation.constraints.NotBlank; import java.io.IOException; @@ -29,6 +31,7 @@ public class UserMappingService { private final GroupRepository groupRepository; + private final TenantRepository tenantRepository; private final Environment env; public static final String ROLES_PATH_SUFFIX = "roles_path"; public static final String GROUPS_PATH_SUFFIX = "groups_path"; @@ -64,6 +67,7 @@ public void mapCurrentUserWithGroup(String property, User user, List gro log.error("Did not create new group"); } } + attachTenantFromGroupMapping(mapping, user); } else { log.error(String.format("No corresponding group found for group %s", role)); } @@ -86,14 +90,35 @@ public void mapCurrentUserWithGroup(String property, User user, List gro private static List safeParseMappings(String json) { ObjectMapper mapper = new ObjectMapper(); try { - return mapper.readValue(json, new TypeReference>() {}); + return mapper.readValue(json, new TypeReference<>() {}); } catch (IOException e) { // Log and return empty list instead of throwing - System.err.println("Failed to parse mappings: " + e.getMessage()); + log.error("Failed to parse group mappings: {}", e.getMessage(), e); return List.of(); } } + /** + * Attaches the user to the tenant configured in the group mapping, if any. Skips if tenantId is + * not set, the user is already attached, or the tenant is not found. + */ + private void attachTenantFromGroupMapping(GroupMapping mapping, User user) { + String tenantId = mapping.getTenantId(); + if (tenantId == null || tenantId.isBlank()) { + return; + } + boolean alreadyAttached = user.getTenants().stream().anyMatch(t -> t.getId().equals(tenantId)); + if (alreadyAttached) { + return; + } + if (!tenantRepository.existsById(tenantId)) { + log.warn("Group mapping tenant ID '{}' configured but not found in database", tenantId); + return; + } + Tenant tenant = tenantRepository.getReferenceById(tenantId); + user.getTenants().add(tenant); + } + /** * Extract the roles from a user * diff --git a/openaev-api/src/main/java/io/openaev/sso/GroupMapping.java b/openaev-api/src/main/java/io/openaev/sso/GroupMapping.java index 1105c925f47..22b71f98981 100644 --- a/openaev-api/src/main/java/io/openaev/sso/GroupMapping.java +++ b/openaev-api/src/main/java/io/openaev/sso/GroupMapping.java @@ -14,4 +14,7 @@ public class GroupMapping { @JsonProperty("autoCreate") private boolean autoCreate; + + @JsonProperty("tenantId") + private String tenantId; } diff --git a/openaev-api/src/main/resources/application.properties b/openaev-api/src/main/resources/application.properties index 04198a855ba..85bf2429b4b 100644 --- a/openaev-api/src/main/resources/application.properties +++ b/openaev-api/src/main/resources/application.properties @@ -169,6 +169,7 @@ openaev.auth-saml2-enable=false # openaev.provider.{registrationId}.roles_admin= # openaev.provider.{registrationId}.audience= # openaev.provider.{registrationId}.groups_management= +# openaev.provider.{registrationId}.tenant_id= ## Kerberos openaev.auth-kerberos-enable=false diff --git a/openaev-api/src/test/java/io/openaev/config/security/SecurityServiceTest.java b/openaev-api/src/test/java/io/openaev/config/security/SecurityServiceTest.java new file mode 100644 index 00000000000..ab2850c10e6 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/security/SecurityServiceTest.java @@ -0,0 +1,150 @@ +package io.openaev.config.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.openaev.IntegrationTest; +import io.openaev.database.model.Tenant; +import io.openaev.database.model.User; +import io.openaev.database.repository.UserRepository; +import io.openaev.utils.fixtures.UserFixture; +import io.openaev.utils.fixtures.composers.UserComposer; +import io.openaev.utils.fixtures.tenants.TenantComposer; +import io.openaev.utils.fixtures.tenants.TenantFixture; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SecurityServiceTest extends IntegrationTest { + + private static final String REGISTRATION_ID = "oidc"; + private static final String SSO_EMAIL = "sso-user@integration.test"; + private static final String SSO_TENANT_ID = "sso-test-tenant-id"; + + @Autowired private SecurityService securityService; + @Autowired private UserRepository userRepository; + @Autowired private UserComposer userComposer; + @Autowired private TenantComposer tenantComposer; + + @BeforeEach + void setup() { + userComposer.reset(); + tenantComposer.reset(); + } + + @Nested + class UserCreation { + + @Test + void given_unknownEmail_should_createUser() { + // -- ARRANGE -- + + // -- ACT -- + User result = + securityService.userManagement( + SSO_EMAIL, REGISTRATION_ID, List.of(), List.of(), "John", "Doe"); + + // -- ASSERT -- + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo(SSO_EMAIL); + assertThat(result.getFirstname()).isEqualTo("John"); + assertThat(result.getLastname()).isEqualTo("Doe"); + + Optional persisted = userRepository.findByEmailIgnoreCase(SSO_EMAIL); + assertThat(persisted).isPresent(); + } + } + + @Nested + class UserUpdate { + + @Test + void given_existingUser_should_updateName() { + // -- ARRANGE -- + User user = UserFixture.getUser("Old", "Name", SSO_EMAIL); + userComposer.forUser(user).persist(); + + // -- ACT -- + User result = + securityService.userManagement( + SSO_EMAIL, REGISTRATION_ID, List.of(), List.of(), "New", "Name"); + + // -- ASSERT -- + assertThat(result.getFirstname()).isEqualTo("New"); + assertThat(result.getLastname()).isEqualTo("Name"); + } + + @Test + void given_emptyEmail_should_returnNull() { + // -- ARRANGE -- + + // -- ACT -- + User result = + securityService.userManagement("", REGISTRATION_ID, List.of(), List.of(), "John", "Doe"); + + // -- ASSERT -- + assertThat(result).isNull(); + } + } + + @Nested + class AttachTenant { + + @Test + void given_newUserWithTenantConfigured_should_attachTenant() { + // -- ARRANGE -- + Tenant tenant = TenantFixture.getTenant(); + tenant.setId(SSO_TENANT_ID); + tenantComposer.forTenant(tenant).persist(); + + // -- ACT -- + User result = + securityService.userManagement( + SSO_EMAIL, REGISTRATION_ID, List.of(), List.of(), "John", "Doe"); + + // -- ASSERT -- + assertThat(result.getTenants()).hasSize(1); + assertThat(result.getTenants().getFirst().getId()).isEqualTo(SSO_TENANT_ID); + } + + @Test + void given_existingUserWithTenantAlreadyAttached_should_notDuplicate() { + // -- ARRANGE -- + Tenant tenant = TenantFixture.getTenant(); + tenant.setId(SSO_TENANT_ID); + tenantComposer.forTenant(tenant).persist(); + User user = UserFixture.getUser("John", "Doe", SSO_EMAIL); + user.getTenants().add(tenant); + userComposer.forUser(user).persist(); + + // -- ACT -- + User result = + securityService.userManagement( + SSO_EMAIL, REGISTRATION_ID, List.of(), List.of(), "John", "Doe"); + + // -- ASSERT -- + assertThat(result.getTenants()).hasSize(1); + } + + @Test + void given_tenantNotFoundInDb_should_notError() { + // -- ARRANGE -- + // tenant_id is configured in test application.properties but tenant is not persisted + + // -- ACT -- + User result = + securityService.userManagement( + SSO_EMAIL, REGISTRATION_ID, List.of(), List.of(), "John", "Doe"); + + // -- ASSERT -- + assertThat(result).isNotNull(); + assertThat(result.getTenants()).isEmpty(); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/sso/UserMappingServiceTest.java b/openaev-api/src/test/java/io/openaev/sso/UserMappingServiceTest.java index 38a9defd678..853bc74bb50 100644 --- a/openaev-api/src/test/java/io/openaev/sso/UserMappingServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/sso/UserMappingServiceTest.java @@ -7,6 +7,7 @@ import io.openaev.IntegrationTest; import io.openaev.database.model.Group; +import io.openaev.database.model.Tenant; import io.openaev.database.model.User; import io.openaev.opencti.connectors.Constants; import io.openaev.service.UserMappingService; @@ -14,6 +15,8 @@ import io.openaev.utils.fixtures.UserFixture; import io.openaev.utils.fixtures.composers.GroupComposer; import io.openaev.utils.fixtures.composers.UserComposer; +import io.openaev.utils.fixtures.tenants.TenantComposer; +import io.openaev.utils.fixtures.tenants.TenantFixture; import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; import java.util.*; @@ -32,27 +35,42 @@ import org.springframework.test.util.ReflectionTestUtils; @Transactional -public class UserMappingServiceTest extends IntegrationTest { +class UserMappingServiceTest extends IntegrationTest { + + private static final String GROUP_TENANT_ID = "group-tenant-id"; @Autowired private GroupComposer groupComposer; @Autowired UserComposer userComposer; + @Autowired private TenantComposer tenantComposer; @Autowired private UserMappingService userMappingService; @Autowired protected EntityManager entityManager; + private static final String GROUPS_MANAGEMENT_JSON = + """ + [ + {"idpGroup": "team-alpha", "userGroup": "Alpha", "autoCreate": true, "tenantId": "group-tenant-id"}, + {"idpGroup": "team-beta", "userGroup": "Beta", "autoCreate": true} + ] + """; + @BeforeEach - public void setup() { + void setup() { groupComposer.reset(); } @Test @DisplayName( - "When the specific group already exists and the autocreate is false, add it to the user") - public void whenTheSpecificGroupAlreadyExistsAndTheAutocreateIsFalse_addItToTheUser() { + "When the specific group already exists and the autocreate is false, add it to the user and" + + " attach tenant") + void whenTheSpecificGroupAlreadyExistsAndTheAutocreateIsFalse_addItToTheUser() { // -- ARRANGE --- - String object = - "[{\"idpGroup\": \"observer\",\"userGroup\": \"observerUserGroup\",\"autoCreate\": \"false\"}]"; - Group specificGroup = GroupFixture.createGroupWithName("observerUserGroup"); + Tenant tenant = TenantFixture.getTenant(); + tenant.setId(GROUP_TENANT_ID); + tenantComposer.forTenant(tenant).persist(); + + String groupsManagement = GROUPS_MANAGEMENT_JSON; + Group specificGroup = GroupFixture.createGroupWithName("Alpha"); specificGroup.setId(Constants.PROCESS_STIX_GROUP_ID); specificGroup.setDescription("a description"); specificGroup.setRoles(new ArrayList<>()); @@ -63,40 +81,43 @@ public void whenTheSpecificGroupAlreadyExistsAndTheAutocreateIsFalse_addItToTheU userComposer.forUser(user).persist(); entityManager.flush(); entityManager.clear(); - List roles = List.of("observer"); + List roles = List.of("team-alpha"); // ---- ACT ---- - userMappingService.mapCurrentUserWithGroup(object, user, roles); + userMappingService.mapCurrentUserWithGroup(groupsManagement, user, roles); // -- ASSERT -- assertTrue(user.getGroups().contains(specificGroup)); + assertThat(user.getTenants().stream().anyMatch(t -> t.getId().equals(GROUP_TENANT_ID))) + .isTrue(); } @Test @DisplayName( - "When the specific group does not exist and the autocreate is true, create it and add it to the user") - public void whenTheSpecificGroupDoesNotExistAndTheAutocreateIsTrue_createItAndAddItToTheUser() { + "When the specific group does not exist and the autocreate is true, create it and add it to" + + " the user") + void whenTheSpecificGroupDoesNotExistAndTheAutocreateIsTrue_createItAndAddItToTheUser() { // -- ARRANGE --- - String object = - "[{\"idpGroup\": \"observer\",\"userGroup\": \"admin\",\"autoCreate\": \"true\"}]"; + String groupsManagement = GROUPS_MANAGEMENT_JSON; User user = UserFixture.getUser(); userComposer.forUser(user).persist(); entityManager.flush(); entityManager.clear(); - List roles = List.of("observer"); + List roles = List.of("team-beta"); // ---- ACT ---- - userMappingService.mapCurrentUserWithGroup(object, user, roles); + userMappingService.mapCurrentUserWithGroup(groupsManagement, user, roles); // -- ASSERT -- Group userGroup = user.getGroups().get(0); - assertTrue(userGroup.getName().equals("admin")); + assertTrue(userGroup.getName().equals("Beta")); + assertThat(user.getTenants().isEmpty()).isTrue(); } @Test @DisplayName("When the specific group does not exist and the autocreate is false, do nothing") - public void whenTheSpecificGroupDoesNotExistAndTheAutocreateIsFalse_doNothing() { + void whenTheSpecificGroupDoesNotExistAndTheAutocreateIsFalse_doNothing() { // -- ARRANGE --- String object = @@ -116,7 +137,7 @@ public void whenTheSpecificGroupDoesNotExistAndTheAutocreateIsFalse_doNothing() @Test @DisplayName("When group from idp and group from oaev do not match, do nothing") - public void whenGroupFromIdpAndRolesFromOaevDoNotMatch_doNothing() { + void whenGroupFromIdpAndRolesFromOaevDoNotMatch_doNothing() { // -- ARRANGE --- String object = @@ -143,11 +164,13 @@ public void whenGroupFromIdpAndRolesFromOaevDoNotMatch_doNothing() { @Test @DisplayName("When multiple config is set, act accordingly") - public void whenMultipleConfigIsSet_actAccordingly() { + void whenMultipleConfigIsSet_actAccordingly() { // -- ARRANGE --- String object = - "[{\"idpGroup\": \"observer\",\"userGroup\": \"admin1\",\"autoCreate\": \"false\"},{\"idpGroup\": \"observer\",\"userGroup\": \"admin2\",\"autoCreate\": \"true\"}]"; + "[{\"idpGroup\": \"observer\",\"userGroup\": \"admin1\",\"autoCreate\":" + + " \"false\"},{\"idpGroup\": \"observer\",\"userGroup\": \"admin2\",\"autoCreate\":" + + " \"true\"}]"; Group specificGroup = GroupFixture.createGroupWithName("observer"); specificGroup.setId(Constants.PROCESS_STIX_GROUP_ID); specificGroup.setDescription("a description"); @@ -171,11 +194,13 @@ public void whenMultipleConfigIsSet_actAccordingly() { @Test @DisplayName("When removed from the idp group, remove from oaev group") - public void whenRemovedFromIdpGroup_propagateDeleteFromGroup() { + void whenRemovedFromIdpGroup_propagateDeleteFromGroup() { // -- ARRANGE --- String object = - "[{\"idpGroup\": \"observer1\",\"userGroup\": \"observerOAEV1\",\"autoCreate\": \"true\"},{\"idpGroup\": \"observer2\",\"userGroup\": \"observerOAEV2\",\"autoCreate\": \"true\"}]"; + "[{\"idpGroup\": \"observer1\",\"userGroup\": \"observerOAEV1\",\"autoCreate\":" + + " \"true\"},{\"idpGroup\": \"observer2\",\"userGroup\":" + + " \"observerOAEV2\",\"autoCreate\": \"true\"}]"; Group specificGroup1 = GroupFixture.createGroupWithName("observerOAEV1"); specificGroup1.setId(Constants.PROCESS_STIX_GROUP_ID); specificGroup1.setDescription("a description"); @@ -207,7 +232,7 @@ public void whenRemovedFromIdpGroup_propagateDeleteFromGroup() { class TestRolesAndGroupsExtraction { @Test @DisplayName("When oidc user, extract roles accordingly") - public void whenOidcUser_extractRoles() { + void whenOidcUser_extractRoles() { // -- ARRANGE --- Environment env = Mockito.mock(Environment.class); @@ -244,7 +269,7 @@ public String getName() { @Test @DisplayName("When oidc user, extract groups accordingly") - public void whenOidcUser_extractGroups() { + void whenOidcUser_extractGroups() { // -- ARRANGE --- Environment env = Mockito.mock(Environment.class); @@ -282,7 +307,7 @@ public String getName() { @Test @DisplayName("When saml user, extract roles accordingly") - public void whenSamlUser_extractRoles() { + void whenSamlUser_extractRoles() { // -- ARRANGE --- Environment env = Mockito.mock(Environment.class); @@ -314,7 +339,7 @@ public Map> getAttributes() { @Test @DisplayName("When saml user, extract groups accordingly") - public void whenSamlUser_extractGroups() { + void whenSamlUser_extractGroups() { // -- ARRANGE --- Environment env = Mockito.mock(Environment.class); @@ -347,7 +372,7 @@ public Map> getAttributes() { @Test @DisplayName("When not implemented user, throw exception") - public void whenNotImplementedUser_throwException() { + void whenNotImplementedUser_throwException() { // -- ARRANGE --- Environment env = Mockito.mock(Environment.class); diff --git a/openaev-api/src/test/resources/application.properties b/openaev-api/src/test/resources/application.properties index c290fc91bd9..86dc9902f80 100644 --- a/openaev-api/src/test/resources/application.properties +++ b/openaev-api/src/test/resources/application.properties @@ -68,6 +68,8 @@ openaev.cron.config.steps.delay.queue.polling.interval=10000 openaev.auth-local-enable=false ## Oauth openaev.auth-openid-enable=false +## SSO provider for tests +openaev.provider.oidc.tenant_id=sso-test-tenant-id ## Kerberos openaev.auth-kerberos-enable=false @@ -198,4 +200,4 @@ spring.datasource.hikari.maximum-pool-size=5 # Help performances on slow compute spring.datasource.hikari.minimum-idle=1 -openaev.enabled-dev-features=SENTINEL_ONE_EXECUTOR,PALO_ALTO_CORTEX_EXECUTOR,INJECT_CHAINING \ No newline at end of file +openaev.enabled-dev-features=SENTINEL_ONE_EXECUTOR,PALO_ALTO_CORTEX_EXECUTOR,INJECT_CHAINING diff --git a/openaev-model/src/main/java/io/openaev/database/repository/TenantRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/TenantRepository.java index 152fca9cdd4..6741bf4ca75 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/TenantRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/TenantRepository.java @@ -3,16 +3,16 @@ import io.openaev.database.model.Tenant; import java.time.Instant; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface TenantRepository - extends CrudRepository, JpaSpecificationExecutor { + extends JpaRepository, JpaSpecificationExecutor { // -- READ --