diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java new file mode 100644 index 0000000..095683d --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java @@ -0,0 +1,25 @@ +package net.siegeln.cameleer.saas.identity; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class LogtoConfig { + + @Value("${cameleer.identity.logto-endpoint:}") + private String logtoEndpoint; + + @Value("${cameleer.identity.m2m-client-id:}") + private String m2mClientId; + + @Value("${cameleer.identity.m2m-client-secret:}") + private String m2mClientSecret; + + public String getLogtoEndpoint() { return logtoEndpoint; } + public String getM2mClientId() { return m2mClientId; } + public String getM2mClientSecret() { return m2mClientSecret; } + + public boolean isConfigured() { + return !logtoEndpoint.isEmpty() && !m2mClientId.isEmpty() && !m2mClientSecret.isEmpty(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java new file mode 100644 index 0000000..f566be0 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -0,0 +1,103 @@ +package net.siegeln.cameleer.saas.identity; + +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Instant; +import java.util.Map; + +@Service +public class LogtoManagementClient { + + private static final Logger log = LoggerFactory.getLogger(LogtoManagementClient.class); + + private final LogtoConfig config; + private final RestClient restClient; + + private String cachedToken; + private Instant tokenExpiry = Instant.MIN; + + public LogtoManagementClient(LogtoConfig config) { + this.config = config; + this.restClient = RestClient.builder() + .defaultHeader("Content-Type", "application/json") + .build(); + } + + public boolean isAvailable() { + return config.isConfigured(); + } + + public String createOrganization(String name, String description) { + if (!isAvailable()) { + log.warn("Logto not configured — skipping organization creation for '{}'", name); + return null; + } + + var body = Map.of("name", name, "description", description != null ? description : ""); + + var response = restClient.post() + .uri(config.getLogtoEndpoint() + "/api/organizations") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(JsonNode.class); + + return response != null ? response.get("id").asText() : null; + } + + public void addUserToOrganization(String orgId, String userId) { + if (!isAvailable()) return; + + restClient.post() + .uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("userIds", new String[]{userId})) + .retrieve() + .toBodilessEntity(); + } + + public void deleteOrganization(String orgId) { + if (!isAvailable()) return; + + restClient.delete() + .uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId) + .header("Authorization", "Bearer " + getAccessToken()) + .retrieve() + .toBodilessEntity(); + } + + private synchronized String getAccessToken() { + if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) { + return cachedToken; + } + + try { + var response = restClient.post() + .uri(config.getLogtoEndpoint() + "/oidc/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("grant_type=client_credentials" + + "&client_id=" + config.getM2mClientId() + + "&client_secret=" + config.getM2mClientSecret() + + "&resource=" + config.getLogtoEndpoint() + "/api" + + "&scope=all") + .retrieve() + .body(JsonNode.class); + + cachedToken = response.get("access_token").asText(); + long expiresIn = response.get("expires_in").asLong(); + tokenExpiry = Instant.now().plusSeconds(expiresIn); + + return cachedToken; + } catch (Exception e) { + log.error("Failed to get Logto Management API token", e); + throw new RuntimeException("Logto authentication failed", e); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java index c751bc6..8925ae5 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; import org.springframework.stereotype.Service; @@ -14,10 +15,12 @@ public class TenantService { private final TenantRepository tenantRepository; private final AuditService auditService; + private final LogtoManagementClient logtoClient; - public TenantService(TenantRepository tenantRepository, AuditService auditService) { + public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) { this.tenantRepository = tenantRepository; this.auditService = auditService; + this.logtoClient = logtoClient; } public TenantEntity create(CreateTenantRequest request, UUID actorId) { @@ -33,6 +36,14 @@ public class TenantService { var saved = tenantRepository.save(entity); + if (logtoClient.isAvailable()) { + String logtoOrgId = logtoClient.createOrganization(saved.getName(), "Tenant: " + saved.getSlug()); + if (logtoOrgId != null) { + saved.setLogtoOrgId(logtoOrgId); + saved = tenantRepository.save(saved); + } + } + auditService.log(actorId, null, saved.getId(), AuditAction.TENANT_CREATE, saved.getSlug(), null, null, "SUCCESS", null); diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java index b952cfa..7be62df 100644 --- a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java @@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,11 +29,14 @@ class TenantServiceTest { @Mock private AuditService auditService; + @Mock + private LogtoManagementClient logtoClient; + private TenantService tenantService; @BeforeEach void setUp() { - tenantService = new TenantService(tenantRepository, auditService); + tenantService = new TenantService(tenantRepository, auditService, logtoClient); } @Test