From 6bdb02ff5a6c7044213e758636b2300309b7b9ae Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:43:40 +0200 Subject: [PATCH] feat: add per-tenant health, OIDC, team methods to API clients Co-Authored-By: Claude Sonnet 4.6 --- .../saas/identity/LogtoManagementClient.java | 105 ++++++++++++++++++ .../saas/identity/ServerApiClient.java | 56 ++++++++-- 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index 60afca0..3f95e4d 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -101,6 +101,111 @@ public class LogtoManagementClient { } } + /** List members of a Logto organization. */ + @SuppressWarnings("unchecked") + public List> 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> 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) 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> 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() { diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java index c755321..1f77a62 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java @@ -72,18 +72,60 @@ public class ServerApiClient { .toBodilessEntity(); } - /** - * Check server health. - */ + /** Health check for a specific tenant's server. */ @SuppressWarnings("unchecked") - public Map 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 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 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;