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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user