feat: bootstrap 2 users, tenant, org-scoped tokens, platform admin UI
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 39s

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:
hsiegeln
2026-04-05 02:50:51 +02:00
parent b83cfdcd49
commit 827e388349
17 changed files with 725 additions and 78 deletions

View File

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

View File

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

View File

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

View File

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

View File

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