feat: add Logto Management API client for org provisioning

Creates Logto organizations when tenants are created. Authenticates
via M2M client credentials. Gracefully skips when Logto is not
configured (dev/test mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 15:07:43 +02:00
parent 0f3bd209a1
commit 42bd116af1
4 changed files with 145 additions and 2 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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