refactor(tenant): replace tier+username with email-first creation
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m37s

- Remove tier from create tenant form (always defaults to STARTER,
  controlled via license minting)
- Admin email is now the primary identity field
- Username auto-derived from email local part, optionally overridable
- Set primaryEmail on Logto user at creation (prevents invalid accounts)
- Async tenant delete: PG/ClickHouse cleanup runs after commit instead
  of blocking the HTTP response
- Remove legacy /server/* OIDC redirect URIs from bootstrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-28 19:34:00 +02:00
parent 15c47fe36c
commit bd301ad1fe
11 changed files with 79 additions and 62 deletions

View File

@@ -44,7 +44,7 @@ public class OnboardingService {
// Create tenant via the existing vendor flow (no admin user — we'll add the caller) // Create tenant via the existing vendor flow (no admin user — we'll add the caller)
UUID actorId = resolveActorId(logtoUserId); UUID actorId = resolveActorId(logtoUserId);
var request = new CreateTenantRequest(name, slug, "STARTER", null, null); var request = new CreateTenantRequest(name, slug, null, null, null);
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId); TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
// Add the calling user to the Logto org as owner // Add the calling user to the Logto org as owner

View File

@@ -31,7 +31,7 @@ public class TenantService {
var entity = new TenantEntity(); var entity = new TenantEntity();
entity.setName(request.name()); entity.setName(request.name());
entity.setSlug(request.slug()); entity.setSlug(request.slug());
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER); entity.setTier(Tier.STARTER);
entity.setStatus(TenantStatus.PROVISIONING); entity.setStatus(TenantStatus.PROVISIONING);
var saved = tenantRepository.save(entity); var saved = tenantRepository.save(entity);

View File

@@ -7,7 +7,7 @@ import jakarta.validation.constraints.Size;
public record CreateTenantRequest( public record CreateTenantRequest(
@NotBlank @Size(max = 255) String name, @NotBlank @Size(max = 255) String name,
@NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug, @NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug,
String tier, String adminEmail,
String adminUsername, String adminUsername,
String adminPassword String adminPassword
) {} ) {}

View File

@@ -95,12 +95,18 @@ public class VendorTenantService {
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) { if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner"); String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
// Create tenant admin // Create tenant admin (email is the primary identity)
if (request.adminUsername() != null && request.adminPassword() != null) { if (request.adminEmail() != null && request.adminPassword() != null) {
try { try {
logtoClient.createUserWithPassword( String email = request.adminEmail();
request.adminUsername(), request.adminPassword(), String username = request.adminUsername();
if (username == null || username.isBlank()) {
username = email.substring(0, email.indexOf('@'));
}
String userId = logtoClient.createUserWithPassword(
username, request.adminPassword(),
tenant.getLogtoOrgId(), ownerRoleId); tenant.getLogtoOrgId(), ownerRoleId);
logtoClient.updateUserProfile(userId, Map.of("primaryEmail", email));
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to create admin user for tenant {}: {}", tenant.getSlug(), e.getMessage()); log.warn("Failed to create admin user for tenant {}: {}", tenant.getSlug(), e.getMessage());
} }
@@ -351,16 +357,6 @@ public class VendorTenantService {
} }
} }
// Drop per-tenant PG user + database
try {
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
} catch (Exception e) {
log.warn("Failed to drop tenant database for {}: {}", tenant.getSlug(), e.getMessage());
}
// Erase tenant data from ClickHouse (GDPR)
dataCleanupService.cleanupClickHouse(tenant.getSlug());
// Soft-delete // Soft-delete
tenant.setStatus(TenantStatus.DELETED); tenant.setStatus(TenantStatus.DELETED);
tenantRepository.save(tenant); tenantRepository.save(tenant);
@@ -368,6 +364,29 @@ public class VendorTenantService {
auditService.log(actorId, null, tenantId, auditService.log(actorId, null, tenantId,
AuditAction.TENANT_DELETE, tenant.getSlug(), AuditAction.TENANT_DELETE, tenant.getSlug(),
null, null, "SUCCESS", null); null, null, "SUCCESS", null);
// Drop databases and erase ClickHouse data asynchronously after commit
String slug = tenant.getSlug();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
self.deleteDataAsync(slug);
}
});
}
@Async
public void deleteDataAsync(String slug) {
try {
tenantDatabaseService.dropTenantDatabase(slug);
} catch (Exception e) {
log.warn("Failed to drop tenant database for {}: {}", slug, e.getMessage());
}
try {
dataCleanupService.cleanupClickHouse(slug);
} catch (Exception e) {
log.warn("Failed to clean up ClickHouse data for {}: {}", slug, e.getMessage());
}
} }
/** /**

View File

@@ -35,7 +35,7 @@ class LicenseControllerTest {
private String createTenantAndGetId() throws Exception { private String createTenantAndGetId() throws Exception {
String slug = "license-tenant-" + System.nanoTime(); String slug = "license-tenant-" + System.nanoTime();
var request = new CreateTenantRequest("License Test Org", slug, "TEAM", null, null); var request = new CreateTenantRequest("License Test Org", slug, null, null, null);
var result = mockMvc.perform(post("/api/tenants") var result = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j

View File

@@ -35,7 +35,7 @@ class TenantControllerTest {
@Test @Test
void createTenant_returns201() throws Exception { void createTenant_returns201() throws Exception {
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "STARTER", null, null); var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), null, null, null);
mockMvc.perform(post("/api/tenants") mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j

View File

@@ -41,7 +41,7 @@ class TenantServiceTest {
@Test @Test
void create_savesNewTenantWithCorrectFields() { void create_savesNewTenantWithCorrectFields() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "TEAM", null, null); var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
var actorId = UUID.randomUUID(); var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false); when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
@@ -51,7 +51,7 @@ class TenantServiceTest {
assertThat(result.getName()).isEqualTo("Acme Corp"); assertThat(result.getName()).isEqualTo("Acme Corp");
assertThat(result.getSlug()).isEqualTo("acme-corp"); assertThat(result.getSlug()).isEqualTo("acme-corp");
assertThat(result.getTier()).isEqualTo(Tier.TEAM); assertThat(result.getTier()).isEqualTo(Tier.STARTER);
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING); assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
} }

View File

@@ -33,8 +33,8 @@ class VendorTenantControllerTest {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
private String createTenant(String name, String slug, String tier) throws Exception { private String createTenant(String name, String slug) throws Exception {
var request = new CreateTenantRequest(name, slug, tier, null, null); var request = new CreateTenantRequest(name, slug, null, null, null);
var result = mockMvc.perform(post("/api/vendor/tenants") var result = mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
.claim("sub", "test-user") .claim("sub", "test-user")
@@ -51,7 +51,7 @@ class VendorTenantControllerTest {
@Test @Test
void listTenants_returnsAllTenants() throws Exception { void listTenants_returnsAllTenants() throws Exception {
String slug = "list-test-" + System.nanoTime(); String slug = "list-test-" + System.nanoTime();
createTenant("List Test Org", slug, "STARTER"); createTenant("List Test Org", slug);
mockMvc.perform(get("/api/vendor/tenants") mockMvc.perform(get("/api/vendor/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
@@ -65,7 +65,7 @@ class VendorTenantControllerTest {
@Test @Test
void createTenant_returns201() throws Exception { void createTenant_returns201() throws Exception {
String slug = "create-test-" + System.nanoTime(); String slug = "create-test-" + System.nanoTime();
var request = new CreateTenantRequest("Create Test Org", slug, "TEAM", null, null); var request = new CreateTenantRequest("Create Test Org", slug, null, null, null);
mockMvc.perform(post("/api/vendor/tenants") mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
@@ -78,16 +78,16 @@ class VendorTenantControllerTest {
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Create Test Org")) .andExpect(jsonPath("$.name").value("Create Test Org"))
.andExpect(jsonPath("$.slug").value(slug)) .andExpect(jsonPath("$.slug").value(slug))
.andExpect(jsonPath("$.tier").value("TEAM")) .andExpect(jsonPath("$.tier").value("STARTER"))
.andExpect(jsonPath("$.id").isNotEmpty()); .andExpect(jsonPath("$.id").isNotEmpty());
} }
@Test @Test
void createTenant_returns409ForDuplicateSlug() throws Exception { void createTenant_returns409ForDuplicateSlug() throws Exception {
String slug = "duplicate-vendor-" + System.nanoTime(); String slug = "duplicate-vendor-" + System.nanoTime();
createTenant("First Org", slug, "STARTER"); createTenant("First Org", slug);
var request = new CreateTenantRequest("Second Org", slug, "STARTER", null, null); var request = new CreateTenantRequest("Second Org", slug, null, null, null);
mockMvc.perform(post("/api/vendor/tenants") mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
.claim("sub", "test-user") .claim("sub", "test-user")
@@ -102,7 +102,7 @@ class VendorTenantControllerTest {
@Test @Test
void getTenantDetail_returnsDetailWithServerStatus() throws Exception { void getTenantDetail_returnsDetailWithServerStatus() throws Exception {
String slug = "detail-test-" + System.nanoTime(); String slug = "detail-test-" + System.nanoTime();
String id = createTenant("Detail Test Org", slug, "STARTER"); String id = createTenant("Detail Test Org", slug);
mockMvc.perform(get("/api/vendor/tenants/" + id) mockMvc.perform(get("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
@@ -118,7 +118,7 @@ class VendorTenantControllerTest {
@Test @Test
void suspendTenant_returnsUpdatedStatus() throws Exception { void suspendTenant_returnsUpdatedStatus() throws Exception {
String slug = "suspend-test-" + System.nanoTime(); String slug = "suspend-test-" + System.nanoTime();
String id = createTenant("Suspend Test Org", slug, "STARTER"); String id = createTenant("Suspend Test Org", slug);
mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend") mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend")
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
@@ -132,7 +132,7 @@ class VendorTenantControllerTest {
@Test @Test
void deleteTenant_returns204() throws Exception { void deleteTenant_returns204() throws Exception {
String slug = "delete-test-" + System.nanoTime(); String slug = "delete-test-" + System.nanoTime();
String id = createTenant("Delete Test Org", slug, "STARTER"); String id = createTenant("Delete Test Org", slug);
mockMvc.perform(delete("/api/vendor/tenants/" + id) mockMvc.perform(delete("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j .with(jwt().jwt(j -> j
@@ -144,7 +144,7 @@ class VendorTenantControllerTest {
@Test @Test
void createTenant_returns401WithoutAuth() throws Exception { void createTenant_returns401WithoutAuth() throws Exception {
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "STARTER", null, null); var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), null, null, null);
mockMvc.perform(post("/api/vendor/tenants") mockMvc.perform(post("/api/vendor/tenants")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)

View File

@@ -151,7 +151,7 @@ class VendorTenantServiceTest {
@Test @Test
void createAndProvision_createsTenantAndLicense() throws Exception { void createAndProvision_createsTenantAndLicense() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
var actorId = UUID.randomUUID(); var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
var license = licenseWithId(tenant.getId()); var license = licenseWithId(tenant.getId());
@@ -171,7 +171,7 @@ class VendorTenantServiceTest {
@Test @Test
void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception { void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
var actorId = UUID.randomUUID(); var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
var license = licenseWithId(tenant.getId()); var license = licenseWithId(tenant.getId());
@@ -195,7 +195,7 @@ class VendorTenantServiceTest {
@Test @Test
void createAndProvision_setsProvisionErrorOnFailure() throws Exception { void createAndProvision_setsProvisionErrorOnFailure() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
var actorId = UUID.randomUUID(); var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
var license = licenseWithId(tenant.getId()); var license = licenseWithId(tenant.getId());
@@ -218,7 +218,7 @@ class VendorTenantServiceTest {
@Test @Test
void createAndProvision_worksWithoutProvisioner() throws Exception { void createAndProvision_worksWithoutProvisioner() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
var actorId = UUID.randomUUID(); var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
var license = licenseWithId(tenant.getId()); var license = licenseWithId(tenant.getId());

View File

@@ -5,8 +5,6 @@ import { useCreateTenant } from '../../api/vendor-hooks';
import { errorMessage } from '../../api/client'; import { errorMessage } from '../../api/client';
import { toSlug } from '../../utils/slug'; import { toSlug } from '../../utils/slug';
const TIERS = ['STARTER', 'TEAM', 'BUSINESS', 'ENTERPRISE'];
export function CreateTenantPage() { export function CreateTenantPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
@@ -15,8 +13,9 @@ export function CreateTenantPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false); const [slugTouched, setSlugTouched] = useState(false);
const [tier, setTier] = useState('STARTER'); const [adminEmail, setAdminEmail] = useState('');
const [adminUsername, setAdminUsername] = useState(''); const [adminUsername, setAdminUsername] = useState('');
const [usernameTouched, setUsernameTouched] = useState(false);
const [adminPassword, setAdminPassword] = useState(''); const [adminPassword, setAdminPassword] = useState('');
useEffect(() => { useEffect(() => {
@@ -25,11 +24,18 @@ export function CreateTenantPage() {
} }
}, [name, slugTouched]); }, [name, slugTouched]);
useEffect(() => {
if (!usernameTouched && adminEmail.includes('@')) {
setAdminUsername(adminEmail.substring(0, adminEmail.indexOf('@')).replace(/[^a-zA-Z0-9]/g, ''));
}
}, [adminEmail, usernameTouched]);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
try { try {
const result = await createTenant.mutateAsync({ const result = await createTenant.mutateAsync({
name, slug, tier, name, slug,
adminEmail: adminEmail || undefined,
adminUsername: adminUsername || undefined, adminUsername: adminUsername || undefined,
adminPassword: adminPassword || undefined, adminPassword: adminPassword || undefined,
}); });
@@ -71,32 +77,24 @@ export function CreateTenantPage() {
/> />
</FormField> </FormField>
<FormField label="Tier" htmlFor="tenant-tier" required> <FormField label="Admin Email" htmlFor="admin-email" hint="Initial tenant admin (owner role)">
<select <Input
id="tenant-tier" id="admin-email"
value={tier} type="email"
onChange={(e) => setTier(e.target.value)} value={adminEmail}
style={{ onChange={(e) => setAdminEmail(e.target.value)}
width: '100%', placeholder="admin@acme.com"
padding: '8px 12px', />
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-surface)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
>
{TIERS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</FormField> </FormField>
<FormField label="Admin Username" htmlFor="admin-user" hint="Initial tenant admin (owner role). Alphanumeric only, no hyphens."> <FormField label="Username" htmlFor="admin-user" hint="Auto-generated from email, override if needed">
<Input <Input
id="admin-user" id="admin-user"
value={adminUsername} value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, ''))} onChange={(e) => {
setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, ''));
setUsernameTouched(true);
}}
placeholder="admin" placeholder="admin"
/> />
</FormField> </FormField>

View File

@@ -95,7 +95,7 @@ export interface VendorTenantDetail {
export interface CreateTenantRequest { export interface CreateTenantRequest {
name: string; name: string;
slug: string; slug: string;
tier?: string; adminEmail?: string;
adminUsername?: string; adminUsername?: string;
adminPassword?: string; adminPassword?: string;
} }