feat: add per-tenant health, OIDC, team methods to API clients

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 21:43:40 +02:00
parent 96a5b1d9f1
commit 6bdb02ff5a
2 changed files with 154 additions and 7 deletions

View File

@@ -101,6 +101,111 @@ public class LogtoManagementClient {
}
}
/** List members of a Logto organization. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listOrganizationMembers(String orgId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org members for {}: {}", orgId, e.getMessage());
return List.of();
}
}
/** Get roles assigned to a user within an organization. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getUserOrganizationRoles(String orgId, String userId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to get user roles: {}", e.getMessage());
return List.of();
}
}
/** Assign a role to a user in an organization. */
public void assignOrganizationRole(String orgId, String userId, String roleId) {
if (!isAvailable()) return;
try {
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("organizationRoleIds", List.of(roleId)))
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to assign role: {}", e.getMessage());
}
}
/** Remove a user from an organization. */
public void removeUserFromOrganization(String orgId, String userId) {
if (!isAvailable()) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to remove user from org: {}", e.getMessage());
}
}
/** Create a user in Logto and add to organization with role. */
@SuppressWarnings("unchecked")
public String createAndInviteUser(String email, String orgId, String roleId) {
if (!isAvailable()) return null;
try {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
return userId;
} catch (Exception e) {
log.error("Failed to create and invite user: {}", e.getMessage());
throw new RuntimeException("Invite failed: " + e.getMessage(), e);
}
}
/** List available organization roles. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listOrganizationRoles() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organization-roles")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org roles: {}", e.getMessage());
return List.of();
}
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() {

View File

@@ -72,18 +72,60 @@ public class ServerApiClient {
.toBodilessEntity();
}
/**
* Check server health.
*/
/** Health check for a specific tenant's server. */
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth(String serverEndpoint) {
return RestClient.create(serverEndpoint)
.get()
.uri("/api/v1/health")
public ServerHealthResponse getHealth(String serverEndpoint) {
try {
String url = serverEndpoint + "/actuator/health";
var resp = RestClient.create().get().uri(url)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(Map.class);
String status = resp != null ? String.valueOf(resp.get("status")) : "UNKNOWN";
return new ServerHealthResponse("UP".equals(status), status);
} catch (Exception e) {
log.warn("Health check failed for {}: {}", serverEndpoint, e.getMessage());
return new ServerHealthResponse(false, "DOWN");
}
}
/** Push OIDC configuration to a tenant's server. */
public void pushOidcConfig(String serverEndpoint, Map<String, Object> oidcConfig) {
try {
RestClient.create().put()
.uri(serverEndpoint + "/api/admin/oidc")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.header("Content-Type", "application/json")
.body(oidcConfig)
.retrieve()
.toBodilessEntity();
log.info("Pushed OIDC config to {}", serverEndpoint);
} catch (Exception e) {
log.error("Failed to push OIDC config to {}: {}", serverEndpoint, e.getMessage());
throw new RuntimeException("OIDC config push failed: " + e.getMessage(), e);
}
}
/** Get OIDC configuration from a tenant's server. */
@SuppressWarnings("unchecked")
public Map<String, Object> getOidcConfig(String serverEndpoint) {
try {
return RestClient.create().get()
.uri(serverEndpoint + "/api/admin/oidc")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get OIDC config from {}: {}", serverEndpoint, e.getMessage());
return Map.of();
}
}
public record ServerHealthResponse(boolean healthy, String status) {}
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;