feat: bootstrap 2 users, tenant, org-scoped tokens, platform admin UI
Bootstrap script now creates: - SaaS Owner (admin/admin) with platform-admin role - Tenant Admin (camel/camel) in Example Tenant org - Traditional Web App for cameleer3-server OIDC - DB records: tenant, default environment, license - Configures cameleer3-server OIDC via its admin API All credentials configurable via env vars. Backend: - Fix LogtoManagementClient resource URL (https://default.logto.app/api) - Add getUserRoles/getUserOrganizations to LogtoManagementClient - Add GET /api/me endpoint (user info, platform admin status, tenants) - Add GET /api/tenants list-all for platform admins - Remove insecure X-header forwarding from Traefik Frontend: - Org-scoped tokens: getAccessToken(resource, orgId) for tenant context - OrgResolver component populates org store from /api/me - useOrganization Zustand store (currentOrgId + currentTenantId) - Platform admin sidebar section + AdminTenantsPage - View Dashboard link points to cameleer3-server on port 8081 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final TenantService tenantService;
|
||||
|
||||
public MeController(LogtoManagementClient logtoClient, TenantService tenantService) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.tenantService = tenantService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/me")
|
||||
public ResponseEntity<Map<String, Object>> me(Authentication authentication) {
|
||||
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
Jwt jwt = jwtAuth.getToken();
|
||||
String userId = jwt.getSubject();
|
||||
|
||||
List<String> globalRoles = logtoClient.getUserRoles(userId);
|
||||
boolean isPlatformAdmin = globalRoles.contains("platform-admin");
|
||||
|
||||
List<Map<String, String>> logtoOrgs = logtoClient.getUserOrganizations(userId);
|
||||
|
||||
List<Map<String, Object>> tenants = logtoOrgs.stream()
|
||||
.map(org -> tenantService.getByLogtoOrgId(org.get("id"))
|
||||
.map(t -> Map.<String, Object>of(
|
||||
"id", t.getId().toString(),
|
||||
"name", t.getName(),
|
||||
"slug", t.getSlug(),
|
||||
"logtoOrgId", t.getLogtoOrgId()
|
||||
))
|
||||
.orElse(null))
|
||||
.filter(t -> t != null)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"userId", userId,
|
||||
"isPlatformAdmin", isPlatformAdmin,
|
||||
"tenants", tenants
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/auth/verify").permitAll()
|
||||
.requestMatchers("/api/config").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license", "/admin/**").permitAll()
|
||||
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@@ -73,6 +75,57 @@ public class LogtoManagementClient {
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
public List<String> getUserRoles(String userId) {
|
||||
if (!isAvailable()) return List.of();
|
||||
|
||||
try {
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/roles")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
|
||||
List<String> roles = new ArrayList<>();
|
||||
if (response != null && response.isArray()) {
|
||||
for (var node : response) {
|
||||
roles.add(node.get("name").asText());
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get user roles for {}: {}", userId, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Map<String, String>> getUserOrganizations(String userId) {
|
||||
if (!isAvailable()) return List.of();
|
||||
|
||||
try {
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/organizations")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
|
||||
List<Map<String, String>> orgs = new ArrayList<>();
|
||||
if (response != null && response.isArray()) {
|
||||
for (var node : response) {
|
||||
orgs.add(Map.of(
|
||||
"id", node.get("id").asText(),
|
||||
"name", node.get("name").asText()
|
||||
));
|
||||
}
|
||||
}
|
||||
return orgs;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get user organizations for {}: {}", userId, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
|
||||
return cachedToken;
|
||||
@@ -85,7 +138,7 @@ public class LogtoManagementClient {
|
||||
.body("grant_type=client_credentials"
|
||||
+ "&client_id=" + config.getM2mClientId()
|
||||
+ "&client_secret=" + config.getM2mClientSecret()
|
||||
+ "&resource=" + config.getLogtoEndpoint() + "/api"
|
||||
+ "&resource=" + MGMT_API_RESOURCE
|
||||
+ "&scope=all")
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -20,9 +22,23 @@ import java.util.UUID;
|
||||
public class TenantController {
|
||||
|
||||
private final TenantService tenantService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
|
||||
public TenantController(TenantService tenantService) {
|
||||
public TenantController(TenantService tenantService, LogtoManagementClient logtoClient) {
|
||||
this.tenantService = tenantService;
|
||||
this.logtoClient = logtoClient;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<TenantResponse>> listAll(Authentication authentication) {
|
||||
String userId = authentication.getName();
|
||||
List<String> roles = logtoClient.getUserRoles(userId);
|
||||
if (!roles.contains("platform-admin")) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
List<TenantResponse> tenants = tenantService.findAll().stream()
|
||||
.map(this::toResponse).toList();
|
||||
return ResponseEntity.ok(tenants);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@@ -68,6 +68,10 @@ public class TenantService {
|
||||
return tenantRepository.findByLogtoOrgId(logtoOrgId);
|
||||
}
|
||||
|
||||
public List<TenantEntity> findAll() {
|
||||
return tenantRepository.findAll();
|
||||
}
|
||||
|
||||
public List<TenantEntity> listActive() {
|
||||
return tenantRepository.findByStatus(TenantStatus.ACTIVE);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user