From df76a2f4cb52b1b9b604573559555b26fae153e4 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Fri, 17 Jul 2026 11:34:16 -0700 Subject: [PATCH 1/8] Add authentication --- README.md | 37 ++- structurizr-application/pom.xml | 10 + .../configuration/Configuration.java | 17 ++ .../configuration/StructurizrProperties.java | 18 ++ .../server/domain/AuthenticationMethod.java | 1 + .../server/domain/WorkspaceMetadata.java | 15 +- .../web/api/NoOpAdminApiController.java | 23 +- .../web/api/ServerWorkspaceApiController.java | 41 ++- .../web/security/ApiAuthenticationUtils.java | 239 ++++++++++++++++++ .../web/security/AuthenticationCheck.java | 16 +- .../OidcAuthenticationConfiguration.java | 132 ++++++++++ .../security/OidcAuthenticationExtractor.java | 44 ++++ .../server/web/security/SecurityUtils.java | 2 + .../server/domain/WorkspaceMetadataTests.java | 41 +++ .../security/ApiAuthenticationUtilsTests.java | 89 +++++++ .../OidcAuthenticationExtractorTests.java | 52 ++++ .../web/security/SecurityUtilsTests.java | 26 ++ .../structurizr/api/AbstractApiClient.java | 2 +- .../java/com/structurizr/api/HttpHeaders.java | 1 + 19 files changed, 789 insertions(+), 17 deletions(-) create mode 100644 structurizr-application/src/main/java/com/structurizr/server/web/security/ApiAuthenticationUtils.java create mode 100644 structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationConfiguration.java create mode 100644 structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationExtractor.java create mode 100644 structurizr-application/src/test/java/com/structurizr/server/web/security/ApiAuthenticationUtilsTests.java create mode 100644 structurizr-application/src/test/java/com/structurizr/server/web/security/OidcAuthenticationExtractorTests.java diff --git a/README.md b/README.md index 81f06a36..b338885e 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,39 @@ The two quickest ways to get started with the Structurizr tooling are: ## Documentation -See [docs.structurizr.com](https://docs.structurizr.com) for documentation, getting started guides, tutorials, etc. \ No newline at end of file +See [docs.structurizr.com](https://docs.structurizr.com) for documentation, getting started guides, tutorials, etc. + +## Trimble Identity integration (enterprise hosting) + +This repository can be configured for enterprise-hosted deployments that require: + +- OIDC sign-in for the web UI. +- Token-based API authentication for workspace uploads. + +The key properties are: + +```properties +structurizr.authentication=oidc + +# OIDC web login +structurizr.authentication.oidc.issueruri=https://id.trimble.com +structurizr.authentication.oidc.clientid= +structurizr.authentication.oidc.clientsecret= +structurizr.authentication.oidc.scopes=openid,profile,email +structurizr.authentication.oidc.usernameclaim=email + +# API upload token validation +structurizr.authentication.api.issueruri=https://id.trimble.com +structurizr.authentication.api.jwkseturi=https://id.trimble.com/.well-known/jwks.json +structurizr.authentication.api.audience=structurizr-upload +structurizr.authentication.api.scopes=structurizr.upload + +# Optional migration fallback +structurizr.authentication.api.sharedtoken= +``` + +When configured, API uploads can pass a client-credentials OAuth access token via `-key`, e.g.: + +```bash +java -jar structurizr-1.0.0.war push -url https://structurizr-app.example.com/api -id 2 -workspace ./workspace.json -key "$ACCESS_TOKEN" -merge false -archive true +``` \ No newline at end of file diff --git a/structurizr-application/pom.xml b/structurizr-application/pom.xml index bde3c266..7aef2e1a 100644 --- a/structurizr-application/pom.xml +++ b/structurizr-application/pom.xml @@ -151,6 +151,16 @@ spring-security-saml2-service-provider 6.5.9 + + org.springframework.security + spring-security-oauth2-client + 6.5.9 + + + org.springframework.security + spring-security-oauth2-jose + 6.5.9 + org.springframework.session spring-session-data-redis diff --git a/structurizr-application/src/main/java/com/structurizr/configuration/Configuration.java b/structurizr-application/src/main/java/com/structurizr/configuration/Configuration.java index 9b910a66..9485ff28 100644 --- a/structurizr-application/src/main/java/com/structurizr/configuration/Configuration.java +++ b/structurizr-application/src/main/java/com/structurizr/configuration/Configuration.java @@ -294,6 +294,23 @@ private void setDefaults() { setDefault(CACHE_EXPIRY_IN_MINUTES, DEFAULT_CACHE_EXPIRY_IN_MINUTES); setDefault(ADMIN_USERS_AND_ROLES, ""); + setDefault(AUTHENTICATION_OIDC_REGISTRATION_ID, "trimble"); + setDefault(AUTHENTICATION_OIDC_ISSUER_URI, ""); + setDefault(AUTHENTICATION_OIDC_CLIENT_ID, ""); + setDefault(AUTHENTICATION_OIDC_CLIENT_SECRET, ""); + setDefault(AUTHENTICATION_OIDC_SCOPES, "openid,profile,email"); + setDefault(AUTHENTICATION_OIDC_AUTHORIZATION_URI, ""); + setDefault(AUTHENTICATION_OIDC_TOKEN_URI, ""); + setDefault(AUTHENTICATION_OIDC_JWK_SET_URI, ""); + setDefault(AUTHENTICATION_OIDC_USER_INFO_URI, ""); + setDefault(AUTHENTICATION_OIDC_USERNAME_CLAIM, "email"); + + setDefault(AUTHENTICATION_API_SHARED_TOKEN, ""); + setDefault(AUTHENTICATION_API_TOKEN_ISSUER_URI, ""); + setDefault(AUTHENTICATION_API_TOKEN_JWK_SET_URI, ""); + setDefault(AUTHENTICATION_API_TOKEN_AUDIENCE, ""); + setDefault(AUTHENTICATION_API_TOKEN_SCOPES, ""); + setDefault(Features.UI_DSL_EDITOR, FALSE); setDefault(Features.WORKSPACE_ARCHIVING, FALSE); setDefault(Features.WORKSPACE_BRANCHES, FALSE); diff --git a/structurizr-application/src/main/java/com/structurizr/configuration/StructurizrProperties.java b/structurizr-application/src/main/java/com/structurizr/configuration/StructurizrProperties.java index fd8becae..e77da0d8 100644 --- a/structurizr-application/src/main/java/com/structurizr/configuration/StructurizrProperties.java +++ b/structurizr-application/src/main/java/com/structurizr/configuration/StructurizrProperties.java @@ -30,6 +30,24 @@ public class StructurizrProperties { public static final String AUTHENTICATION_IMPLEMENTATION = "structurizr.authentication"; public static final String AUTHENTICATION_VARIANT_NONE = "none"; public static final String AUTHENTICATION_VARIANT_FILE = "file"; + public static final String AUTHENTICATION_VARIANT_OIDC = "oidc"; + + public static final String AUTHENTICATION_OIDC_REGISTRATION_ID = "structurizr.authentication.oidc.registrationid"; + public static final String AUTHENTICATION_OIDC_ISSUER_URI = "structurizr.authentication.oidc.issueruri"; + public static final String AUTHENTICATION_OIDC_CLIENT_ID = "structurizr.authentication.oidc.clientid"; + public static final String AUTHENTICATION_OIDC_CLIENT_SECRET = "structurizr.authentication.oidc.clientsecret"; + public static final String AUTHENTICATION_OIDC_SCOPES = "structurizr.authentication.oidc.scopes"; + public static final String AUTHENTICATION_OIDC_AUTHORIZATION_URI = "structurizr.authentication.oidc.authorizationuri"; + public static final String AUTHENTICATION_OIDC_TOKEN_URI = "structurizr.authentication.oidc.tokenuri"; + public static final String AUTHENTICATION_OIDC_JWK_SET_URI = "structurizr.authentication.oidc.jwkseturi"; + public static final String AUTHENTICATION_OIDC_USER_INFO_URI = "structurizr.authentication.oidc.userinfouri"; + public static final String AUTHENTICATION_OIDC_USERNAME_CLAIM = "structurizr.authentication.oidc.usernameclaim"; + + public static final String AUTHENTICATION_API_SHARED_TOKEN = "structurizr.authentication.api.sharedtoken"; + public static final String AUTHENTICATION_API_TOKEN_ISSUER_URI = "structurizr.authentication.api.issueruri"; + public static final String AUTHENTICATION_API_TOKEN_JWK_SET_URI = "structurizr.authentication.api.jwkseturi"; + public static final String AUTHENTICATION_API_TOKEN_AUDIENCE = "structurizr.authentication.api.audience"; + public static final String AUTHENTICATION_API_TOKEN_SCOPES = "structurizr.authentication.api.scopes"; public static final String SESSION_IMPLEMENTATION = "structurizr.session"; public static final String SESSION_VARIANT_LOCAL = "local"; diff --git a/structurizr-application/src/main/java/com/structurizr/server/domain/AuthenticationMethod.java b/structurizr-application/src/main/java/com/structurizr/server/domain/AuthenticationMethod.java index c4d5fef9..05702ced 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/domain/AuthenticationMethod.java +++ b/structurizr-application/src/main/java/com/structurizr/server/domain/AuthenticationMethod.java @@ -4,6 +4,7 @@ public enum AuthenticationMethod { NONE, LOCAL, + OIDC, SAML } \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java index 5fecad78..46b06ca8 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java +++ b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java @@ -2,8 +2,11 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.web.security.ApiAuthenticationUtils; import com.structurizr.util.DateUtils; import com.structurizr.util.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.text.ParseException; @@ -14,6 +17,8 @@ public class WorkspaceMetadata { + private static final Log log = LogFactory.getLog(WorkspaceMetadata.class); + public static final int LOCK_TIMEOUT_IN_MINUTES = 2; static final String NAME_PROPERTY = "name"; @@ -129,9 +134,17 @@ public boolean isApiKeyValid(String key) { String adminApiKey = Configuration.getInstance().getProperty(StructurizrProperties.API_KEY); if (!StringUtils.isNullOrEmpty(adminApiKey)) { // does the given API key match the bcrypt encoded admin API key? - return bcryptEncoder.matches(key, adminApiKey); + if (bcryptEncoder.matches(key, adminApiKey)) { + return true; + } + } + + if (ApiAuthenticationUtils.isSharedApiTokenValid(key)) { + return true; } + log.warn("Workspace API credential rejected"); + return false; } diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java b/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java index be3e92a4..c4145aba 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java @@ -15,28 +15,45 @@ public class NoOpAdminApiController extends AbstractController { private static final String MESSAGE = "The admin API is not supported in the open core version of the Structurizr server"; + private String resolveApiCredential(String xAuthorization, String authorization) { + if (xAuthorization != null && !xAuthorization.trim().isEmpty()) { + return xAuthorization; + } + + return authorization; + } + @RequestMapping(value = "/api/workspace", method = RequestMethod.GET, produces = "application/json; charset=UTF-8") public void getWorkspaces( - @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey + @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization ) { + String apiKey = resolveApiCredential(xAuthorization, authorization); + throw new ApiException(MESSAGE); } @RequestMapping(value = "/api/workspace", method = RequestMethod.POST, produces = "application/json; charset=UTF-8") public void createWorkspace( - @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey + @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization ) { + String apiKey = resolveApiCredential(xAuthorization, authorization); + throw new ApiException(MESSAGE); } @RequestMapping(value = "/api/workspace/{workspaceId}", method = RequestMethod.DELETE, produces = "application/json; charset=UTF-8") public void deleteWorkspace( - @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey, + @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @PathVariable("workspaceId") long workspaceId ) { + String apiKey = resolveApiCredential(xAuthorization, authorization); + throw new ApiException(MESSAGE); } diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java b/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java index e768762e..d6ef03db 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java @@ -13,7 +13,10 @@ import com.structurizr.util.DateUtils; import com.structurizr.util.ImageUtils; import com.structurizr.util.StringUtils; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import java.io.File; import java.io.IOException; @@ -38,13 +41,31 @@ @org.springframework.context.annotation.Profile("command-server") public class ServerWorkspaceApiController extends AbstractWorkspaceApiController { + private String resolveApiCredential(String apiKey) { + if (!StringUtils.isNullOrEmpty(apiKey)) { + return apiKey; + } + + ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return apiKey; + } + + HttpServletRequest request = requestAttributes.getRequest(); + if (request == null) { + return apiKey; + } + + return request.getHeader(HttpHeaders.AUTHORIZATION); + } + @CrossOrigin @RequestMapping(value = "/api/workspace/{workspaceId}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8") public String getWorkspace(@PathVariable("workspaceId") long workspaceId, @RequestParam(required = false) String version, @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey) { - return get(workspaceId, WorkspaceBranch.MAIN_BRANCH, version, apiKey); + return get(workspaceId, WorkspaceBranch.MAIN_BRANCH, version, resolveApiCredential(apiKey)); } @CrossOrigin @@ -53,7 +74,7 @@ public String getWorkspace(@PathVariable("workspaceId") long workspaceId, @RequestBody String json, @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey) { - return put(workspaceId, WorkspaceBranch.MAIN_BRANCH, json, apiKey); + return put(workspaceId, WorkspaceBranch.MAIN_BRANCH, json, resolveApiCredential(apiKey)); } @CrossOrigin @@ -62,7 +83,7 @@ public String getWorkspace(@PathVariable("workspaceId") long workspaceId, @PathVariable("branch") String branch, @RequestParam(required = false) String version, @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey) { - return get(workspaceId, branch, version, apiKey); + return get(workspaceId, branch, version, resolveApiCredential(apiKey)); } @CrossOrigin @@ -71,7 +92,7 @@ public String getWorkspace(@PathVariable("workspaceId") long workspaceId, @PathVariable("branch") String branch, @RequestBody String json, @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey) { - return put(workspaceId, branch, json, apiKey); + return put(workspaceId, branch, json, resolveApiCredential(apiKey)); } @RequestMapping(value = "/api/workspace/{workspaceId}/branch", method = RequestMethod.GET, produces = "application/json; charset=UTF-8") @@ -82,7 +103,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, throw new ApiException("Workspace branches are not enabled for this installation"); } - authoriseRequest(workspaceId, Permission.Read, apiKey); + authoriseRequest(workspaceId, Permission.Read, resolveApiCredential(apiKey)); try { List branches = workspaceComponent.getWorkspaceBranches(workspaceId); @@ -113,7 +134,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, throw new ApiException("Workspace branches are not enabled for this installation"); } - authoriseRequest(workspaceId, Permission.Write, apiKey); + authoriseRequest(workspaceId, Permission.Write, resolveApiCredential(apiKey)); if (WorkspaceBranch.isMainBranch(branch)) { throw new ApiException("The main branch cannot be deleted"); @@ -140,7 +161,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, user = u.getUsername(); } - authoriseRequest(workspaceId, Permission.Write, apiKey); + authoriseRequest(workspaceId, Permission.Write, resolveApiCredential(apiKey)); if (workspaceComponent.lockWorkspace(workspaceId, user, agent)) { return new ApiResponse("OK"); @@ -163,7 +184,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, user = u.getUsername(); } - authoriseRequest(workspaceId, Permission.Write, apiKey); + authoriseRequest(workspaceId, Permission.Write, resolveApiCredential(apiKey)); WorkspaceMetadata workspaceMetadata = workspaceComponent.getWorkspaceMetadata(workspaceId); if (workspaceMetadata.isLockedBy(user, agent)) { @@ -183,7 +204,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, @RequestBody String imageAsBase64EncodedDataUri, @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String apiKey) { - return storeImage(workspaceId, WorkspaceBranch.NO_BRANCH, filename, imageAsBase64EncodedDataUri, apiKey); + return storeImage(workspaceId, WorkspaceBranch.NO_BRANCH, filename, imageAsBase64EncodedDataUri, resolveApiCredential(apiKey)); } @RequestMapping(value = "/api/workspace/{workspaceId}/branch/{branch}/images/{filename:.+}", method = RequestMethod.PUT, consumes = "text/plain", produces = "application/json; charset=UTF-8") @@ -197,7 +218,7 @@ public String getBranches(@PathVariable("workspaceId") long workspaceId, throw new ApiException("Workspace branches are not enabled for this installation"); } - return storeImage(workspaceId, branch, filename, imageAsBase64EncodedDataUri, apiKey); + return storeImage(workspaceId, branch, filename, imageAsBase64EncodedDataUri, resolveApiCredential(apiKey)); } private ApiResponse storeImage(long workspaceId, String branch, String filename, String imageAsBase64EncodedDataUri, String apiKey) { diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/security/ApiAuthenticationUtils.java b/structurizr-application/src/main/java/com/structurizr/server/web/security/ApiAuthenticationUtils.java new file mode 100644 index 00000000..bdbc883f --- /dev/null +++ b/structurizr-application/src/main/java/com/structurizr/server/web/security/ApiAuthenticationUtils.java @@ -0,0 +1,239 @@ +package com.structurizr.server.web.security; + +import com.structurizr.configuration.Configuration; +import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.util.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class ApiAuthenticationUtils { + + private static final Log log = LogFactory.getLog(ApiAuthenticationUtils.class); + private static final ConcurrentMap JWT_DECODER_CACHE = new ConcurrentHashMap<>(); + + private static volatile JwtDecoder jwtDecoderOverride; + + private ApiAuthenticationUtils() { + } + + public static String getOidcRegistrationId() { + String registrationId = Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_REGISTRATION_ID); + if (StringUtils.isNullOrEmpty(registrationId)) { + return "trimble"; + } + + return registrationId; + } + + public static String getOidcIssuerUri() { + String issuerUri = Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_ISSUER_URI); + if (StringUtils.isNullOrEmpty(issuerUri)) { + return ""; + } + + return removeTrailingSlash(issuerUri); + } + + public static Set parseConfigValues(String value) { + if (StringUtils.isNullOrEmpty(value)) { + return Collections.emptySet(); + } + + String[] tokens = value.split("[,\\s]+"); + Set values = new LinkedHashSet<>(); + for (String token : tokens) { + String trimmed = token.trim(); + if (!trimmed.isEmpty()) { + values.add(trimmed); + } + } + + return values; + } + + public static boolean isSharedApiTokenValid(String providedSecret) { + String sharedToken = Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_API_SHARED_TOKEN); + if (StringUtils.isNullOrEmpty(sharedToken)) { + return false; + } + + if (sharedToken.startsWith("$2a$") || sharedToken.startsWith("$2b$") || sharedToken.startsWith("$2y$")) { + return new BCryptPasswordEncoder().matches(providedSecret, sharedToken); + } + + byte[] provided = providedSecret.getBytes(StandardCharsets.UTF_8); + byte[] expected = sharedToken.getBytes(StandardCharsets.UTF_8); + + return MessageDigest.isEqual(provided, expected); + } + + public static boolean isJwtTokenValid(String token) { + String issuerUri = removeTrailingSlash(Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_ISSUER_URI)); + String jwkSetUri = Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_JWK_SET_URI); + + if (StringUtils.isNullOrEmpty(jwkSetUri) && !StringUtils.isNullOrEmpty(issuerUri)) { + jwkSetUri = issuerUri + "/.well-known/jwks.json"; + } + + if (StringUtils.isNullOrEmpty(jwkSetUri)) { + return false; + } + + Jwt jwt; + try { + jwt = getJwtDecoder(issuerUri, jwkSetUri).decode(token); + } catch (JwtException e) { + log.warn("API token rejected due to JWT decode/validation error", e); + return false; + } + + if (!hasValidIssuer(jwt, issuerUri)) { + log.warn("API token rejected due to issuer mismatch"); + return false; + } + + if (!hasValidTimestamps(jwt)) { + log.warn("API token rejected due to invalid token timestamps"); + return false; + } + + Set requiredAudiences = parseConfigValues(Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_AUDIENCE)); + if (!requiredAudiences.isEmpty() && !hasRequiredAudience(jwt, requiredAudiences)) { + log.warn("API token rejected due to missing audience"); + return false; + } + + Set requiredScopes = parseConfigValues(Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_SCOPES)); + if (!requiredScopes.isEmpty() && !hasRequiredScopes(jwt, requiredScopes)) { + log.warn("API token rejected due to missing scope"); + return false; + } + + return true; + } + + private static JwtDecoder getJwtDecoder(String issuerUri, String jwkSetUri) { + JwtDecoder override = jwtDecoderOverride; + if (override != null) { + return override; + } + + String key = issuerUri + "|" + jwkSetUri; + return JWT_DECODER_CACHE.computeIfAbsent(key, unused -> NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build()); + } + + private static boolean hasValidIssuer(Jwt jwt, String requiredIssuer) { + if (StringUtils.isNullOrEmpty(requiredIssuer)) { + return true; + } + + if (jwt.getIssuer() == null) { + return false; + } + + return requiredIssuer.equalsIgnoreCase(removeTrailingSlash(jwt.getIssuer().toString())); + } + + private static boolean hasValidTimestamps(Jwt jwt) { + Instant now = Instant.now(); + if (jwt.getExpiresAt() != null && !jwt.getExpiresAt().isAfter(now)) { + return false; + } + + if (jwt.getNotBefore() != null && jwt.getNotBefore().isAfter(now)) { + return false; + } + + return true; + } + + private static boolean hasRequiredAudience(Jwt jwt, Set requiredAudiences) { + if (requiredAudiences.isEmpty()) { + return true; + } + + Set audiences = new LinkedHashSet<>(jwt.getAudience()); + for (String requiredAudience : requiredAudiences) { + if (audiences.contains(requiredAudience)) { + return true; + } + } + + return false; + } + + private static boolean hasRequiredScopes(Jwt jwt, Set requiredScopes) { + if (requiredScopes.isEmpty()) { + return true; + } + + Set scopes = new LinkedHashSet<>(); + + String scope = jwt.getClaimAsString("scope"); + if (!StringUtils.isNullOrEmpty(scope)) { + scopes.addAll(Arrays.asList(scope.split("\\s+"))); + } + + Object scpClaim = jwt.getClaim("scp"); + if (scpClaim instanceof String) { + String scp = (String) scpClaim; + scopes.addAll(Arrays.asList(scp.split("\\s+"))); + } else if (scpClaim instanceof Collection) { + for (Object item : (Collection) scpClaim) { + scopes.add(String.valueOf(item)); + } + } + + Set lowerScopes = new LinkedHashSet<>(); + for (String configuredScope : scopes) { + lowerScopes.add(configuredScope.toLowerCase(Locale.ROOT)); + } + + for (String requiredScope : requiredScopes) { + if (lowerScopes.contains(requiredScope.toLowerCase(Locale.ROOT))) { + return true; + } + } + + return false; + } + + private static String removeTrailingSlash(String value) { + if (StringUtils.isNullOrEmpty(value)) { + return ""; + } + + if (value.endsWith("/")) { + return value.substring(0, value.length() - 1); + } + + return value; + } + + public static void setJwtDecoderOverrideForTesting(JwtDecoder jwtDecoder) { + jwtDecoderOverride = jwtDecoder; + } + + public static void clearJwtDecoderOverrideForTesting() { + jwtDecoderOverride = null; + JWT_DECODER_CACHE.clear(); + } + +} diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/security/AuthenticationCheck.java b/structurizr-application/src/main/java/com/structurizr/server/web/security/AuthenticationCheck.java index b426935f..d3b6c46d 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/security/AuthenticationCheck.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/security/AuthenticationCheck.java @@ -1,6 +1,7 @@ package com.structurizr.server.web.security; import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.util.StringUtils; import com.structurizr.server.Server; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -17,11 +18,24 @@ public class AuthenticationCheck { @EventListener public void onApplicationEvent(final ServletWebServerInitializedEvent event) { + String authenticationImplementation = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_IMPLEMENTATION); + + if (StructurizrProperties.AUTHENTICATION_VARIANT_OIDC.equalsIgnoreCase(authenticationImplementation)) { + String issuerUri = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_ISSUER_URI); + String clientId = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_CLIENT_ID); + + if (StringUtils.isNullOrEmpty(issuerUri) || StringUtils.isNullOrEmpty(clientId)) { + log.fatal("Authentication is configured for OIDC but required properties are missing: " + + StructurizrProperties.AUTHENTICATION_OIDC_ISSUER_URI + " and/or " + StructurizrProperties.AUTHENTICATION_OIDC_CLIENT_ID); + System.exit(1); + } + } + if (!SecurityUtils.isAuthenticationConfigured()) { log.fatal("Authentication has not been configured: " + StructurizrProperties.AUTHENTICATION_IMPLEMENTATION + "=" + - com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_IMPLEMENTATION) + + authenticationImplementation + " is not supported in this build"); System.exit(1); } diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationConfiguration.java b/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationConfiguration.java new file mode 100644 index 00000000..5db2b047 --- /dev/null +++ b/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationConfiguration.java @@ -0,0 +1,132 @@ +package com.structurizr.server.web.security; + +import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.util.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +@EnableWebSecurity +@Profile("authentication-oidc") +class OidcAuthenticationConfiguration { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable); + + http.authorizeHttpRequests(authorizeRequests -> authorizeRequests + .requestMatchers("/api/**", "/health", "/error", "/favicon.ico", "/signin", "/oauth2/**", "/login/**").permitAll() + .anyRequest().authenticated() + ); + + http.oauth2Login(oauth2Login -> oauth2Login + .loginPage("/signin") + .defaultSuccessUrl("/", true) + ); + + http.logout(logout -> logout + .logoutUrl("/signout") + .logoutSuccessUrl("/signin?logout") + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") + ); + + http.headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable) + ); + + SecurityUtils.setAuthenticationConfigured(true); + + return http.build(); + } + + @Bean + ClientRegistrationRepository clientRegistrationRepository() { + String registrationId = ApiAuthenticationUtils.getOidcRegistrationId(); + String issuerUri = ApiAuthenticationUtils.getOidcIssuerUri(); + + String authorizationUri = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_AUTHORIZATION_URI); + if (StringUtils.isNullOrEmpty(authorizationUri)) { + authorizationUri = issuerUri + "/oauth/authorize"; + } + + String tokenUri = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_TOKEN_URI); + if (StringUtils.isNullOrEmpty(tokenUri)) { + tokenUri = issuerUri + "/oauth/token"; + } + + String jwkSetUri = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_JWK_SET_URI); + if (StringUtils.isNullOrEmpty(jwkSetUri)) { + jwkSetUri = issuerUri + "/.well-known/jwks.json"; + } + + String userInfoUri = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_USER_INFO_URI); + if (StringUtils.isNullOrEmpty(userInfoUri)) { + userInfoUri = issuerUri + "/oauth/userinfo"; + } + + List scopes = new ArrayList<>(ApiAuthenticationUtils.parseConfigValues(com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_SCOPES))); + if (scopes.isEmpty()) { + scopes = List.of("openid", "profile", "email"); + } + + String clientSecret = com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_CLIENT_SECRET); + + ClientRegistration.Builder builder = ClientRegistration + .withRegistrationId(registrationId) + .clientId(com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_CLIENT_ID)) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope(scopes.toArray(new String[0])) + .authorizationUri(authorizationUri) + .tokenUri(tokenUri) + .jwkSetUri(jwkSetUri) + .issuerUri(issuerUri) + .userInfoUri(userInfoUri) + .userNameAttributeName(com.structurizr.configuration.Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_USERNAME_CLAIM)); + + if (StringUtils.isNullOrEmpty(clientSecret)) { + builder.clientAuthenticationMethod(ClientAuthenticationMethod.NONE); + } else { + builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + builder.clientSecret(clientSecret); + } + + return new InMemoryClientRegistrationRepository(builder.build()); + } + + @Bean + OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) { + return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository); + } + + @Controller + static class SignInController { + + @GetMapping("/signin") + String signIn() { + return "redirect:/oauth2/authorization/" + ApiAuthenticationUtils.getOidcRegistrationId(); + } + + } + +} diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationExtractor.java b/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationExtractor.java new file mode 100644 index 00000000..92fc4f48 --- /dev/null +++ b/structurizr-application/src/main/java/com/structurizr/server/web/security/OidcAuthenticationExtractor.java @@ -0,0 +1,44 @@ +package com.structurizr.server.web.security; + +import com.structurizr.configuration.Configuration; +import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.domain.AuthenticationMethod; +import com.structurizr.server.domain.User; +import com.structurizr.util.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +class OidcAuthenticationExtractor implements AuthenticationExtractor { + + @Override + public User extract(Authentication authentication) { + OAuth2AuthenticationToken oauth2AuthenticationToken = (OAuth2AuthenticationToken) authentication; + OAuth2AuthenticatedPrincipal principal = oauth2AuthenticationToken.getPrincipal(); + + String usernameClaim = Configuration.getInstance().getProperty(StructurizrProperties.AUTHENTICATION_OIDC_USERNAME_CLAIM); + String username = principal.getAttribute(usernameClaim); + if (StringUtils.isNullOrEmpty(username)) { + username = principal.getAttribute("email"); + } + if (StringUtils.isNullOrEmpty(username)) { + username = principal.getAttribute("preferred_username"); + } + if (StringUtils.isNullOrEmpty(username)) { + username = principal.getName(); + } + + Set roles = new HashSet<>(); + for (GrantedAuthority grantedAuthority : oauth2AuthenticationToken.getAuthorities()) { + roles.add(grantedAuthority.getAuthority().toLowerCase(Locale.ROOT)); + } + + return new User(username, roles, AuthenticationMethod.OIDC); + } + +} diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/security/SecurityUtils.java b/structurizr-application/src/main/java/com/structurizr/server/web/security/SecurityUtils.java index e9ada80b..e7eecb68 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/security/SecurityUtils.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/security/SecurityUtils.java @@ -5,6 +5,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -24,6 +25,7 @@ public final class SecurityUtils { static { registerAuthenticationExtractor(AnonymousAuthenticationToken.class, new AnonymousAuthenticationExtractor()); registerAuthenticationExtractor(UsernamePasswordAuthenticationToken.class, new UsernamePasswordAuthenticationExtractor()); + registerAuthenticationExtractor(OAuth2AuthenticationToken.class, new OidcAuthenticationExtractor()); } public static void registerAuthenticationExtractor(Class clazz, AuthenticationExtractor extractor) { diff --git a/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java b/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java index 6fea3cd5..fa0dceb1 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java @@ -1,10 +1,13 @@ package com.structurizr.server.domain; import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.web.security.ApiAuthenticationUtils; import com.structurizr.server.web.AbstractTestsBase; import com.structurizr.util.DateUtils; import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -484,4 +487,42 @@ void getPermissions_WhenAuthenticationIsEnabled_WorkspaceUsers_AdminUsers() { assertTrue(wmd.getPermissions(read).contains(Permission.Read)); } + @Test + void isApiKeyValid_ReturnsTrue_WhenConfiguredSharedApiTokenMatches() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_SHARED_TOKEN, "shared-upload-token"); + configureAsServerWithAuthenticationEnabled(properties); + + WorkspaceMetadata workspace = new WorkspaceMetadata(1); + workspace.setApiKey("workspace-api-key"); + + assertTrue(workspace.isApiKeyValid("shared-upload-token")); + } + + @Test + void isApiKeyValid_ReturnsTrue_WhenConfiguredJwtTokenIsValid() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_ISSUER_URI, "https://id.trimble.com"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_JWK_SET_URI, "https://id.trimble.com/.well-known/jwks.json"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_AUDIENCE, "structurizr-upload"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_SCOPES, "structurizr.upload"); + configureAsServerWithAuthenticationEnabled(properties); + + ApiAuthenticationUtils.setJwtDecoderOverrideForTesting(token -> Jwt.withTokenValue(token) + .header("alg", "RS256") + .issuer("https://id.trimble.com") + .audience(List.of("structurizr-upload")) + .claim("scope", "structurizr.upload") + .issuedAt(Instant.now().minusSeconds(30)) + .expiresAt(Instant.now().plusSeconds(300)) + .build()); + + WorkspaceMetadata workspace = new WorkspaceMetadata(1); + workspace.setApiKey("workspace-api-key"); + + assertTrue(workspace.isApiKeyValid("jwt-token")); + + ApiAuthenticationUtils.clearJwtDecoderOverrideForTesting(); + } + } \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/security/ApiAuthenticationUtilsTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/security/ApiAuthenticationUtilsTests.java new file mode 100644 index 00000000..958171dd --- /dev/null +++ b/structurizr-application/src/test/java/com/structurizr/server/web/security/ApiAuthenticationUtilsTests.java @@ -0,0 +1,89 @@ +package com.structurizr.server.web.security; + +import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.web.AbstractTestsBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtException; + +import java.time.Instant; +import java.util.List; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApiAuthenticationUtilsTests extends AbstractTestsBase { + + @AfterEach + void tearDown() { + ApiAuthenticationUtils.clearJwtDecoderOverrideForTesting(); + } + + @Test + void isSharedApiTokenValid_ReturnsTrueForConfiguredStaticSharedToken() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_SHARED_TOKEN, "shared-upload-token"); + configureAsServerWithAuthenticationEnabled(properties); + + assertTrue(ApiAuthenticationUtils.isSharedApiTokenValid("shared-upload-token")); + assertFalse(ApiAuthenticationUtils.isSharedApiTokenValid("wrong-token")); + } + + @Test + void isJwtTokenValid_ReturnsTrueWhenIssuerAudienceAndScopeAreValid() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_ISSUER_URI, "https://id.trimble.com"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_JWK_SET_URI, "https://id.trimble.com/.well-known/jwks.json"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_AUDIENCE, "structurizr-upload"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_SCOPES, "structurizr.upload"); + configureAsServerWithAuthenticationEnabled(properties); + + ApiAuthenticationUtils.setJwtDecoderOverrideForTesting(token -> Jwt.withTokenValue(token) + .header("alg", "RS256") + .issuer("https://id.trimble.com") + .audience(List.of("structurizr-upload")) + .claim("scope", "structurizr.upload") + .issuedAt(Instant.now().minusSeconds(30)) + .expiresAt(Instant.now().plusSeconds(300)) + .build()); + + assertTrue(ApiAuthenticationUtils.isJwtTokenValid("token")); + } + + @Test + void isJwtTokenValid_ReturnsFalseWhenScopeIsMissing() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_ISSUER_URI, "https://id.trimble.com"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_JWK_SET_URI, "https://id.trimble.com/.well-known/jwks.json"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_AUDIENCE, "structurizr-upload"); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_SCOPES, "structurizr.upload"); + configureAsServerWithAuthenticationEnabled(properties); + + ApiAuthenticationUtils.setJwtDecoderOverrideForTesting(token -> Jwt.withTokenValue(token) + .header("alg", "RS256") + .issuer("https://id.trimble.com") + .audience(List.of("structurizr-upload")) + .claim("scope", "other.scope") + .issuedAt(Instant.now().minusSeconds(30)) + .expiresAt(Instant.now().plusSeconds(300)) + .build()); + + assertFalse(ApiAuthenticationUtils.isJwtTokenValid("token")); + } + + @Test + void isJwtTokenValid_ReturnsFalseWhenTokenCannotBeDecoded() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_API_TOKEN_JWK_SET_URI, "https://id.trimble.com/.well-known/jwks.json"); + configureAsServerWithAuthenticationEnabled(properties); + + ApiAuthenticationUtils.setJwtDecoderOverrideForTesting(token -> { + throw new JwtException("invalid token"); + }); + + assertFalse(ApiAuthenticationUtils.isJwtTokenValid("token")); + } + +} diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/security/OidcAuthenticationExtractorTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/security/OidcAuthenticationExtractorTests.java new file mode 100644 index 00000000..26ed2abd --- /dev/null +++ b/structurizr-application/src/test/java/com/structurizr/server/web/security/OidcAuthenticationExtractorTests.java @@ -0,0 +1,52 @@ +package com.structurizr.server.web.security; + +import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.domain.AuthenticationMethod; +import com.structurizr.server.domain.User; +import com.structurizr.server.web.AbstractTestsBase; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OidcAuthenticationExtractorTests extends AbstractTestsBase { + + @Test + void extract_UsesConfiguredUsernameClaimAndCapturesAuthorities() { + Properties properties = new Properties(); + properties.setProperty(StructurizrProperties.AUTHENTICATION_OIDC_USERNAME_CLAIM, "email"); + configureAsServer(properties); + + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_ARCHITECT"), + new SimpleGrantedAuthority("SCOPE_structurizr.upload") + ); + + DefaultOAuth2User principal = new DefaultOAuth2User( + authorities, + Map.of( + "sub", "123", + "email", "architect@example.com" + ), + "email" + ); + + OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(principal, authorities, "trimble"); + User user = new OidcAuthenticationExtractor().extract(authenticationToken); + + assertEquals("architect@example.com", user.getUsername()); + assertEquals(AuthenticationMethod.OIDC, user.getAuthenticationMethod()); + assertEquals(Set.of("role_architect", "scope_structurizr.upload"), user.getRoles()); + assertTrue(user.isAuthenticated()); + } + +} diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/security/SecurityUtilsTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/security/SecurityUtilsTests.java index f0e79deb..0440989c 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/security/SecurityUtilsTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/security/SecurityUtilsTests.java @@ -6,11 +6,15 @@ import org.junit.jupiter.api.Test; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import java.util.HashSet; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -46,4 +50,26 @@ void getUser_WithUsernameAndPasswordAuthentication() { assertTrue(user.isAuthenticated()); } + @Test + void getUser_WithOidcAuthentication() { + Authentication authentication = new OAuth2AuthenticationToken( + new DefaultOAuth2User( + Set.of(new SimpleGrantedAuthority("ROLE_ARCHITECT"), new SimpleGrantedAuthority("SCOPE_structurizr.upload")), + Map.of("email", "oidc-user@example.com"), + "email" + ), + Set.of(new SimpleGrantedAuthority("ROLE_ARCHITECT"), new SimpleGrantedAuthority("SCOPE_structurizr.upload")), + "trimble" + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + User user = SecurityUtils.getUser(); + assertEquals("oidc-user@example.com", user.getUsername()); + assertEquals(AuthenticationMethod.OIDC, user.getAuthenticationMethod()); + assertTrue(user.getRoles().contains("role_architect")); + assertTrue(user.getRoles().contains("scope_structurizr.upload")); + assertTrue(user.isAuthenticated()); + } + } \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java b/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java index 966c2fce..bbee4253 100644 --- a/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java +++ b/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java @@ -67,7 +67,7 @@ protected void addHeaders(HttpUriRequestBase httpRequest, String contentType) { httpRequest.addHeader(HttpHeaders.USER_AGENT, agent); if (!StringUtils.isNullOrEmpty(apiKey)) { - httpRequest.addHeader(HttpHeaders.X_AUTHORIZATION, apiKey); + httpRequest.addHeader(HttpHeaders.AUTHORIZATION, apiKey); } if (httpMethod.equals("PUT")) { diff --git a/structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java b/structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java index 8ab8ed81..72ecb326 100644 --- a/structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java +++ b/structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java @@ -6,6 +6,7 @@ public final class HttpHeaders { public static final String USER_AGENT = "User-Agent"; + public static final String AUTHORIZATION = "Authorization"; public static final String X_AUTHORIZATION = "X-Authorization"; public static final String CONTENT_TYPE = "Content-Type"; From 9bc60259fe298c72caec2d6c214b343531687259 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Mon, 20 Jul 2026 19:50:27 -0700 Subject: [PATCH 2/8] Add workspace name routing. --- structurizr-application/pom.xml | 11 +++ .../workspace/WorkspaceComponent.java | 2 + .../workspace/WorkspaceComponentImpl.java | 27 ++++++++ .../DiagramViewerController.java | 41 ++++++++++++ .../WorkspaceSummaryController.java | 67 +++++++++++++++++-- .../WorkspaceComponentImplTests.java | 64 ++++++++++++++++++ .../server/web/MockWorkspaceComponent.java | 5 ++ .../DiagramViewerControllerTests.java | 33 +++++++++ .../WorkspaceSummaryControllerTests.java | 57 +++++++++++++++- 9 files changed, 300 insertions(+), 7 deletions(-) diff --git a/structurizr-application/pom.xml b/structurizr-application/pom.xml index 7aef2e1a..49739a90 100644 --- a/structurizr-application/pom.xml +++ b/structurizr-application/pom.xml @@ -95,6 +95,17 @@ + + com.structurizr + structurizr-client + 6.2.0 + + + commons-logging + commons-logging + + + org.codehaus.groovy groovy-jsr223 diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java index f145ee5c..9590f9ab 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java @@ -19,6 +19,8 @@ public interface WorkspaceComponent { WorkspaceMetadata getWorkspaceMetadata(long workspaceId) throws WorkspaceComponentException; + WorkspaceMetadata getWorkspaceMetadata(String workspaceName) throws WorkspaceComponentException; + void putWorkspaceMetadata(WorkspaceMetadata workspaceMetadata); String getWorkspace(long workspaceId, String branch, String version); diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java index 889d9e8f..1183fe22 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java @@ -190,6 +190,33 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) throws Workspace return wmd; } + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) throws WorkspaceComponentException { + if (StringUtils.isNullOrEmpty(workspaceName)) { + throw new IllegalArgumentException("Workspace name cannot be null or empty"); + } + + String normalizedWorkspaceName = workspaceName.trim(); + WorkspaceMetadata match = null; + + for (Long workspaceId : workspaceAdapter.getWorkspaceIds()) { + WorkspaceMetadata workspaceMetadata = getWorkspaceMetadata(workspaceId); + if (workspaceMetadata == null) { + continue; + } + + if (normalizedWorkspaceName.equalsIgnoreCase(workspaceMetadata.getName())) { + if (match != null) { + throw new WorkspaceComponentException("Multiple workspaces found with name: " + workspaceName); + } + + match = workspaceMetadata; + } + } + + return match; + } + @Override public void putWorkspaceMetadata(WorkspaceMetadata workspaceMetadata) { if (workspaceMetadata == null) { diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java index 05d0724b..255546d9 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java @@ -3,7 +3,9 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.Profile; import com.structurizr.configuration.StructurizrProperties; +import com.structurizr.server.component.workspace.WorkspaceComponentException; import com.structurizr.server.domain.Permission; +import com.structurizr.server.domain.WorkspaceMetadata; import com.structurizr.server.web.Views; import com.structurizr.util.HtmlUtils; import com.structurizr.util.StringUtils; @@ -19,6 +21,21 @@ @Controller class DiagramViewerController extends AbstractWorkspaceController { + @RequestMapping(value = "/workspace/diagrams", method = RequestMethod.GET, params = "name") + String redirectToAuthenticatedDiagramViewerByName( + @RequestParam("name") String workspaceName, + @RequestParam(required = false, defaultValue = "") String branch, + @RequestParam(required = false) String version, + ModelMap model + ) { + WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceName); + if (workspaceMetadata == null) { + return show404Page(model); + } + + return "redirect:" + buildDiagramViewerUrl(workspaceMetadata.getId(), branch, version); + } + @RequestMapping(value = "/workspace/{workspaceId}/diagrams", method = RequestMethod.GET) String showAuthenticatedDiagramViewer( @PathVariable("workspaceId") long workspaceId, @@ -53,4 +70,28 @@ String showAuthenticatedDiagramViewer( ); } + private WorkspaceMetadata resolveWorkspaceByName(String workspaceName) { + try { + return workspaceComponent.getWorkspaceMetadata(workspaceName); + } catch (WorkspaceComponentException e) { + return null; + } + } + + private String buildDiagramViewerUrl(long workspaceId, String branch, String version) { + StringBuilder url = new StringBuilder("/workspace/").append(workspaceId).append("/diagrams"); + boolean hasQuery = false; + + if (branch != null && !branch.isEmpty()) { + url.append("?branch=").append(branch); + hasQuery = true; + } + + if (version != null && !version.isEmpty()) { + url.append(hasQuery ? "&" : "?").append("version=").append(version); + } + + return url.toString(); + } + } \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java index 2d6b925c..6b879ac3 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java @@ -3,6 +3,8 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.Features; import com.structurizr.configuration.Profile; +import com.structurizr.server.component.workspace.WorkspaceComponentException; +import com.structurizr.server.domain.WorkspaceMetadata; import com.structurizr.server.web.Views; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; @@ -14,32 +16,89 @@ @Controller class WorkspaceSummaryController extends AbstractWorkspaceController { + @RequestMapping(value = "/workspace", method = RequestMethod.GET, params = "name") + public String redirectToWorkspaceSummaryByName( + @RequestParam("name") String workspaceName, + @RequestParam(required = false, defaultValue = "") String branch, + @RequestParam(required = false) String version, + ModelMap model + ) { + WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceName); + if (workspaceMetadata == null) { + return show404Page(model); + } + + return "redirect:" + buildWorkspaceSummaryUrl(workspaceMetadata.getId(), branch, version); + } + @RequestMapping(value = "/workspace/{workspaceId}", method = RequestMethod.GET) public String showAuthenticatedWorkspaceSummary( - @PathVariable("workspaceId") long workspaceId, + @PathVariable("workspaceId") String workspaceId, @RequestParam(required = false, defaultValue = "") String branch, @RequestParam(required = false) String version, ModelMap model ) { + Long numericWorkspaceId = parseWorkspaceId(workspaceId); + if (numericWorkspaceId == null) { + WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceId); + if (workspaceMetadata == null) { + return show404Page(model); + } + + return "redirect:" + buildWorkspaceSummaryUrl(workspaceMetadata.getId(), branch, version); + } + if (Configuration.getInstance().getProfile() == com.structurizr.configuration.Profile.Local) { enableLocalRefresh(model); } return showAuthenticatedView( - Views.WORKSPACE_SUMMARY, workspaceId, + Views.WORKSPACE_SUMMARY, numericWorkspaceId, workspaceMetadata -> { if (Configuration.getInstance().getProfile() == Profile.Server) { if (Configuration.getInstance().isFeatureEnabled(Features.WORKSPACE_BRANCHES)) { model.addAttribute("branchesEnabled", true); model.addAttribute("branch", branch); - model.addAttribute("branches", workspaceComponent.getWorkspaceBranches(workspaceId)); + model.addAttribute("branches", workspaceComponent.getWorkspaceBranches(numericWorkspaceId)); } - model.addAttribute("versions", workspaceComponent.getWorkspaceVersions(workspaceId, branch)); + model.addAttribute("versions", workspaceComponent.getWorkspaceVersions(numericWorkspaceId, branch)); } }, branch, version, model, true, true ); } + private WorkspaceMetadata resolveWorkspaceByName(String workspaceName) { + try { + return workspaceComponent.getWorkspaceMetadata(workspaceName); + } catch (WorkspaceComponentException e) { + return null; + } + } + + private Long parseWorkspaceId(String workspaceId) { + try { + return Long.parseLong(workspaceId); + } catch (NumberFormatException e) { + return null; + } + } + + private String buildWorkspaceSummaryUrl(long workspaceId, String branch, String version) { + StringBuilder url = new StringBuilder("/workspace/").append(workspaceId); + boolean hasQuery = false; + + if (branch != null && !branch.isEmpty()) { + url.append("?branch=").append(branch); + hasQuery = true; + } + + if (version != null && !version.isEmpty()) { + url.append(hasQuery ? "&" : "?").append("version=").append(version); + } + + return url.toString(); + } + } \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java index 80747a66..c195b67a 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java @@ -331,6 +331,70 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { assertNull(workspaceComponent.getWorkspaceMetadata(1)); } + @Test + void getWorkspaceMetaDataByName_WhenTheWorkspaceExists() { + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(1); + workspaceMetadata.setName("Dewey"); + + workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { + @Override + public List getWorkspaceIds() { + return List.of(1L); + } + + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return workspaceMetadata; + } + }); + + assertSame(workspaceMetadata, workspaceComponent.getWorkspaceMetadata("dewey")); + } + + @Test + void getWorkspaceMetaDataByName_WhenTheWorkspaceIsArchived() { + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(1); + workspaceMetadata.setName("Dewey"); + workspaceMetadata.setArchived(true); + + workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { + @Override + public List getWorkspaceIds() { + return List.of(1L); + } + + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return workspaceMetadata; + } + }); + + assertNull(workspaceComponent.getWorkspaceMetadata("dewey")); + } + + @Test + void getWorkspaceMetaDataByName_ThrowsAnException_WhenMultipleWorkspacesMatch() { + WorkspaceMetadata workspaceMetadata1 = new WorkspaceMetadata(1); + workspaceMetadata1.setName("Dewey"); + + WorkspaceMetadata workspaceMetadata2 = new WorkspaceMetadata(2); + workspaceMetadata2.setName("dewey"); + + workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { + @Override + public List getWorkspaceIds() { + return List.of(1L, 2L); + } + + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return workspaceId == 1L ? workspaceMetadata1 : workspaceMetadata2; + } + }); + + assertThrows(WorkspaceComponentException.class, () -> workspaceComponent.getWorkspaceMetadata("dewey")); + } + @Test void putWorkspaceMetadata_ThrowsAnException_WhenPassedNull() { workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter()); diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java b/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java index e4ff8d06..3882c828 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java @@ -28,6 +28,11 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { return null; } + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + return null; + } + @Override public void putWorkspaceMetadata(WorkspaceMetadata workspaceMetaData) { diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java index 8989f9f3..e5c43fca 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java @@ -220,4 +220,37 @@ public long getLastModifiedDate() { assertEquals(false, model.get("publishImages")); } + @Test + void redirectToAuthenticatedDiagramViewerByName_RedirectsToCanonicalWorkspaceUrl() { + configureAsServerWithAuthenticationDisabled(); + + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); + workspaceMetadata.setName("dewey"); + + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + return workspaceMetadata; + } + }); + + String view = controller.redirectToAuthenticatedDiagramViewerByName("dewey", "main", "5", model); + assertEquals("redirect:/workspace/2/diagrams?branch=main&version=5", view); + } + + @Test + void redirectToAuthenticatedDiagramViewerByName_Returns404WhenNamedWorkspaceLookupFails() { + configureAsServerWithAuthenticationDisabled(); + + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + throw new WorkspaceComponentException("Duplicate workspace name"); + } + }); + + String view = controller.redirectToAuthenticatedDiagramViewerByName("dewey", "", "", model); + assertEquals("404", view); + } + } \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java index f51d4696..0408162f 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java @@ -55,7 +55,7 @@ public List getWorkspaceVersions(long workspaceId, String bran } }); - String view = controller.showAuthenticatedWorkspaceSummary(1, "", "", model); + String view = controller.showAuthenticatedWorkspaceSummary("1", "", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -96,7 +96,7 @@ public List getWorkspaceBranches(long workspaceId) { } }); - String view = controller.showAuthenticatedWorkspaceSummary(1, "branch1", "", model); + String view = controller.showAuthenticatedWorkspaceSummary("1", "branch1", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -138,7 +138,7 @@ public long getLastModifiedDate() { } }); - String view = controller.showAuthenticatedWorkspaceSummary(1, "", "", model); + String view = controller.showAuthenticatedWorkspaceSummary("1", "", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -149,4 +149,55 @@ public long getLastModifiedDate() { assertEquals(1234567890L, model.getAttribute("autoRefreshLastModifiedDate")); } + @Test + void redirectToWorkspaceSummaryByName_RedirectsToCanonicalWorkspaceUrl() { + configureAsServerWithAuthenticationDisabled(); + + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); + workspaceMetadata.setName("dewey"); + + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + return workspaceMetadata; + } + }); + + String view = controller.redirectToWorkspaceSummaryByName("dewey", "main", "5", model); + assertEquals("redirect:/workspace/2?branch=main&version=5", view); + } + + @Test + void showAuthenticatedWorkspaceSummary_RedirectsNamedWorkspacePathToCanonicalWorkspaceUrl() { + configureAsServerWithAuthenticationDisabled(); + + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); + workspaceMetadata.setName("dewey"); + + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + return workspaceMetadata; + } + }); + + String view = controller.showAuthenticatedWorkspaceSummary("dewey", "main", "5", model); + assertEquals("redirect:/workspace/2?branch=main&version=5", view); + } + + @Test + void showAuthenticatedWorkspaceSummary_Returns404WhenNamedWorkspaceLookupFails() { + configureAsServerWithAuthenticationDisabled(); + + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + throw new WorkspaceComponentException("Duplicate workspace name"); + } + }); + + String view = controller.showAuthenticatedWorkspaceSummary("dewey", "", "", model); + assertEquals("404", view); + } + } \ No newline at end of file From e4eb564929c17634695c193e0c06eed10ec59fef Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Tue, 21 Jul 2026 10:10:04 -0700 Subject: [PATCH 3/8] named workspace routing --- META-INF/MANIFEST.MF | 13 +++++++ .../web/api/NoOpAdminApiController.java | 6 +-- .../web/api/ServerWorkspaceApiController.java | 2 +- .../WorkspaceSummaryController.java | 26 ++----------- .../DiagramViewerControllerTests.java | 22 +++++++++++ .../WorkspaceSummaryControllerTests.java | 39 ++----------------- .../main/java/com/structurizr/util/Url.java | 9 +++++ .../com/structurizr/model/ModelItemTests.java | 14 +++++++ .../java/com/structurizr/util/UrlTests.java | 11 ++++++ .../structurizr/dsl/ModelItemParserTests.java | 9 +++++ 10 files changed, 89 insertions(+), 62 deletions(-) create mode 100644 META-INF/MANIFEST.MF diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 00000000..77ec1900 --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,13 @@ +Manifest-Version: 1.0 +Created-By: Maven WAR Plugin 3.4.0 +Build-Jdk-Spec: 25 +Implementation-Title: structurizr +Implementation-Version: 1.0.0 +Main-Class: org.springframework.boot.loader.launch.WarLauncher +Start-Class: com.structurizr.Application +Spring-Boot-Version: 3.5.13 +Spring-Boot-Classes: WEB-INF/classes/ +Spring-Boot-Lib: WEB-INF/lib/ +Spring-Boot-Classpath-Index: WEB-INF/classpath.idx +Spring-Boot-Layers-Index: WEB-INF/layers.idx + diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java b/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java index c4145aba..ceded352 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/api/NoOpAdminApiController.java @@ -26,7 +26,7 @@ private String resolveApiCredential(String xAuthorization, String authorization) @RequestMapping(value = "/api/workspace", method = RequestMethod.GET, produces = "application/json; charset=UTF-8") public void getWorkspaces( @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, - @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization + @RequestHeader(name = org.springframework.http.HttpHeaders.AUTHORIZATION, required = false) String authorization ) { String apiKey = resolveApiCredential(xAuthorization, authorization); @@ -37,7 +37,7 @@ public void getWorkspaces( @RequestMapping(value = "/api/workspace", method = RequestMethod.POST, produces = "application/json; charset=UTF-8") public void createWorkspace( @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, - @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization + @RequestHeader(name = org.springframework.http.HttpHeaders.AUTHORIZATION, required = false) String authorization ) { String apiKey = resolveApiCredential(xAuthorization, authorization); @@ -48,7 +48,7 @@ public void createWorkspace( @RequestMapping(value = "/api/workspace/{workspaceId}", method = RequestMethod.DELETE, produces = "application/json; charset=UTF-8") public void deleteWorkspace( @RequestHeader(name = HttpHeaders.X_AUTHORIZATION, required = false) String xAuthorization, - @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestHeader(name = org.springframework.http.HttpHeaders.AUTHORIZATION, required = false) String authorization, @PathVariable("workspaceId") long workspaceId ) { diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java b/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java index d6ef03db..862d33bf 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/api/ServerWorkspaceApiController.java @@ -56,7 +56,7 @@ private String resolveApiCredential(String apiKey) { return apiKey; } - return request.getHeader(HttpHeaders.AUTHORIZATION); + return request.getHeader(org.springframework.http.HttpHeaders.AUTHORIZATION); } @CrossOrigin diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java index 6b879ac3..41969984 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java @@ -33,36 +33,26 @@ public String redirectToWorkspaceSummaryByName( @RequestMapping(value = "/workspace/{workspaceId}", method = RequestMethod.GET) public String showAuthenticatedWorkspaceSummary( - @PathVariable("workspaceId") String workspaceId, + @PathVariable("workspaceId") long workspaceId, @RequestParam(required = false, defaultValue = "") String branch, @RequestParam(required = false) String version, ModelMap model ) { - Long numericWorkspaceId = parseWorkspaceId(workspaceId); - if (numericWorkspaceId == null) { - WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceId); - if (workspaceMetadata == null) { - return show404Page(model); - } - - return "redirect:" + buildWorkspaceSummaryUrl(workspaceMetadata.getId(), branch, version); - } - if (Configuration.getInstance().getProfile() == com.structurizr.configuration.Profile.Local) { enableLocalRefresh(model); } return showAuthenticatedView( - Views.WORKSPACE_SUMMARY, numericWorkspaceId, + Views.WORKSPACE_SUMMARY, workspaceId, workspaceMetadata -> { if (Configuration.getInstance().getProfile() == Profile.Server) { if (Configuration.getInstance().isFeatureEnabled(Features.WORKSPACE_BRANCHES)) { model.addAttribute("branchesEnabled", true); model.addAttribute("branch", branch); - model.addAttribute("branches", workspaceComponent.getWorkspaceBranches(numericWorkspaceId)); + model.addAttribute("branches", workspaceComponent.getWorkspaceBranches(workspaceId)); } - model.addAttribute("versions", workspaceComponent.getWorkspaceVersions(numericWorkspaceId, branch)); + model.addAttribute("versions", workspaceComponent.getWorkspaceVersions(workspaceId, branch)); } }, branch, version, model, true, true @@ -77,14 +67,6 @@ private WorkspaceMetadata resolveWorkspaceByName(String workspaceName) { } } - private Long parseWorkspaceId(String workspaceId) { - try { - return Long.parseLong(workspaceId); - } catch (NumberFormatException e) { - return null; - } - } - private String buildWorkspaceSummaryUrl(long workspaceId, String branch, String version) { StringBuilder url = new StringBuilder("/workspace/").append(workspaceId); boolean hasQuery = false; diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java index e5c43fca..3691c72b 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java @@ -253,4 +253,26 @@ public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { assertEquals("404", view); } + @Test + void showAuthenticatedDiagramViewer_NumericWorkspaceUrlsRemainSupported() { + configureAsServerWithAuthenticationDisabled(); + + final WorkspaceMetadata workspaceMetaData = new WorkspaceMetadata(1); + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return workspaceMetaData; + } + + @Override + public String getWorkspace(long workspaceId, String branch, String version) throws WorkspaceComponentException { + return "json"; + } + }); + + String view = controller.showAuthenticatedDiagramViewer(1, "", null, model); + assertEquals("diagrams", view); + assertEquals("/workspace/1", model.getAttribute("urlPrefix")); + } + } \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java index 0408162f..715e823b 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java @@ -55,7 +55,7 @@ public List getWorkspaceVersions(long workspaceId, String bran } }); - String view = controller.showAuthenticatedWorkspaceSummary("1", "", "", model); + String view = controller.showAuthenticatedWorkspaceSummary(1, "", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -96,7 +96,7 @@ public List getWorkspaceBranches(long workspaceId) { } }); - String view = controller.showAuthenticatedWorkspaceSummary("1", "branch1", "", model); + String view = controller.showAuthenticatedWorkspaceSummary(1, "branch1", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -138,7 +138,7 @@ public long getLastModifiedDate() { } }); - String view = controller.showAuthenticatedWorkspaceSummary("1", "", "", model); + String view = controller.showAuthenticatedWorkspaceSummary(1, "", "", model); assertEquals("workspace-summary", view); assertSame(workspaceMetaData, model.getAttribute("workspace")); assertNull(model.getAttribute("workspaceAsJson")); @@ -167,37 +167,4 @@ public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { assertEquals("redirect:/workspace/2?branch=main&version=5", view); } - @Test - void showAuthenticatedWorkspaceSummary_RedirectsNamedWorkspacePathToCanonicalWorkspaceUrl() { - configureAsServerWithAuthenticationDisabled(); - - WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); - workspaceMetadata.setName("dewey"); - - controller.setWorkspaceComponent(new MockWorkspaceComponent() { - @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { - return workspaceMetadata; - } - }); - - String view = controller.showAuthenticatedWorkspaceSummary("dewey", "main", "5", model); - assertEquals("redirect:/workspace/2?branch=main&version=5", view); - } - - @Test - void showAuthenticatedWorkspaceSummary_Returns404WhenNamedWorkspaceLookupFails() { - configureAsServerWithAuthenticationDisabled(); - - controller.setWorkspaceComponent(new MockWorkspaceComponent() { - @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { - throw new WorkspaceComponentException("Duplicate workspace name"); - } - }); - - String view = controller.showAuthenticatedWorkspaceSummary("dewey", "", "", model); - assertEquals("404", view); - } - } \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/util/Url.java b/structurizr-core/src/main/java/com/structurizr/util/Url.java index b817e2a0..3282abf7 100644 --- a/structurizr-core/src/main/java/com/structurizr/util/Url.java +++ b/structurizr-core/src/main/java/com/structurizr/util/Url.java @@ -11,6 +11,7 @@ public class Url { private static final String HTTPS_PROTOCOL = "https://"; private static final String HTTP_PROTOCOL = "http://"; + private static final String ROOT_RELATIVE_PREFIX = "/"; public static final String INTRA_WORKSPACE_URL_PREFIX = "{workspace}"; public static final String INTER_WORKSPACE_URL_REGEX = "\\{workspace:\\d+\\}.*"; @@ -23,6 +24,10 @@ public class Url { */ public static boolean isUrl(String urlAsString) { if (!StringUtils.isNullOrEmpty(urlAsString)) { + if (isRootRelativeUrl(urlAsString)) { + return true; + } + try { URI.create(urlAsString).toURL(); return true; @@ -34,6 +39,10 @@ public static boolean isUrl(String urlAsString) { return false; } + private static boolean isRootRelativeUrl(String urlAsString) { + return urlAsString.startsWith(ROOT_RELATIVE_PREFIX) && !urlAsString.startsWith("//"); + } + /** * Determines whether the supplied string is a valid HTTPS URL. * diff --git a/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java b/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java index bf7eff3f..5f4e0ec0 100644 --- a/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java @@ -253,6 +253,20 @@ void setUrl_AcceptsAnIntraWorkspaceUrl() { assertEquals("{workspace}/diagrams#key", element.getUrl()); } + @Test + void setUrl_AcceptsARootRelativeUrl() { + Element element = model.addSoftwareSystem("Name"); + element.setUrl("/workspace/dewey"); + assertEquals("/workspace/dewey", element.getUrl()); + } + + @Test + void setUrl_AcceptsARootRelativeUrlWithQueryString() { + Element element = model.addSoftwareSystem("Name"); + element.setUrl("/workspace/diagrams?name=dewey"); + assertEquals("/workspace/diagrams?name=dewey", element.getUrl()); + } + @Test void setUrl_AcceptsAnInterWorkspaceUrl() { Element element = model.addSoftwareSystem("Name"); diff --git a/structurizr-core/src/test/java/com/structurizr/util/UrlTests.java b/structurizr-core/src/test/java/com/structurizr/util/UrlTests.java index c9e17909..d8a4227c 100644 --- a/structurizr-core/src/test/java/com/structurizr/util/UrlTests.java +++ b/structurizr-core/src/test/java/com/structurizr/util/UrlTests.java @@ -28,4 +28,15 @@ void isUrl_ReturnsTrue_WhenPassedAValidUrl() { assertTrue(Url.isUrl("https://www.google.com")); } + @Test + void isUrl_ReturnsTrue_WhenPassedARootRelativeUrl() { + assertTrue(Url.isUrl("/workspace/dewey")); + assertTrue(Url.isUrl("/workspace/diagrams?name=dewey")); + } + + @Test + void isUrl_ReturnsFalse_WhenPassedAProtocolRelativeUrl() { + assertFalse(Url.isUrl("//example.com/workspace/dewey")); + } + } diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java index fb109178..3a43eed4 100644 --- a/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java @@ -104,4 +104,13 @@ void test_parseUrl_SetsTheUrl_WhenAUrlIsSpecified() { assertEquals("http://example.com", softwareSystem.getUrl()); } + @Test + void test_parseUrl_SetsTheUrl_WhenARootRelativeUrlIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ModelItemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseUrl(context, tokens("url", "/workspace/dewey")); + + assertEquals("/workspace/dewey", softwareSystem.getUrl()); + } + } \ No newline at end of file From eab79952c21e0e3ef32fea5c4e1f70715b4aa521 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Wed, 29 Jul 2026 15:54:09 -0700 Subject: [PATCH 4/8] Load workspaces by key instead of workspace ID. --- README.md | 6 +- .../structurizr/server/AbstractServer.java | 10 + .../java/com/structurizr/server/Server.java | 16 +- .../LocalFileSystemWorkspaceAdapter.java | 3 + .../workspace/WorkspaceComponent.java | 2 +- .../workspace/WorkspaceComponentImpl.java | 16 +- .../server/domain/WorkspaceMetadata.java | 17 + .../web/WorkspaceNameRedirectFilter.java | 111 ++ .../DiagramViewerController.java | 41 - .../WorkspaceSummaryController.java | 41 - ...FileSystemSingleWorkspaceAdapterTests.java | 40 +- .../WorkspaceComponentImplTests.java | 41 +- .../server/domain/WorkspaceMetadataTests.java | 12 + .../server/web/MockHttpServletRequest.java | 23 +- .../server/web/MockHttpServletResponse.java | 8 +- .../server/web/MockWorkspaceComponent.java | 2 +- .../web/WorkspaceNameRedirectFilterTests.java | 123 ++ .../DiagramViewerControllerTests.java | 33 - .../WorkspaceSummaryControllerTests.java | 18 - tyler-debug/startup-logs.txt | 1406 +++++++++++++++++ 20 files changed, 1807 insertions(+), 162 deletions(-) create mode 100644 structurizr-application/src/main/java/com/structurizr/server/web/WorkspaceNameRedirectFilter.java create mode 100644 structurizr-application/src/test/java/com/structurizr/server/web/WorkspaceNameRedirectFilterTests.java create mode 100644 tyler-debug/startup-logs.txt diff --git a/README.md b/README.md index b338885e..33f9b539 100644 --- a/README.md +++ b/README.md @@ -51,4 +51,8 @@ When configured, API uploads can pass a client-credentials OAuth access token vi ```bash java -jar structurizr-1.0.0.war push -url https://structurizr-app.example.com/api -id 2 -workspace ./workspace.json -key "$ACCESS_TOKEN" -merge false -archive true -``` \ No newline at end of file +``` + +## Build + +`.\mvnw.cmd -pl structurizr-application -Pexclude-playwright -DskipTests package` \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/AbstractServer.java b/structurizr-application/src/main/java/com/structurizr/server/AbstractServer.java index 00dacd2f..94edb4d6 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/AbstractServer.java +++ b/structurizr-application/src/main/java/com/structurizr/server/AbstractServer.java @@ -55,6 +55,16 @@ public FilterRegistrationBean resourceUrlEncodingFilterRegistr return registrationBean; } + @Bean + public FilterRegistrationBean workspaceNameRedirectFilterRegistration(com.structurizr.server.web.WorkspaceNameRedirectFilter filter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(filter); + registrationBean.addUrlPatterns("/workspace", "/workspace/*"); + registrationBean.setOrder(1); + + return registrationBean; + } + @Bean public ConfigurableServletWebServerFactory configurableServletWebServerFactory ( ) { return new TomcatServletWebServerFactory() { diff --git a/structurizr-application/src/main/java/com/structurizr/server/Server.java b/structurizr-application/src/main/java/com/structurizr/server/Server.java index 2b6d8979..af722a4f 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/Server.java +++ b/structurizr-application/src/main/java/com/structurizr/server/Server.java @@ -3,6 +3,8 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.StructurizrProperties; import com.structurizr.view.ThemeUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; import org.springframework.context.ApplicationListener; @@ -16,6 +18,8 @@ public class Server extends AbstractServer { + private static final Log log = LogFactory.getLog(Server.class); + public static void main(String[] args) { Properties properties = new Properties(); @@ -29,7 +33,17 @@ public static void main(String[] args) { List profiles = new ArrayList<>(); profiles.add("command-server"); - profiles.add("authentication-" + Configuration.getInstance().getProperty(AUTHENTICATION_IMPLEMENTATION)); + + String authImpl = Configuration.getInstance().getProperty(AUTHENTICATION_IMPLEMENTATION); + if (AUTHENTICATION_VARIANT_OIDC.equalsIgnoreCase(authImpl)) { + String issuerUri = Configuration.getInstance().getProperty(AUTHENTICATION_OIDC_ISSUER_URI); + String clientId = Configuration.getInstance().getProperty(AUTHENTICATION_OIDC_CLIENT_ID); + if (issuerUri == null || issuerUri.isBlank() || clientId == null || clientId.isBlank()) { + log.warn("structurizr.authentication=oidc but required OIDC properties (issuerUri, clientId) are missing; falling back to authentication=none"); + authImpl = AUTHENTICATION_VARIANT_NONE; + } + } + profiles.add("authentication-" + authImpl); profiles.add("session-" + Configuration.getInstance().getProperty(StructurizrProperties.SESSION_IMPLEMENTATION)); try { diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/LocalFileSystemWorkspaceAdapter.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/LocalFileSystemWorkspaceAdapter.java index fb597325..7c0a6b9f 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/LocalFileSystemWorkspaceAdapter.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/LocalFileSystemWorkspaceAdapter.java @@ -190,6 +190,9 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { if (workspace != null) { wmd.setName(workspace.getName()); wmd.setDescription(workspace.getDescription()); + if (workspace.getProperties() != null) { + wmd.setRoutingKey(workspace.getProperties().get("key")); + } } } catch (Exception e) { log.error(e); diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java index 9590f9ab..fcd85204 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponent.java @@ -19,7 +19,7 @@ public interface WorkspaceComponent { WorkspaceMetadata getWorkspaceMetadata(long workspaceId) throws WorkspaceComponentException; - WorkspaceMetadata getWorkspaceMetadata(String workspaceName) throws WorkspaceComponentException; + WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) throws WorkspaceComponentException; void putWorkspaceMetadata(WorkspaceMetadata workspaceMetadata); diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java index 1183fe22..0ac24653 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java @@ -40,6 +40,7 @@ class WorkspaceComponentImpl implements WorkspaceComponent { private static final Log log = LogFactory.getLog(WorkspaceComponent.class); private static final String ENCRYPTION_STRATEGY_STRING = "encryptionStrategy"; private static final String CIPHERTEXT_STRING = "ciphertext"; + private static final String ROUTING_KEY_PROPERTY = "key"; private final WorkspaceAdapter workspaceAdapter; private final String encryptionPassphrase; @@ -191,23 +192,23 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) throws Workspace } @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) throws WorkspaceComponentException { - if (StringUtils.isNullOrEmpty(workspaceName)) { - throw new IllegalArgumentException("Workspace name cannot be null or empty"); + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) throws WorkspaceComponentException { + if (StringUtils.isNullOrEmpty(routingKey)) { + throw new IllegalArgumentException("Routing key cannot be null or empty"); } - String normalizedWorkspaceName = workspaceName.trim(); + String normalizedRoutingKey = routingKey.trim(); WorkspaceMetadata match = null; for (Long workspaceId : workspaceAdapter.getWorkspaceIds()) { WorkspaceMetadata workspaceMetadata = getWorkspaceMetadata(workspaceId); - if (workspaceMetadata == null) { + if (workspaceMetadata == null || StringUtils.isNullOrEmpty(workspaceMetadata.getRoutingKey())) { continue; } - if (normalizedWorkspaceName.equalsIgnoreCase(workspaceMetadata.getName())) { + if (normalizedRoutingKey.equalsIgnoreCase(workspaceMetadata.getRoutingKey().trim())) { if (match != null) { - throw new WorkspaceComponentException("Multiple workspaces found with name: " + workspaceName); + throw new WorkspaceComponentException("Multiple workspaces found with routing key: " + routingKey); } match = workspaceMetadata; @@ -424,6 +425,7 @@ public void putWorkspace(long workspaceId, String branch, String json) { try { workspaceMetadata.setName(workspaceToBeStored.getName()); workspaceMetadata.setDescription(workspaceToBeStored.getDescription()); + workspaceMetadata.setRoutingKey(workspaceToBeStored.getProperties().get(ROUTING_KEY_PROPERTY)); // configure workspace visibility and users if (configuration != null) { diff --git a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java index 46b06ca8..f3534764 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java +++ b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java @@ -38,10 +38,12 @@ public class WorkspaceMetadata { static final String READ_USERS_AND_ROLES_PROPERTY = "readUsers"; static final String WRITE_USERS_AND_ROLES_PROPERTY = "writeUsers"; static final String ARCHIVED_PROPERTY = "archived"; + static final String ROUTING_KEY_PROPERTY = "routingKey"; private final long id; private String name = ""; private String description = ""; + private String routingKey; private String version; private long size; private boolean clientSideEncrypted = false; @@ -92,6 +94,14 @@ public void setDescription(String description) { this.description = description; } + public String getRoutingKey() { + return routingKey; + } + + public void setRoutingKey(String routingKey) { + this.routingKey = routingKey; + } + public String getVersion() { return version; } @@ -466,6 +476,7 @@ public static WorkspaceMetadata fromProperties(long workspaceId, Properties prop WorkspaceMetadata workspace = new WorkspaceMetadata(workspaceId); workspace.setName(properties.getProperty(NAME_PROPERTY)); workspace.setDescription(properties.getProperty(DESCRIPTION_PROPERTY)); + workspace.setRoutingKey(properties.getProperty(ROUTING_KEY_PROPERTY)); workspace.setVersion(properties.getProperty(VERSION_PROPERTY)); workspace.setClientSideEncrypted("true".equals(properties.getProperty(CLIENT_SIDE_ENCRYPTED_PROPERTY))); workspace.setLastModifiedUser(properties.getProperty(LAST_MODIFIED_USER_PROPERTY)); @@ -528,6 +539,12 @@ public Properties toProperties() { properties.setProperty(DESCRIPTION_PROPERTY, ""); } + if (this.getRoutingKey() != null) { + properties.setProperty(ROUTING_KEY_PROPERTY, this.getRoutingKey()); + } else { + properties.setProperty(ROUTING_KEY_PROPERTY, ""); + } + if (this.getVersion() != null) { properties.setProperty(VERSION_PROPERTY, this.getVersion()); } diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/WorkspaceNameRedirectFilter.java b/structurizr-application/src/main/java/com/structurizr/server/web/WorkspaceNameRedirectFilter.java new file mode 100644 index 00000000..f8499bb8 --- /dev/null +++ b/structurizr-application/src/main/java/com/structurizr/server/web/WorkspaceNameRedirectFilter.java @@ -0,0 +1,111 @@ +package com.structurizr.server.web; + +import com.structurizr.server.component.workspace.WorkspaceComponent; +import com.structurizr.server.component.workspace.WorkspaceComponentException; +import com.structurizr.server.domain.WorkspaceMetadata; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class WorkspaceNameRedirectFilter extends OncePerRequestFilter { + + private static final String WORKSPACE_PREFIX = "/workspace"; + + private final WorkspaceComponent workspaceComponent; + + public WorkspaceNameRedirectFilter(WorkspaceComponent workspaceComponent) { + this.workspaceComponent = workspaceComponent; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (!"GET".equalsIgnoreCase(request.getMethod())) { + filterChain.doFilter(request, response); + return; + } + + String routingKey = request.getParameter("key"); + if (routingKey == null || routingKey.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + String requestUri = request.getRequestURI(); + if (requestUri == null || !requestUri.startsWith(WORKSPACE_PREFIX) || isCanonicalNumericWorkspaceUrl(requestUri)) { + filterChain.doFilter(request, response); + return; + } + + WorkspaceMetadata workspaceMetadata = resolveWorkspaceByRoutingKey(routingKey); + if (workspaceMetadata == null) { + response.sendRedirect("/"); + return; + } + + response.sendRedirect(buildRedirectUrl(requestUri, request, workspaceMetadata.getId(), routingKey)); + } + + private WorkspaceMetadata resolveWorkspaceByRoutingKey(String routingKey) { + try { + return workspaceComponent.getWorkspaceMetadataByRoutingKey(routingKey); + } catch (WorkspaceComponentException e) { + return null; + } + } + + private boolean isCanonicalNumericWorkspaceUrl(String requestUri) { + String remainder = requestUri.substring(WORKSPACE_PREFIX.length()); + if (remainder.isEmpty() || "/".equals(remainder)) { + return false; + } + + if (!remainder.startsWith("/")) { + return false; + } + + int nextSlash = remainder.indexOf('/', 1); + String firstSegment = nextSlash == -1 ? remainder.substring(1) : remainder.substring(1, nextSlash); + return !firstSegment.isEmpty() && firstSegment.chars().allMatch(Character::isDigit); + } + + private String buildRedirectUrl(String requestUri, HttpServletRequest request, long workspaceId, String routingKey) { + String suffix = requestUri.substring(WORKSPACE_PREFIX.length()); + String normalizedRoutingKey = routingKey.trim(); + + if (suffix.equals("/" + normalizedRoutingKey)) { + suffix = ""; + } else if (suffix.startsWith("/" + normalizedRoutingKey + "/")) { + suffix = suffix.substring(normalizedRoutingKey.length() + 1); + } + + StringBuilder redirectUrl = new StringBuilder(WORKSPACE_PREFIX) + .append('/') + .append(workspaceId) + .append(suffix); + + List queryParameters = new ArrayList<>(); + appendQueryParameter(queryParameters, "branch", request.getParameter("branch")); + appendQueryParameter(queryParameters, "version", request.getParameter("version")); + + if (!queryParameters.isEmpty()) { + redirectUrl.append('?').append(String.join("&", queryParameters)); + } + + return redirectUrl.toString(); + } + + private void appendQueryParameter(List queryParameters, String name, String value) { + if (value != null && !value.isBlank()) { + queryParameters.add(name + "=" + value); + } + } + +} \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java index 255546d9..05d0724b 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerController.java @@ -3,9 +3,7 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.Profile; import com.structurizr.configuration.StructurizrProperties; -import com.structurizr.server.component.workspace.WorkspaceComponentException; import com.structurizr.server.domain.Permission; -import com.structurizr.server.domain.WorkspaceMetadata; import com.structurizr.server.web.Views; import com.structurizr.util.HtmlUtils; import com.structurizr.util.StringUtils; @@ -21,21 +19,6 @@ @Controller class DiagramViewerController extends AbstractWorkspaceController { - @RequestMapping(value = "/workspace/diagrams", method = RequestMethod.GET, params = "name") - String redirectToAuthenticatedDiagramViewerByName( - @RequestParam("name") String workspaceName, - @RequestParam(required = false, defaultValue = "") String branch, - @RequestParam(required = false) String version, - ModelMap model - ) { - WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceName); - if (workspaceMetadata == null) { - return show404Page(model); - } - - return "redirect:" + buildDiagramViewerUrl(workspaceMetadata.getId(), branch, version); - } - @RequestMapping(value = "/workspace/{workspaceId}/diagrams", method = RequestMethod.GET) String showAuthenticatedDiagramViewer( @PathVariable("workspaceId") long workspaceId, @@ -70,28 +53,4 @@ String showAuthenticatedDiagramViewer( ); } - private WorkspaceMetadata resolveWorkspaceByName(String workspaceName) { - try { - return workspaceComponent.getWorkspaceMetadata(workspaceName); - } catch (WorkspaceComponentException e) { - return null; - } - } - - private String buildDiagramViewerUrl(long workspaceId, String branch, String version) { - StringBuilder url = new StringBuilder("/workspace/").append(workspaceId).append("/diagrams"); - boolean hasQuery = false; - - if (branch != null && !branch.isEmpty()) { - url.append("?branch=").append(branch); - hasQuery = true; - } - - if (version != null && !version.isEmpty()) { - url.append(hasQuery ? "&" : "?").append("version=").append(version); - } - - return url.toString(); - } - } \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java index 41969984..2d6b925c 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java +++ b/structurizr-application/src/main/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryController.java @@ -3,8 +3,6 @@ import com.structurizr.configuration.Configuration; import com.structurizr.configuration.Features; import com.structurizr.configuration.Profile; -import com.structurizr.server.component.workspace.WorkspaceComponentException; -import com.structurizr.server.domain.WorkspaceMetadata; import com.structurizr.server.web.Views; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; @@ -16,21 +14,6 @@ @Controller class WorkspaceSummaryController extends AbstractWorkspaceController { - @RequestMapping(value = "/workspace", method = RequestMethod.GET, params = "name") - public String redirectToWorkspaceSummaryByName( - @RequestParam("name") String workspaceName, - @RequestParam(required = false, defaultValue = "") String branch, - @RequestParam(required = false) String version, - ModelMap model - ) { - WorkspaceMetadata workspaceMetadata = resolveWorkspaceByName(workspaceName); - if (workspaceMetadata == null) { - return show404Page(model); - } - - return "redirect:" + buildWorkspaceSummaryUrl(workspaceMetadata.getId(), branch, version); - } - @RequestMapping(value = "/workspace/{workspaceId}", method = RequestMethod.GET) public String showAuthenticatedWorkspaceSummary( @PathVariable("workspaceId") long workspaceId, @@ -59,28 +42,4 @@ public String showAuthenticatedWorkspaceSummary( ); } - private WorkspaceMetadata resolveWorkspaceByName(String workspaceName) { - try { - return workspaceComponent.getWorkspaceMetadata(workspaceName); - } catch (WorkspaceComponentException e) { - return null; - } - } - - private String buildWorkspaceSummaryUrl(long workspaceId, String branch, String version) { - StringBuilder url = new StringBuilder("/workspace/").append(workspaceId); - boolean hasQuery = false; - - if (branch != null && !branch.isEmpty()) { - url.append("?branch=").append(branch); - hasQuery = true; - } - - if (version != null && !version.isEmpty()) { - url.append(hasQuery ? "&" : "?").append("version=").append(version); - } - - return url.toString(); - } - } \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/LocalFileSystemSingleWorkspaceAdapterTests.java b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/LocalFileSystemSingleWorkspaceAdapterTests.java index 6ca6d80a..b36b5ba3 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/LocalFileSystemSingleWorkspaceAdapterTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/LocalFileSystemSingleWorkspaceAdapterTests.java @@ -78,6 +78,18 @@ void getWorkspaceMetadata_WhenJsonFileExists() throws Exception { assertEquals("Description - JSON", wmd.getDescription()); } + @Test + void getWorkspaceMetadata_WhenJsonFileExists_ExtractsRoutingKeyFromWorkspaceProperties() throws Exception { + Workspace workspace = new Workspace("Name - JSON", "Description - JSON"); + workspace.addProperty("key", "cm-support-kb"); + WorkspaceUtils.saveWorkspaceToJson(workspace, new File(dataDirectory, "workspace.json")); + + workspaceAdapter = new LocalFileSystemSingleWorkspaceAdapter(); + + WorkspaceMetadata wmd = workspaceAdapter.getWorkspaceMetadata(1); + assertEquals("cm-support-kb", wmd.getRoutingKey()); + } + @Test void getWorkspaceMetadata_WhenJsonFileDoesNotExist() { workspaceAdapter = new LocalFileSystemSingleWorkspaceAdapter(); @@ -101,6 +113,9 @@ void getWorkspace_WhenDslFileExists() { String dsl = """ workspace "DSL" "Description" { + properties { + key "cm-support-kb" + } }"""; FileUtils.write(new File(dataDirectory, "workspace.dsl"), dsl); @@ -109,8 +124,29 @@ void getWorkspace_WhenDslFileExists() { String json = workspaceAdapter.getWorkspace(1, "", ""); assertTrue(json.startsWith(""" {"configuration":{},"description":"Description","documentation":{},"id":1,"lastModifiedDate":""")); - assertTrue(json.endsWith(""" - ,"model":{},"name":"DSL","properties":{"structurizr.inspection.error":"3","structurizr.dsl":"d29ya3NwYWNlICJEU0wiICJEZXNjcmlwdGlvbiIgewp9","structurizr.inspection.info":"0","structurizr.inspection.ignore":"0","structurizr.inspection.warning":"0"},"views":{"configuration":{"styles":{},"terminology":{}}}}""")); + assertTrue(json.contains("\"name\":\"DSL\"")); + assertTrue(json.contains("\"key\":\"cm-support-kb\"")); + assertTrue(json.contains("\"structurizr.dsl\":\"")); + } + + @Test + void getWorkspaceMetadata_WhenDslFileExists_ExtractsRoutingKeyAfterRenderingJson() { + deleteDirectory(dataDirectory); + dataDirectory.mkdirs(); + + String dsl = """ + workspace "DSL" "Description" { + properties { + key "cm-support-kb" + } + }"""; + FileUtils.write(new File(dataDirectory, "workspace.dsl"), dsl); + + workspaceAdapter = new LocalFileSystemSingleWorkspaceAdapter(); + workspaceAdapter.getWorkspace(1, "", ""); + + WorkspaceMetadata wmd = workspaceAdapter.getWorkspaceMetadata(1); + assertEquals("cm-support-kb", wmd.getRoutingKey()); } @Test diff --git a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java index c195b67a..d435c38e 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java @@ -332,9 +332,9 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { } @Test - void getWorkspaceMetaDataByName_WhenTheWorkspaceExists() { + void getWorkspaceMetaDataByRoutingKey_WhenTheWorkspaceExists() { WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(1); - workspaceMetadata.setName("Dewey"); + workspaceMetadata.setRoutingKey("Dewey"); workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { @Override @@ -348,13 +348,13 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { } }); - assertSame(workspaceMetadata, workspaceComponent.getWorkspaceMetadata("dewey")); + assertSame(workspaceMetadata, workspaceComponent.getWorkspaceMetadataByRoutingKey("dewey")); } @Test - void getWorkspaceMetaDataByName_WhenTheWorkspaceIsArchived() { + void getWorkspaceMetaDataByRoutingKey_WhenTheWorkspaceIsArchived() { WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(1); - workspaceMetadata.setName("Dewey"); + workspaceMetadata.setRoutingKey("Dewey"); workspaceMetadata.setArchived(true); workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { @@ -369,16 +369,16 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { } }); - assertNull(workspaceComponent.getWorkspaceMetadata("dewey")); + assertNull(workspaceComponent.getWorkspaceMetadataByRoutingKey("dewey")); } @Test - void getWorkspaceMetaDataByName_ThrowsAnException_WhenMultipleWorkspacesMatch() { + void getWorkspaceMetaDataByRoutingKey_ThrowsAnException_WhenMultipleWorkspacesMatch() { WorkspaceMetadata workspaceMetadata1 = new WorkspaceMetadata(1); - workspaceMetadata1.setName("Dewey"); + workspaceMetadata1.setRoutingKey("Dewey"); WorkspaceMetadata workspaceMetadata2 = new WorkspaceMetadata(2); - workspaceMetadata2.setName("dewey"); + workspaceMetadata2.setRoutingKey("dewey"); workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { @Override @@ -392,7 +392,7 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { } }); - assertThrows(WorkspaceComponentException.class, () -> workspaceComponent.getWorkspaceMetadata("dewey")); + assertThrows(WorkspaceComponentException.class, () -> workspaceComponent.getWorkspaceMetadataByRoutingKey("dewey")); } @Test @@ -714,6 +714,27 @@ public void putWorkspaceMetadata(WorkspaceMetadata workspaceMetaData) { assertFalse(wmd.isPublicWorkspace()); } + @Test + void test_putWorkspace_UpdatesTheRoutingKey_WhenKeyPropertyIsSpecified() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.addProperty("key", "dewey"); + + String json = WorkspaceUtils.toJson(workspace, false); + + final WorkspaceMetadata wmd = new WorkspaceMetadata(1); + + WorkspaceComponent workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { + @Override + public void putWorkspaceMetadata(WorkspaceMetadata workspaceMetaData) { + wmd.setRoutingKey(workspaceMetaData.getRoutingKey()); + } + }); + + workspaceComponent.putWorkspace(1, "", json); + + assertEquals("dewey", wmd.getRoutingKey()); + } + @Test void test_putWorkspace_UpdatesTheRoleBasedSecurity_WhenUsersAreDefined() throws Exception { configureAsServerWithAuthenticationEnabled(); diff --git a/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java b/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java index fa0dceb1..49aea42b 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/domain/WorkspaceMetadataTests.java @@ -51,6 +51,18 @@ void sharingToken() { assertTrue(workspace.isShareable()); } + @Test + void routingKey_RoundTripsViaProperties() { + WorkspaceMetadata workspace = new WorkspaceMetadata(1); + workspace.setApiKey(""); + workspace.setRoutingKey("dewey"); + + Properties properties = workspace.toProperties(); + WorkspaceMetadata hydratedWorkspace = WorkspaceMetadata.fromProperties(1, properties); + + assertEquals("dewey", hydratedWorkspace.getRoutingKey()); + } + @Test void addReadUser_WhenNull() { WorkspaceMetadata workspace = new WorkspaceMetadata(1); diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletRequest.java b/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletRequest.java index fc15f6ce..13fbd525 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletRequest.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletRequest.java @@ -13,6 +13,8 @@ public class MockHttpServletRequest implements HttpServletRequest { private String pathInfo; + private String method; + private String requestUri; private final Map headers = new HashMap<>(); private final Map parameters = new HashMap<>(); private StringReader stringReader; @@ -71,7 +73,11 @@ public int getIntHeader(String s) { @Override public String getMethod() { - return null; + return method; + } + + void setMethod(String method) { + this.method = method; } void setPathInfo(String pathInfo) { @@ -90,7 +96,7 @@ public String getPathTranslated() { @Override public String getContextPath() { - return "/"; + return ""; } @Override @@ -120,7 +126,11 @@ public String getRequestedSessionId() { @Override public String getRequestURI() { - return null; + return requestUri; + } + + void setRequestURI(String requestUri) { + this.requestUri = requestUri; } @Override @@ -208,12 +218,15 @@ public Enumeration getParameterNames() { @Override public String[] getParameterValues(String s) { - return new String[0]; + String value = parameters.get(s); + return value == null ? null : new String[] { value }; } @Override public Map getParameterMap() { - return null; + Map parameterMap = new HashMap<>(); + parameters.forEach((key, value) -> parameterMap.put(key, new String[] { value })); + return parameterMap; } @Override diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletResponse.java b/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletResponse.java index a6d6558e..4c401a86 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletResponse.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/MockHttpServletResponse.java @@ -18,6 +18,7 @@ public class MockHttpServletResponse implements HttpServletResponse { private final PrintWriter printWriter = new PrintWriter(stringWriter); private final List bytes = new ArrayList<>(); private String contentType; + private String redirectedUrl; String getContent() { return stringWriter.toString(); @@ -44,7 +45,7 @@ public String encodeURL(String s) { @Override public String encodeRedirectURL(String s) { - return null; + return s; } @Override @@ -59,7 +60,12 @@ public void sendError(int status) throws IOException { @Override public void sendRedirect(String s) throws IOException { + this.status = SC_FOUND; + this.redirectedUrl = s; + } + String getRedirectedUrl() { + return redirectedUrl; } @Override diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java b/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java index 3882c828..91acc27b 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/MockWorkspaceComponent.java @@ -29,7 +29,7 @@ public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { } @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) { return null; } diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/WorkspaceNameRedirectFilterTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/WorkspaceNameRedirectFilterTests.java new file mode 100644 index 00000000..f6e5a62e --- /dev/null +++ b/structurizr-application/src/test/java/com/structurizr/server/web/WorkspaceNameRedirectFilterTests.java @@ -0,0 +1,123 @@ +package com.structurizr.server.web; + +import com.structurizr.server.domain.WorkspaceMetadata; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkspaceNameRedirectFilterTests { + + private WorkspaceNameRedirectFilter filter; + + @BeforeEach + void setUp() { + filter = new WorkspaceNameRedirectFilter(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) { + if ("dewey".equalsIgnoreCase(routingKey)) { + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); + workspaceMetadata.setRoutingKey("dewey"); + return workspaceMetadata; + } + + return null; + } + }); + } + + @Test + void redirectsWorkspaceRootByRoutingKey() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/workspace"); + request.setParameter("key", "dewey"); + request.setParameter("branch", "main"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new NoOpFilterChain()); + + assertEquals(HttpServletResponse.SC_FOUND, response.getStatus()); + assertEquals("/workspace/2?branch=main", response.getRedirectedUrl()); + } + + @Test + void redirectsWorkspaceSubpathByRoutingKey() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/workspace/diagrams"); + request.setParameter("key", "dewey"); + request.setParameter("version", "5"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new NoOpFilterChain()); + + assertEquals(HttpServletResponse.SC_FOUND, response.getStatus()); + assertEquals("/workspace/2/diagrams?version=5", response.getRedirectedUrl()); + } + + @Test + void removesLeadingWorkspaceRoutingKeySegmentWhenPresent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/workspace/dewey/documentation/payments"); + request.setParameter("key", "dewey"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new NoOpFilterChain()); + + assertEquals(HttpServletResponse.SC_FOUND, response.getStatus()); + assertEquals("/workspace/2/documentation/payments", response.getRedirectedUrl()); + } + + @Test + void ignoresCanonicalNumericWorkspaceUrls() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/workspace/2/diagrams"); + request.setParameter("key", "dewey"); + MockHttpServletResponse response = new MockHttpServletResponse(); + TrackingFilterChain filterChain = new TrackingFilterChain(); + + filter.doFilter(request, response, filterChain); + + assertTrue(filterChain.called); + assertNull(response.getRedirectedUrl()); + } + + @Test + void redirectsToRootWhenWorkspaceRoutingKeyCannotBeResolved() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/workspace/diagrams"); + request.setParameter("key", "unknown"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new NoOpFilterChain()); + + assertEquals(HttpServletResponse.SC_FOUND, response.getStatus()); + assertEquals("/", response.getRedirectedUrl()); + } + + private static final class NoOpFilterChain implements FilterChain { + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response) { + } + } + + private static final class TrackingFilterChain implements FilterChain { + + private boolean called; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response) { + called = true; + } + } + +} \ No newline at end of file diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java index 3691c72b..611945d5 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/DiagramViewerControllerTests.java @@ -220,39 +220,6 @@ public long getLastModifiedDate() { assertEquals(false, model.get("publishImages")); } - @Test - void redirectToAuthenticatedDiagramViewerByName_RedirectsToCanonicalWorkspaceUrl() { - configureAsServerWithAuthenticationDisabled(); - - WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); - workspaceMetadata.setName("dewey"); - - controller.setWorkspaceComponent(new MockWorkspaceComponent() { - @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { - return workspaceMetadata; - } - }); - - String view = controller.redirectToAuthenticatedDiagramViewerByName("dewey", "main", "5", model); - assertEquals("redirect:/workspace/2/diagrams?branch=main&version=5", view); - } - - @Test - void redirectToAuthenticatedDiagramViewerByName_Returns404WhenNamedWorkspaceLookupFails() { - configureAsServerWithAuthenticationDisabled(); - - controller.setWorkspaceComponent(new MockWorkspaceComponent() { - @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { - throw new WorkspaceComponentException("Duplicate workspace name"); - } - }); - - String view = controller.redirectToAuthenticatedDiagramViewerByName("dewey", "", "", model); - assertEquals("404", view); - } - @Test void showAuthenticatedDiagramViewer_NumericWorkspaceUrlsRemainSupported() { configureAsServerWithAuthenticationDisabled(); diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java index 715e823b..f51d4696 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/web/workspace/authenticated/WorkspaceSummaryControllerTests.java @@ -149,22 +149,4 @@ public long getLastModifiedDate() { assertEquals(1234567890L, model.getAttribute("autoRefreshLastModifiedDate")); } - @Test - void redirectToWorkspaceSummaryByName_RedirectsToCanonicalWorkspaceUrl() { - configureAsServerWithAuthenticationDisabled(); - - WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(2); - workspaceMetadata.setName("dewey"); - - controller.setWorkspaceComponent(new MockWorkspaceComponent() { - @Override - public WorkspaceMetadata getWorkspaceMetadata(String workspaceName) { - return workspaceMetadata; - } - }); - - String view = controller.redirectToWorkspaceSummaryByName("dewey", "main", "5", model); - assertEquals("redirect:/workspace/2?branch=main&version=5", view); - } - } \ No newline at end of file diff --git a/tyler-debug/startup-logs.txt b/tyler-debug/startup-logs.txt new file mode 100644 index 00000000..338d1f5b --- /dev/null +++ b/tyler-debug/startup-logs.txt @@ -0,0 +1,1406 @@ +java -Xms256m -Xmx768m -jar .\docs\design\bin\structurizr-1.0.0.war local .\docs\design ` + --debug ` + --spring.main.log-startup-info=true ` + --logging.level.com.structurizr=DEBUG ` + --logging.level.org.springframework=INFO ` + --logging.level.org.redisson=INFO ` + --logging.level.io.netty=INFO +2026-07-21 10:05:09.551 INFO  com.structurizr.server.Local - ******************************************************************* +**************** +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - _____ _ _ _ +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - / ____| | | | (_) +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - | (___ | |_ _ __ _ _ ___| |_ _ _ _ __ _ _____ __ +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - \___ \| __| '__| | | |/ __| __| | | | '__| |_ / '__| +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - ____) | |_| | | |_| | (__| |_| |_| | | | |/ /| | +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - |_____/ \__|_| \__,_|\___|\__|\__,_|_| |_/___|_| +2026-07-21 10:05:09.553 INFO  com.structurizr.server.Local - +2026-07-21 10:05:09.554 INFO  com.structurizr.server.Local - Local v1.0.0 +2026-07-21 10:05:09.555 INFO  com.structurizr.server.Local - ******************************************************************* +**************** +2026-07-21 10:05:09.555 INFO  com.structurizr.server.Local - - structurizr.authentication: none +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.autorefreshinterval: 0 +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.autosaveinterval: 5000 +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.cache: none +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.data: file +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.datadirectory: C:\Users\tulrich\_work\trimble\archit +ecture\dewey\.\docs\design +2026-07-21 10:05:09.556 INFO  com.structurizr.server.Local - - structurizr.debug: false +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.editable: true +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.logging.filename: C:\Users\tulrich\_work\trimble\arc +hitecture\dewey\.\docs\design\.structurizr\logs\structurizr.log +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.network.timeout: 60000 +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.network.urls.allowed: .* +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.search: lucene +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.themes: C:\Users\tulrich\_work\trimble\architecture\ +dewey\.\docs\design\themes\ +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.workspace.maxsize: 1MB +2026-07-21 10:05:09.557 INFO  com.structurizr.server.Local - - structurizr.workspace.threads: 10 +2026-07-21 10:05:09.558 INFO  com.structurizr.server.Local - - structurizr.workspaces: * +2026-07-21 10:05:09.558 INFO  com.structurizr.server.Local - ******************************************************************* +**************** +2026-07-21 10:05:09.558 INFO  com.structurizr.server.Local - Themes: +2026-07-21 10:05:09.559 INFO  com.structurizr.server.Local - ******************************************************************* +**************** +2026-07-21 10:05:09.699 INFO  com.structurizr.server.Local - Starting Local v1.0.0 using Java 25.0.2 with PID 40756 (C:\Users\tu +lrich\_work\trimble\architecture\dewey\docs\design\bin\structurizr-1.0.0.war started by tulrich in C:\Users\tulrich\_work\trimble\architecture\dewey) +2026-07-21 10:05:09.700 DEBUG com.structurizr.server.Local - Running with Spring Boot v3.5.13, Spring v6.2.17 +2026-07-21 10:05:09.701 INFO  com.structurizr.server.Local - The following 1 profile is active: "command-local" +2026-07-21 10:05:09.702 DEBUG org.springframework.boot.SpringApplication - Loading source class com.structurizr.server.Local +2026-07-21 10:05:09.957 DEBUG org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext - +Refreshing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@a43ce46 +2026-07-21 10:05:11.175 INFO  org.springframework.data.repository.config.RepositoryConfigurationDelegate - Multiple Spring Data +modules found, entering strict repository configuration mode +2026-07-21 10:05:11.180 INFO  org.springframework.data.repository.config.RepositoryConfigurationDelegate - Bootstrapping Spring +Data Redis repositories in DEFAULT mode. +2026-07-21 10:05:11.222 DEBUG org.springframework.boot.autoconfigure.AutoConfigurationPackages - @EnableAutoConfiguration was de +clared on a class in the package 'com.structurizr.server'. Automatic @Repository and @Entity scanning is enabled. +2026-07-21 10:05:11.249 INFO  org.springframework.data.repository.config.RepositoryConfigurationDelegate - Finished Spring Data +repository scanning in 45 ms. Found 0 Redis repository interfaces. +2026-07-21 10:05:11.974 DEBUG com.structurizr.server.AbstractServer$1 - Code archive: C:\Users\tulrich\_work\trimble\architectur +e\dewey\docs\design\bin\structurizr-1.0.0.war +2026-07-21 10:05:11.975 DEBUG com.structurizr.server.AbstractServer$1 - Document root: C:\Users\tulrich\_work\trimble\architectu +re\dewey\docs\design\bin\structurizr-1.0.0.war +2026-07-21 10:05:12.023 INFO  org.springframework.boot.web.embedded.tomcat.TomcatWebServer - Tomcat initialized with port 8080 ( +http) +2026-07-21 10:05:35.218 DEBUG org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext - Published root W +ebApplicationContext as ServletContext attribute with name [org.springframework.web.context.WebApplicationContext.ROOT] +2026-07-21 10:05:35.219 INFO  org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext - Root WebApplicat +ionContext: initialization completed in 25262 ms +2026-07-21 10:05:35.254 DEBUG org.springframework.boot.web.servlet.ServletContextInitializerBeans - Mapping filters: springSecur +ityFilterChain urls=[/*] order=-100, localFilterRegistration urls=[/*] order=2147483647, characterEncodingFilterRegistration urls=[/*] order=2147483647, + resourceUrlEncodingFilterRegistration urls=[/*] order=2147483647, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] orde +r=-9900, requestContextFilter urls=[/*] order=-105 +2026-07-21 10:05:35.254 DEBUG org.springframework.boot.web.servlet.ServletContextInitializerBeans - Mapping servlets: dispatcher +Servlet urls=[/] +2026-07-21 10:05:35.292 DEBUG org.springframework.boot.web.servlet.filter.OrderedRequestContextFilter - Filter 'requestContextFi +lter' configured for use +2026-07-21 10:05:35.293 DEBUG org.springframework.boot.web.servlet.filter.OrderedCharacterEncodingFilter - Filter 'characterEnco +dingFilter' configured for use +2026-07-21 10:05:35.294 DEBUG org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1 - Filter 'springSecur +ityFilterChain' configured for use +2026-07-21 10:05:35.294 DEBUG org.springframework.boot.web.servlet.filter.OrderedFormContentFilter - Filter 'formContentFilter' +configured for use +2026-07-21 10:05:35.294 DEBUG org.springframework.web.filter.CharacterEncodingFilter - Filter 'characterEncodingFilterRegistrati +on' configured for use +2026-07-21 10:05:35.294 DEBUG org.springframework.web.servlet.resource.ResourceUrlEncodingFilter - Filter 'resourceUrlEncodingFi +lterRegistration' configured for use +2026-07-21 10:05:35.995 DEBUG com.structurizr.server.web.search.SearchIndexer - Rebuilding search index... +2026-07-21 10:05:36.256 DEBUG com.structurizr.server.web.search.SearchIndexer - Indexing workspace with ID 1 +2026-07-21 10:05:36.256 DEBUG com.structurizr.server.web.search.SearchIndexer - Indexing workspace with ID 2 +2026-07-21 10:05:36.817 DEBUG org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - 40 mappings i +n 'requestMappingHandlerMapping' +2026-07-21 10:05:37.010 DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Patterns [/webjars/**, /**, /sta +tic/js/structurizr*, /static/css/structurizr*, /static/js/*, /static/css/*, /static/themes/**] in 'resourceHandlerMapping' +2026-07-21 10:05:37.521 DEBUG org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter - ControllerAdv +ice beans: 0 @ModelAttribute, 0 @InitBinder, 1 RequestBodyAdvice, 1 ResponseBodyAdvice +2026-07-21 10:05:37.684 DEBUG org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver - Controll +erAdvice beans: 0 @ExceptionHandler, 1 ResponseBodyAdvice +2026-07-21 10:05:38.840 INFO  org.springframework.ldap.core.support.AbstractContextSource - Property 'userDn' not set - anonymou +s context will be used for read-only operations +java : WARNING: A terminally deprecated method in sun.misc.Unsafe has been called +At line:1 char:1 ++ java -Xms256m -Xmx768m -jar .\docs\design\bin\structurizr-1.0.0.war l ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (WARNING: A term...has been called:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +WARNING: sun.misc.Unsafe::allocateMemory has been called by io.netty.util.internal.PlatformDependent0$2 +(jar:nested:/C:/Users/tulrich/_work/trimble/architecture/dewey/docs/design/bin/structurizr-1.0.0.war/!WEB-INF/lib/netty-common-4.1.132.Final.jar!/) +WARNING: Please consider reporting this to the maintainers of class io.netty.util.internal.PlatformDependent0$2 +WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release +2026-07-21 10:05:39.131 INFO  org.springframework.boot.web.embedded.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) +with context path '/' +2026-07-21 10:05:39.158 DEBUG org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLogger - + + +============================ +CONDITIONS EVALUATION REPORT +============================ + + +Positive matches: +----------------- + + AopAutoConfiguration matched: + - @ConditionalOnBooleanProperty (spring.aop.auto=true) matched (OnPropertyCondition) + + AopAutoConfiguration.ClassProxyingConfiguration matched: + - @ConditionalOnMissingClass did not find unwanted class 'org.aspectj.weaver.Advice' (OnClassCondition) + - @ConditionalOnBooleanProperty (spring.aop.proxy-target-class=true) matched (OnPropertyCondition) + + ApplicationAvailabilityAutoConfiguration#applicationAvailability matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.availability.ApplicationAvailability; SearchStrategy: all) did not find any beans (On +BeanCondition) + + org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration matched: + - @ConditionalOnClass found required classes 'org.springframework.http.client.reactive.ClientHttpConnector', 'reactor.core.publisher.Mono' (OnClas +sCondition) + - Detected ClientHttpConnectorBuilder (ConditionalOnClientHttpConnectorBuilderDetection) + + ClientHttpConnectorAutoConfiguration#clientHttpConnector matched: + - @ConditionalOnMissingBean (types: org.springframework.http.client.reactive.ClientHttpConnector; SearchStrategy: all) did not find any beans (OnB +eanCondition) + + ClientHttpConnectorAutoConfiguration#clientHttpConnectorBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; SearchStrategy: all) did not find + any beans (OnBeanCondition) + + ClientHttpConnectorAutoConfiguration#clientHttpConnectorSettings matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; SearchStrategy: all) did not find a +ny beans (OnBeanCondition) + + ClientHttpConnectorAutoConfiguration.ReactorNetty matched: + - @ConditionalOnClass found required class 'reactor.netty.http.client.HttpClient' (OnClassCondition) + + DispatcherServletAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + DispatcherServletAutoConfiguration.DispatcherServletConfiguration matched: + - @ConditionalOnClass found required class 'jakarta.servlet.ServletRegistration' (OnClassCondition) + - Default DispatcherServlet did not find dispatcher servlet beans (DispatcherServletAutoConfiguration.DefaultDispatcherServletCondition) + + DispatcherServletAutoConfiguration.DispatcherServletRegistrationConfiguration matched: + - @ConditionalOnClass found required class 'jakarta.servlet.ServletRegistration' (OnClassCondition) + - DispatcherServlet Registration did not find servlet registration bean (DispatcherServletAutoConfiguration.DispatcherServletRegistrationCondition +) + + DispatcherServletAutoConfiguration.DispatcherServletRegistrationConfiguration#dispatcherServletRegistration matched: + - @ConditionalOnBean (names: dispatcherServlet types: org.springframework.web.servlet.DispatcherServlet; SearchStrategy: all) found bean 'dispatch +erServlet' (OnBeanCondition) + + ElasticsearchRestClientAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.elasticsearch.client.RestClientBuilder' (OnClassCondition) + + ElasticsearchRestClientConfigurations.RestClientBuilderConfiguration matched: + - @ConditionalOnMissingBean (types: org.elasticsearch.client.RestClientBuilder; SearchStrategy: all) did not find any beans (OnBeanCondition) + + ElasticsearchRestClientConfigurations.RestClientBuilderConfiguration#elasticsearchConnectionDetails matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails; SearchStrategy: all) did +not find any beans (OnBeanCondition) + + ElasticsearchRestClientConfigurations.RestClientConfiguration matched: + - @ConditionalOnMissingBean (types: org.elasticsearch.client.RestClient; SearchStrategy: all) did not find any beans (OnBeanCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration matched: + - @ConditionalOnWebApplication (required) found 'session' scope (OnWebApplicationCondition) + - @ConditionalOnWarDeployment the application is not deployed as a WAR file. (OnWarDeploymentCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration.NettyWebServerFactoryCustomizerConfiguration matched: + - @ConditionalOnClass found required class 'reactor.netty.http.server.HttpServer' (OnClassCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration.TomcatWebServerFactoryCustomizerConfiguration matched: + - @ConditionalOnClass found required classes 'org.apache.catalina.startup.Tomcat', 'org.apache.coyote.UpgradeProtocol' (OnClassCondition) + + ErrorMvcAutoConfiguration matched: + - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + ErrorMvcAutoConfiguration#basicErrorController matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.error.ErrorController; SearchStrategy: current) did not find any beans (O +nBeanCondition) + + ErrorMvcAutoConfiguration#errorAttributes matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.error.ErrorAttributes; SearchStrategy: current) did not find any beans (O +nBeanCondition) + + ErrorMvcAutoConfiguration.DefaultErrorViewResolverConfiguration#conventionErrorViewResolver matched: + - @ConditionalOnBean (types: org.springframework.web.servlet.DispatcherServlet; SearchStrategy: all) found bean 'dispatcherServlet'; @ConditionalO +nMissingBean (types: org.springframework.boot.autoconfigure.web.servlet.error.ErrorViewResolver; SearchStrategy: all) did not find any beans (OnBeanCond +ition) + + GenericCacheConfiguration matched: + - Cache org.springframework.boot.autoconfigure.cache.GenericCacheConfiguration automatic cache type (CacheCondition) + + GsonAutoConfiguration matched: + - @ConditionalOnClass found required class 'com.google.gson.Gson' (OnClassCondition) + + GsonAutoConfiguration#gson matched: + - @ConditionalOnMissingBean (types: com.google.gson.Gson; SearchStrategy: all) did not find any beans (OnBeanCondition) + + GsonAutoConfiguration#gsonBuilder matched: + - @ConditionalOnMissingBean (types: com.google.gson.GsonBuilder; SearchStrategy: all) did not find any beans (OnBeanCondition) + + GsonHttpMessageConvertersConfiguration matched: + - @ConditionalOnClass found required class 'com.google.gson.Gson' (OnClassCondition) + + HttpClientAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.client.ClientHttpRequestFactory' (OnClassCondition) + - NoneNestedConditions 0 matched 1 did not; NestedCondition on NotReactiveWebApplicationCondition.ReactiveWebApplication did not find reactive web + application classes (NotReactiveWebApplicationCondition) + + HttpClientAutoConfiguration#clientHttpRequestFactoryBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; SearchStrategy: all) did not find any + beans (OnBeanCondition) + + HttpClientAutoConfiguration#clientHttpRequestFactorySettings matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.http.client.ClientHttpRequestFactorySettings; SearchStrategy: all) did not find any b +eans (OnBeanCondition) + + HttpEncodingAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.web.filter.CharacterEncodingFilter' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + - @ConditionalOnBooleanProperty (server.servlet.encoding.enabled=true) matched (OnPropertyCondition) + + HttpEncodingAutoConfiguration#characterEncodingFilter matched: + - @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) did not find any beans (OnBeanCon +dition) + + HttpMessageConvertersAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.converter.HttpMessageConverter' (OnClassCondition) + - NoneNestedConditions 0 matched 1 did not; NestedCondition on HttpMessageConvertersAutoConfiguration.NotReactiveWebApplicationCondition.ReactiveW +ebApplication did not find reactive web application classes (HttpMessageConvertersAutoConfiguration.NotReactiveWebApplicationCondition) + + HttpMessageConvertersAutoConfiguration#messageConverters matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.http.HttpMessageConverters; SearchStrategy: all) did not find any beans + (OnBeanCondition) + + HttpMessageConvertersAutoConfiguration.StringHttpMessageConverterConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.converter.StringHttpMessageConverter' (OnClassCondition) + + HttpMessageConvertersAutoConfiguration.StringHttpMessageConverterConfiguration#stringHttpMessageConverter matched: + - @ConditionalOnMissingBean (types: org.springframework.http.converter.StringHttpMessageConverter; SearchStrategy: all) did not find any beans (On +BeanCondition) + + JCacheCacheConfiguration matched: + - @ConditionalOnClass found required classes 'javax.cache.Caching', 'org.springframework.cache.jcache.JCacheCacheManager' (OnClassCondition) + - Cache org.springframework.boot.autoconfigure.cache.JCacheCacheConfiguration automatic cache type (CacheCondition) + + JacksonAutoConfiguration matched: + - @ConditionalOnClass found required class 'com.fasterxml.jackson.databind.ObjectMapper' (OnClassCondition) + + JacksonAutoConfiguration.Jackson2ObjectMapperBuilderCustomizerConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.converter.json.Jackson2ObjectMapperBuilder' (OnClassCondition) + + JacksonAutoConfiguration.JacksonObjectMapperBuilderConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.converter.json.Jackson2ObjectMapperBuilder' (OnClassCondition) + + JacksonAutoConfiguration.JacksonObjectMapperBuilderConfiguration#jacksonObjectMapperBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; SearchStrategy: all) did not find any bea +ns (OnBeanCondition) + + JacksonAutoConfiguration.JacksonObjectMapperConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.http.converter.json.Jackson2ObjectMapperBuilder' (OnClassCondition) + + JacksonAutoConfiguration.JacksonObjectMapperConfiguration#jacksonObjectMapper matched: + - @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper; SearchStrategy: all) did not find any beans (OnBeanCondition) + + JacksonAutoConfiguration.ParameterNamesModuleConfiguration matched: + - @ConditionalOnClass found required class 'com.fasterxml.jackson.module.paramnames.ParameterNamesModule' (OnClassCondition) + + JacksonAutoConfiguration.ParameterNamesModuleConfiguration#parameterNamesModule matched: + - @ConditionalOnMissingBean (types: com.fasterxml.jackson.module.paramnames.ParameterNamesModule; SearchStrategy: all) did not find any beans (OnB +eanCondition) + + JacksonHttpMessageConvertersConfiguration.MappingJackson2HttpMessageConverterConfiguration matched: + - @ConditionalOnClass found required class 'com.fasterxml.jackson.databind.ObjectMapper' (OnClassCondition) + - @ConditionalOnPreferredJsonMapper JACKSON no property was configured and Jackson is the default (OnPreferredJsonMapperCondition) + - @ConditionalOnBean (types: com.fasterxml.jackson.databind.ObjectMapper; SearchStrategy: all) found bean 'jacksonObjectMapper' (OnBeanCondition) + + JacksonHttpMessageConvertersConfiguration.MappingJackson2HttpMessageConverterConfiguration#mappingJackson2HttpMessageConverter matched: + - @ConditionalOnMissingBean (types: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter ignored: org.springframework.hateo +as.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter,org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter; SearchStrategy: + all) did not find any beans (OnBeanCondition) + + JedisConnectionConfiguration matched: + - @ConditionalOnClass found required classes 'org.apache.commons.pool2.impl.GenericObjectPool', 'org.springframework.data.redis.connection.jedis.J +edisConnection', 'redis.clients.jedis.Jedis' (OnClassCondition) + - @ConditionalOnProperty (spring.data.redis.client-type=jedis) matched (OnPropertyCondition) + - @ConditionalOnMissingBean (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) did not find any beans +(OnBeanCondition) + + JedisConnectionConfiguration#redisConnectionFactory matched: + - @ConditionalOnThreading found PLATFORM (OnThreadingCondition) + + LdapAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.ldap.core.ContextSource' (OnClassCondition) + + LdapAutoConfiguration#ldapContextSource matched: + - @ConditionalOnMissingBean (types: org.springframework.ldap.core.support.LdapContextSource; SearchStrategy: all) did not find any beans (OnBeanCo +ndition) + + LdapAutoConfiguration#ldapTemplate matched: + - @ConditionalOnMissingBean (types: org.springframework.ldap.core.LdapOperations; SearchStrategy: all) did not find any beans (OnBeanCondition) + + LdapAutoConfiguration#objectDirectoryMapper matched: + - @ConditionalOnMissingBean (types: org.springframework.ldap.odm.core.ObjectDirectoryMapper; SearchStrategy: all) did not find any beans (OnBeanCo +ndition) + + LdapAutoConfiguration#propertiesLdapConnectionDetails matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.ldap.LdapConnectionDetails; SearchStrategy: all) did not find any beans + (OnBeanCondition) + + LifecycleAutoConfiguration#defaultLifecycleProcessor matched: + - @ConditionalOnMissingBean (names: lifecycleProcessor; SearchStrategy: current) did not find any beans (OnBeanCondition) + + MultipartAutoConfiguration matched: + - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'org.springframework.web.multipart.support.StandardServletMultipartResolve +r', 'jakarta.servlet.MultipartConfigElement' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + - @ConditionalOnBooleanProperty (spring.servlet.multipart.enabled=true) matched (OnPropertyCondition) + + MultipartAutoConfiguration#multipartConfigElement matched: + - @ConditionalOnMissingBean (types: jakarta.servlet.MultipartConfigElement; SearchStrategy: all) did not find any beans (OnBeanCondition) + + MultipartAutoConfiguration#multipartResolver matched: + - @ConditionalOnMissingBean (types: org.springframework.web.multipart.MultipartResolver; SearchStrategy: all) did not find any beans (OnBeanCondit +ion) + + NettyAutoConfiguration matched: + - @ConditionalOnClass found required class 'io.netty.util.NettyRuntime' (OnClassCondition) + + NoOpCacheConfiguration matched: + - Cache org.springframework.boot.autoconfigure.cache.NoOpCacheConfiguration automatic cache type (CacheCondition) + + OAuth2ClientAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.security.oauth2.client.registration.ClientRegistration' (OnClassCondition) + - NoneNestedConditions 0 matched 1 did not; NestedCondition on OAuth2ClientAutoConfiguration.NonReactiveWebApplicationCondition.ReactiveWebApplica +tionCondition did not find reactive web application classes (OAuth2ClientAutoConfiguration.NonReactiveWebApplicationCondition) + + PersistenceExceptionTranslationAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor' (OnClassCondition) + + PersistenceExceptionTranslationAutoConfiguration#persistenceExceptionTranslationPostProcessor matched: + - @ConditionalOnBooleanProperty (spring.dao.exceptiontranslation.enabled=true) matched (OnPropertyCondition) + - @ConditionalOnMissingBean (types: org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor; SearchStrategy: all) did not +find any beans (OnBeanCondition) + + PropertyPlaceholderAutoConfiguration#propertySourcesPlaceholderConfigurer matched: + - @ConditionalOnMissingBean (types: org.springframework.context.support.PropertySourcesPlaceholderConfigurer; SearchStrategy: current) did not fin +d any beans (OnBeanCondition) + + ReactorAutoConfiguration matched: + - @ConditionalOnClass found required class 'reactor.core.publisher.Hooks' (OnClassCondition) + + ReactorNettyConfigurations.ReactorResourceFactoryConfiguration#reactorResourceFactory matched: + - @ConditionalOnMissingBean (types: org.springframework.http.client.ReactorResourceFactory; SearchStrategy: all) did not find any beans (OnBeanCon +dition) + + RedisAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.data.redis.core.RedisOperations' (OnClassCondition) + + RedisAutoConfiguration#redisConnectionDetails matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.data.redis.RedisConnectionDetails; SearchStrategy: all) did not find an +y beans (OnBeanCondition) + + RedisAutoConfiguration#redisTemplate matched: + - @ConditionalOnSingleCandidate (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) found a single bean + 'redisConnectionFactory'; @ConditionalOnMissingBean (names: redisTemplate; SearchStrategy: all) did not find any beans (OnBeanCondition) + + RedisAutoConfiguration#stringRedisTemplate matched: + - @ConditionalOnSingleCandidate (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) found a single bean + 'redisConnectionFactory'; @ConditionalOnMissingBean (types: org.springframework.data.redis.core.StringRedisTemplate; SearchStrategy: all) did not find +any beans (OnBeanCondition) + + RedisCacheConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.data.redis.connection.RedisConnectionFactory' (OnClassCondition) + - Cache org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration automatic cache type (CacheCondition) + + RedisReactiveAutoConfiguration matched: + - @ConditionalOnClass found required classes 'org.springframework.data.redis.connection.ReactiveRedisConnectionFactory', 'org.springframework.data +.redis.core.ReactiveRedisTemplate', 'reactor.core.publisher.Flux' (OnClassCondition) + + RedisRepositoriesAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.data.redis.repository.configuration.EnableRedisRepositories' (OnClassCondition) + - @ConditionalOnBooleanProperty (spring.data.redis.repositories.enabled=true) matched (OnPropertyCondition) + - @ConditionalOnBean (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) found bean 'redisConnectionFac +tory'; @ConditionalOnMissingBean (types: org.springframework.data.redis.repository.support.RedisRepositoryFactoryBean; SearchStrategy: all) did not find + any beans (OnBeanCondition) + + RestClientAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.web.client.RestClient' (OnClassCondition) + - AnyNestedCondition 1 matched 1 did not; NestedCondition on NotReactiveWebApplicationOrVirtualThreadsExecutorEnabledCondition.VirtualThreadsExecu +torEnabled found non-matching nested conditions @ConditionalOnThreading did not find VIRTUAL; NestedCondition on NotReactiveWebApplicationOrVirtualThrea +dsExecutorEnabledCondition.NotReactiveWebApplication NoneNestedConditions 0 matched 1 did not; NestedCondition on NotReactiveWebApplicationCondition.Rea +ctiveWebApplication did not find reactive web application classes (NotReactiveWebApplicationOrVirtualThreadsExecutorEnabledCondition) + + RestClientAutoConfiguration#httpMessageConvertersRestClientCustomizer matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.web.client.HttpMessageConvertersRestClientCustomizer; SearchStrategy: a +ll) did not find any beans (OnBeanCondition) + + RestClientAutoConfiguration#restClientBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.web.client.RestClient$Builder; SearchStrategy: all) did not find any beans (OnBeanConditio +n) + + RestClientAutoConfiguration#restClientBuilderConfigurer matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.web.client.RestClientBuilderConfigurer; SearchStrategy: all) did not fi +nd any beans (OnBeanCondition) + + RestClientAutoConfiguration#restClientSsl matched: + - @ConditionalOnBean (types: org.springframework.boot.ssl.SslBundles; SearchStrategy: all) found bean 'sslBundleRegistry'; @ConditionalOnMissingBe +an (types: org.springframework.boot.autoconfigure.web.client.RestClientSsl; SearchStrategy: all) did not find any beans (OnBeanCondition) + + RestTemplateAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.web.client.RestTemplate' (OnClassCondition) + - NoneNestedConditions 0 matched 1 did not; NestedCondition on NotReactiveWebApplicationCondition.ReactiveWebApplication did not find reactive web + application classes (NotReactiveWebApplicationCondition) + + RestTemplateAutoConfiguration#restTemplateBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.web.client.RestTemplateBuilder; SearchStrategy: all) did not find any beans (OnBeanCo +ndition) + + Saml2RelyingPartyAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository' ( +OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + SecurityAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.security.authentication.DefaultAuthenticationEventPublisher' (OnClassCondition) + + SecurityAutoConfiguration#authenticationEventPublisher matched: + - @ConditionalOnMissingBean (types: org.springframework.security.authentication.AuthenticationEventPublisher; SearchStrategy: all) did not find an +y beans (OnBeanCondition) + + SecurityFilterAutoConfiguration matched: + - @ConditionalOnClass found required classes 'org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer', 'org.springfram +ework.security.config.http.SessionCreationPolicy' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + SecurityFilterAutoConfiguration#securityFilterChainRegistration matched: + - @ConditionalOnBean (names: springSecurityFilterChain; SearchStrategy: all) found bean 'springSecurityFilterChain' (OnBeanCondition) + + ServletWebServerFactoryAutoConfiguration matched: + - @ConditionalOnClass found required class 'jakarta.servlet.ServletRequest' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + ServletWebServerFactoryAutoConfiguration.TomcatConfiguration matched: + - @ConditionalOnClass found required class 'org.apache.catalina.startup.Tomcat' (OnClassCondition) + + SimpleCacheConfiguration matched: + - Cache org.springframework.boot.autoconfigure.cache.SimpleCacheConfiguration automatic cache type (CacheCondition) + + SpringBootWebSecurityConfiguration matched: + - found 'session' scope (OnWebApplicationCondition) + + SpringDataWebAutoConfiguration matched: + - @ConditionalOnClass found required classes 'org.springframework.data.web.PageableHandlerMethodArgumentResolver', 'org.springframework.web.servle +t.config.annotation.WebMvcConfigurer' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + - @ConditionalOnMissingBean (types: org.springframework.data.web.PageableHandlerMethodArgumentResolver; SearchStrategy: all) did not find any bean +s (OnBeanCondition) + + SpringDataWebAutoConfiguration#pageableCustomizer matched: + - @ConditionalOnMissingBean (types: org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer; SearchStrategy: all) did +not find any beans (OnBeanCondition) + + SpringDataWebAutoConfiguration#sortCustomizer matched: + - @ConditionalOnMissingBean (types: org.springframework.data.web.config.SortHandlerMethodArgumentResolverCustomizer; SearchStrategy: all) did not +find any beans (OnBeanCondition) + + SpringDataWebAutoConfiguration#springDataWebSettings matched: + - @ConditionalOnMissingBean (types: org.springframework.data.web.config.SpringDataWebSettings; SearchStrategy: all) did not find any beans (OnBean +Condition) + + SqlInitializationAutoConfiguration matched: + - @ConditionalOnBooleanProperty (spring.sql.init.enabled=true) matched (OnPropertyCondition) + - NoneNestedConditions 0 matched 1 did not; NestedCondition on SqlInitializationAutoConfiguration.SqlInitializationModeCondition.ModeIsNever @Cond +itionalOnProperty (spring.sql.init.mode=never) did not find property 'spring.sql.init.mode' (SqlInitializationAutoConfiguration.SqlInitializationModeCon +dition) + + SslAutoConfiguration#sslBundleRegistry matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.ssl.SslBundleRegistry,org.springframework.boot.ssl.SslBundles; SearchStrategy: all) d +id not find any beans (OnBeanCondition) + + TaskExecutionAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor' (OnClassCondition) + + TaskExecutorConfigurations.AsyncConfigurerConfiguration matched: + - @ConditionalOnMissingBean (types: org.springframework.scheduling.annotation.AsyncConfigurer; SearchStrategy: all) did not find any beans (OnBean +Condition) + + TaskExecutorConfigurations.AsyncConfigurerConfiguration#applicationTaskExecutorAsyncConfigurer matched: + - @ConditionalOnMissingBean (types: org.springframework.scheduling.annotation.AsyncConfigurer; SearchStrategy: all) did not find any beans (OnBean +Condition) + + TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration#simpleAsyncTaskExecutorBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; SearchStrategy: all) did not find any beans (OnB +eanCondition) + - @ConditionalOnThreading found PLATFORM (OnThreadingCondition) + + TaskExecutorConfigurations.TaskExecutorConfiguration matched: + - AnyNestedCondition 1 matched 1 did not; NestedCondition on TaskExecutorConfigurations.OnExecutorCondition.ModelCondition @ConditionalOnProperty +(spring.task.execution.mode=force) did not find property 'spring.task.execution.mode'; NestedCondition on TaskExecutorConfigurations.OnExecutorCondition +.ExecutorBeanCondition @ConditionalOnMissingBean (types: java.util.concurrent.Executor; SearchStrategy: all) did not find any beans (TaskExecutorConfigu +rations.OnExecutorCondition) + + TaskExecutorConfigurations.TaskExecutorConfiguration#applicationTaskExecutor matched: + - @ConditionalOnThreading found PLATFORM (OnThreadingCondition) + + TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration#threadPoolTaskExecutorBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; SearchStrategy: all) did not find any beans (OnBe +anCondition) + + TaskSchedulingAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler' (OnClassCondition) + + TaskSchedulingAutoConfiguration#scheduledBeanLazyInitializationExcludeFilter matched: + - @ConditionalOnBean (names: org.springframework.context.annotation.internalScheduledAnnotationProcessor; SearchStrategy: all) found bean 'org.spr +ingframework.context.annotation.internalScheduledAnnotationProcessor' (OnBeanCondition) + + TaskSchedulingConfigurations.SimpleAsyncTaskSchedulerBuilderConfiguration#simpleAsyncTaskSchedulerBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; SearchStrategy: all) did not find any beans (On +BeanCondition) + - @ConditionalOnThreading found PLATFORM (OnThreadingCondition) + + TaskSchedulingConfigurations.TaskSchedulerConfiguration matched: + - @ConditionalOnBean (names: org.springframework.context.annotation.internalScheduledAnnotationProcessor; SearchStrategy: all) found bean 'org.spr +ingframework.context.annotation.internalScheduledAnnotationProcessor'; @ConditionalOnMissingBean (types: org.springframework.scheduling.TaskScheduler,ja +va.util.concurrent.ScheduledExecutorService; SearchStrategy: all) did not find any beans (OnBeanCondition) + + TaskSchedulingConfigurations.TaskSchedulerConfiguration#taskScheduler matched: + - @ConditionalOnThreading found PLATFORM (OnThreadingCondition) + + TaskSchedulingConfigurations.ThreadPoolTaskSchedulerBuilderConfiguration#threadPoolTaskSchedulerBuilder matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder; SearchStrategy: all) did not find any beans (OnB +eanCondition) + + TransactionAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.transaction.PlatformTransactionManager' (OnClassCondition) + + TransactionManagerCustomizationAutoConfiguration matched: + - @ConditionalOnClass found required class 'org.springframework.transaction.PlatformTransactionManager' (OnClassCondition) + + TransactionManagerCustomizationAutoConfiguration#platformTransactionManagerCustomizers matched: + - @ConditionalOnMissingBean (types: org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers; SearchStrategy: all) did not + find any beans (OnBeanCondition) + + WebMvcAutoConfiguration matched: + - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'org.springframework.web.servlet.DispatcherServlet', 'org.springframework. +web.servlet.config.annotation.WebMvcConfigurer' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + - @ConditionalOnMissingBean (types: org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; SearchStrategy: all) did not fin +d any beans (OnBeanCondition) + + WebMvcAutoConfiguration#formContentFilter matched: + - @ConditionalOnBooleanProperty (spring.mvc.formcontent.filter.enabled=true) matched (OnPropertyCondition) + - @ConditionalOnMissingBean (types: org.springframework.web.filter.FormContentFilter; SearchStrategy: all) did not find any beans (OnBeanCondition +) + + WebMvcAutoConfiguration.EnableWebMvcConfiguration#flashMapManager matched: + - @ConditionalOnMissingBean (names: flashMapManager; SearchStrategy: all) did not find any beans (OnBeanCondition) + + WebMvcAutoConfiguration.EnableWebMvcConfiguration#localeResolver matched: + - @ConditionalOnMissingBean (names: localeResolver; SearchStrategy: all) did not find any beans (OnBeanCondition) + + WebMvcAutoConfiguration.EnableWebMvcConfiguration#themeResolver matched: + - @ConditionalOnMissingBean (names: themeResolver; SearchStrategy: all) did not find any beans (OnBeanCondition) + + WebMvcAutoConfiguration.EnableWebMvcConfiguration#viewNameTranslator matched: + - @ConditionalOnMissingBean (names: viewNameTranslator; SearchStrategy: all) did not find any beans (OnBeanCondition) + + WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter#defaultViewResolver matched: + - @ConditionalOnMissingBean (types: org.springframework.web.servlet.view.InternalResourceViewResolver; SearchStrategy: all) did not find any beans + (OnBeanCondition) + + WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter#requestContextFilter matched: + - @ConditionalOnMissingBean (types: org.springframework.web.context.request.RequestContextListener,org.springframework.web.filter.RequestContextFi +lter; SearchStrategy: all) did not find any beans (OnBeanCondition) + + WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter#viewResolver matched: + - @ConditionalOnBean (types: org.springframework.web.servlet.ViewResolver; SearchStrategy: all) found beans 'defaultViewResolver', 'mvcViewResolve +r'; @ConditionalOnMissingBean (names: viewResolver types: org.springframework.web.servlet.view.ContentNegotiatingViewResolver; SearchStrategy: all) did +not find any beans (OnBeanCondition) + + WebSocketServletAutoConfiguration matched: + - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'jakarta.websocket.server.ServerContainer' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + WebSocketServletAutoConfiguration.TomcatWebSocketConfiguration matched: + - @ConditionalOnClass found required classes 'org.apache.catalina.startup.Tomcat', 'org.apache.tomcat.websocket.server.WsSci' (OnClassCondition) + + WebSocketServletAutoConfiguration.TomcatWebSocketConfiguration#websocketServletWebServerCustomizer matched: + - @ConditionalOnMissingBean (names: websocketServletWebServerCustomizer; SearchStrategy: all) did not find any beans (OnBeanCondition) + + +Negative matches: +----------------- + + ActiveMQAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.jms.ConnectionFactory' (OnClassCondition) + + AopAutoConfiguration.AspectJAutoProxyingConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.aspectj.weaver.Advice' (OnClassCondition) + + ArtemisAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.jms.ConnectionFactory' (OnClassCondition) + + BatchAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.batch.core.launch.JobLauncher' (OnClassCondition) + + Cache2kCacheConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.cache2k.Cache2kBuilder' (OnClassCondition) + + CacheAutoConfiguration: + Did not match: + - @ConditionalOnBean (types: org.springframework.cache.interceptor.CacheAspectSupport; SearchStrategy: all) did not find any beans of type org. +springframework.cache.interceptor.CacheAspectSupport (OnBeanCondition) + Matched: + - @ConditionalOnClass found required class 'org.springframework.cache.CacheManager' (OnClassCondition) + + CacheAutoConfiguration.CacheManagerEntityManagerFactoryDependsOnPostProcessor: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean' (OnClassCondition) + - Ancestor org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration did not match (ConditionEvaluationReport.AncestorsMatchedConditi +on) + + CaffeineCacheConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.github.benmanes.caffeine.cache.Caffeine' (OnClassCondition) + + CassandraAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.datastax.oss.driver.api.core.CqlSession' (OnClassCondition) + + CassandraDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.datastax.oss.driver.api.core.CqlSession' (OnClassCondition) + + CassandraReactiveDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.datastax.oss.driver.api.core.CqlSession' (OnClassCondition) + + CassandraReactiveRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.cassandra.ReactiveSession' (OnClassCondition) + + CassandraRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.datastax.oss.driver.api.core.CqlSession' (OnClassCondition) + + org.springframework.boot.autoconfigure.web.reactive.function.client.ClientHttpConnectorAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.function.client.WebClient' (OnClassCondition) + + CodecsAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.function.client.WebClient' (OnClassCondition) + + CouchbaseAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Cluster' (OnClassCondition) + + CouchbaseCacheConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Cluster' (OnClassCondition) + + CouchbaseDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Bucket' (OnClassCondition) + + CouchbaseReactiveDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Cluster' (OnClassCondition) + + CouchbaseReactiveRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Cluster' (OnClassCondition) + + CouchbaseRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.couchbase.client.java.Bucket' (OnClassCondition) + + DataSourceAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType' (OnClassCondition) + + DataSourceInitializationConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jdbc.datasource.init.DatabasePopulator' (OnClassCondition) + + DataSourceTransactionManagerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jdbc.core.JdbcTemplate' (OnClassCondition) + + DispatcherServletAutoConfiguration.DispatcherServletConfiguration#multipartResolver: + Did not match: + - @ConditionalOnBean (types: org.springframework.web.multipart.MultipartResolver; SearchStrategy: all) did not find any beans of type org.sprin +gframework.web.multipart.MultipartResolver (OnBeanCondition) + + ElasticsearchClientAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'co.elastic.clients.elasticsearch.ElasticsearchClient' (OnClassCondition) + + ElasticsearchDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate' (OnClassCondition) + + ElasticsearchRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.elasticsearch.repository.ElasticsearchRepository' (OnClassCondition +) + + ElasticsearchRestClientConfigurations.RestClientSnifferConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.elasticsearch.client.sniff.Sniffer' (OnClassCondition) + + EmbeddedLdapAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.unboundid.ldap.listener.InMemoryDirectoryServer' (OnClassCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration.JettyWebServerFactoryCustomizerConfiguration: + Did not match: + - @ConditionalOnClass did not find required classes 'org.eclipse.jetty.server.Server', 'org.eclipse.jetty.util.Loader', 'org.eclipse.jetty.ee10 +.webapp.WebAppContext' (OnClassCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration.TomcatWebServerFactoryCustomizerConfiguration#tomcatVirtualThreadsProtocolHandlerCustomizer: + Did not match: + - @ConditionalOnThreading did not find VIRTUAL (OnThreadingCondition) + + EmbeddedWebServerFactoryCustomizerAutoConfiguration.UndertowWebServerFactoryCustomizerConfiguration: + Did not match: + - @ConditionalOnClass did not find required classes 'io.undertow.Undertow', 'org.xnio.SslClientAuthMode' (OnClassCondition) + + ErrorMvcAutoConfiguration.WhitelabelErrorViewConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (server.error.whitelabel.enabled=true) found different value in property 'server.error.whitelabel.enabled' (OnP +ropertyCondition) + + ErrorWebFluxAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.config.WebFluxConfigurer' (OnClassCondition) + + FlywayAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.flywaydb.core.Flyway' (OnClassCondition) + + FreeMarkerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'freemarker.template.Configuration' (OnClassCondition) + + GraphQlAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlQueryByExampleAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlQuerydslAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.querydsl.core.Query' (OnClassCondition) + + GraphQlRSocketAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlReactiveQueryByExampleAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlReactiveQuerydslAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.querydsl.core.Query' (OnClassCondition) + + GraphQlWebFluxAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlWebFluxSecurityAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlWebMvcAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GraphQlWebMvcSecurityAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + GroovyTemplateAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'groovy.text.markup.MarkupTemplateEngine' (OnClassCondition) + + GsonHttpMessageConvertersConfiguration.GsonHttpMessageConverterConfiguration: + Did not match: + - AnyNestedCondition 0 matched 2 did not; NestedCondition on GsonHttpMessageConvertersConfiguration.PreferGsonOrJacksonAndJsonbUnavailableCondi +tion.JacksonJsonbUnavailable NoneNestedConditions 1 matched 1 did not; NestedCondition on GsonHttpMessageConvertersConfiguration.JacksonAndJsonbUnavaila +bleCondition.JsonbPreferred @ConditionalOnPreferredJsonMapper JSONB no property was configured and Jackson is the default; NestedCondition on GsonHttpMe +ssageConvertersConfiguration.JacksonAndJsonbUnavailableCondition.JacksonAvailable @ConditionalOnBean (types: org.springframework.http.converter.json.Map +pingJackson2HttpMessageConverter; SearchStrategy: all) found bean 'mappingJackson2HttpMessageConverter'; NestedCondition on GsonHttpMessageConvertersCon +figuration.PreferGsonOrJacksonAndJsonbUnavailableCondition.GsonPreferred @ConditionalOnPreferredJsonMapper GSON no property was configured and Jackson i +s the default (GsonHttpMessageConvertersConfiguration.PreferGsonOrJacksonAndJsonbUnavailableCondition) + + H2ConsoleAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.h2.server.web.JakartaWebServlet' (OnClassCondition) + + HazelcastAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.hazelcast.core.HazelcastInstance' (OnClassCondition) + + HazelcastCacheConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.hazelcast.core.HazelcastInstance' (OnClassCondition) + + HazelcastJCacheCustomizationConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.hazelcast.core.HazelcastInstance' (OnClassCondition) + + HazelcastJpaDependencyAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.hazelcast.core.HazelcastInstance' (OnClassCondition) + + HibernateJpaAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.persistence.EntityManager' (OnClassCondition) + + HttpHandlerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.DispatcherHandler' (OnClassCondition) + + HypermediaAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.hateoas.EntityModel' (OnClassCondition) + + InfinispanCacheConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.infinispan.spring.embedded.provider.SpringEmbeddedCacheManager' (OnClassCondition) + + IntegrationAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.integration.config.EnableIntegration' (OnClassCondition) + + JacksonHttpMessageConvertersConfiguration.MappingJackson2XmlHttpMessageConverterConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.fasterxml.jackson.dataformat.xml.XmlMapper' (OnClassCondition) + + JdbcClientAutoConfiguration: + Did not match: + - @ConditionalOnSingleCandidate did not find required type 'org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate' (OnBeanConditi +on) + + JdbcRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration' (OnClassCondition +) + + JdbcTemplateAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jdbc.core.JdbcTemplate' (OnClassCondition) + + JedisConnectionConfiguration#redisConnectionFactoryVirtualThreads: + Did not match: + - @ConditionalOnThreading did not find VIRTUAL (OnThreadingCondition) + + JerseyAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.glassfish.jersey.server.spring.SpringComponentProvider' (OnClassCondition) + + JmsAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.jms.Message' (OnClassCondition) + + JmxAutoConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (spring.jmx.enabled=true) found different value in property 'spring.jmx.enabled' (OnPropertyCondition) + Matched: + - @ConditionalOnClass found required class 'org.springframework.jmx.export.MBeanExporter' (OnClassCondition) + + JndiConnectionFactoryAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jms.core.JmsTemplate' (OnClassCondition) + + JndiDataSourceAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType' (OnClassCondition) + + JooqAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.jooq.DSLContext' (OnClassCondition) + + JpaRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.jpa.repository.JpaRepository' (OnClassCondition) + + JsonbAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.json.bind.Jsonb' (OnClassCondition) + + JsonbHttpMessageConvertersConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.json.bind.Jsonb' (OnClassCondition) + + JtaAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.transaction.Transaction' (OnClassCondition) + + KafkaAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.kafka.core.KafkaTemplate' (OnClassCondition) + + LdapRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.ldap.repository.LdapRepository' (OnClassCondition) + + LettuceConnectionConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.lettuce.core.RedisClient' (OnClassCondition) + + LiquibaseAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'liquibase.change.DatabaseChange' (OnClassCondition) + + MailSenderAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.mail.internet.MimeMessage' (OnClassCondition) + + MailSenderValidatorAutoConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (spring.mail.test-connection=true) did not find property 'spring.mail.test-connection' (OnPropertyCondition) + + MessageSourceAutoConfiguration: + Did not match: + - ResourceBundle did not find bundle with basename messages (MessageSourceAutoConfiguration.ResourceBundleCondition) + + MongoAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' (OnClassCondition) + + MongoDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' (OnClassCondition) + + MongoReactiveAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.reactivestreams.client.MongoClient' (OnClassCondition) + + MongoReactiveDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.reactivestreams.client.MongoClient' (OnClassCondition) + + MongoReactiveRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.reactivestreams.client.MongoClient' (OnClassCondition) + + MongoRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' (OnClassCondition) + + MustacheAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.samskivert.mustache.Mustache' (OnClassCondition) + + Neo4jAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.neo4j.driver.Driver' (OnClassCondition) + + Neo4jDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.neo4j.driver.Driver' (OnClassCondition) + + Neo4jReactiveDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.neo4j.driver.Driver' (OnClassCondition) + + Neo4jReactiveRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.neo4j.driver.Driver' (OnClassCondition) + + Neo4jRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.neo4j.driver.Driver' (OnClassCondition) + + OAuth2AuthorizationServerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.security.oauth2.server.authorization.OAuth2Authorization' (OnClassCondit +ion) + + OAuth2AuthorizationServerJwtAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.security.oauth2.server.authorization.OAuth2Authorization' (OnClassCondit +ion) + + OAuth2ClientConfigurations.ClientRegistrationRepositoryConfiguration: + Did not match: + - OAuth2 Clients Configured Condition registered clients is not available (ClientsConfiguredCondition) + + OAuth2ClientConfigurations.OAuth2AuthorizedClientServiceConfiguration: + Did not match: + - @ConditionalOnBean (types: org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; SearchStrategy: all) did not + find any beans of type org.springframework.security.oauth2.client.registration.ClientRegistrationRepository (OnBeanCondition) + + OAuth2ClientWebSecurityAutoConfiguration: + Did not match: + - @ConditionalOnBean (types: org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; SearchStrategy: all) did not find any be +ans of type org.springframework.security.oauth2.client.OAuth2AuthorizedClientService (OnBeanCondition) + Matched: + - @ConditionalOnClass found required classes 'org.springframework.security.config.annotation.web.configuration.EnableWebSecurity', 'org.springf +ramework.security.oauth2.client.web.OAuth2AuthorizedClientRepository' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + OAuth2ResourceServerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication +Token' (OnClassCondition) + + ProjectInfoAutoConfiguration#buildProperties: + Did not match: + - @ConditionalOnResource did not find resource '${spring.info.build.location:classpath:META-INF/build-info.properties}' (OnResourceCondition) + + ProjectInfoAutoConfiguration#gitProperties: + Did not match: + - GitResource did not find git info at classpath:git.properties (ProjectInfoAutoConfiguration.GitResourceAvailableCondition) + + PulsarAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.apache.pulsar.client.api.PulsarClient' (OnClassCondition) + + PulsarReactiveAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.apache.pulsar.client.api.PulsarClient' (OnClassCondition) + + QuartzAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.quartz.Scheduler' (OnClassCondition) + + R2dbcAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.r2dbc.spi.ConnectionFactory' (OnClassCondition) + + R2dbcDataAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.r2dbc.core.R2dbcEntityTemplate' (OnClassCondition) + + R2dbcInitializationConfiguration: + Did not match: + - @ConditionalOnClass did not find required classes 'io.r2dbc.spi.ConnectionFactory', 'org.springframework.r2dbc.connection.init.DatabasePopula +tor' (OnClassCondition) + + R2dbcProxyAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.r2dbc.proxy.ProxyConnectionFactory' (OnClassCondition) + + R2dbcRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.r2dbc.spi.ConnectionFactory' (OnClassCondition) + + R2dbcTransactionManagerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.r2dbc.connection.R2dbcTransactionManager' (OnClassCondition) + + RSocketGraphQlClientAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'graphql.GraphQL' (OnClassCondition) + + RSocketMessagingAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.rsocket.RSocket' (OnClassCondition) + + RSocketRequesterAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.rsocket.RSocket' (OnClassCondition) + + RSocketSecurityAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.security.rsocket.core.SecuritySocketAcceptorInterceptor' (OnClassConditi +on) + + RSocketServerAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.rsocket.core.RSocketServer' (OnClassCondition) + + RSocketStrategiesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.rsocket.RSocket' (OnClassCondition) + + RabbitAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.rabbitmq.client.Channel' (OnClassCondition) + + ReactiveElasticsearchClientAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'co.elastic.clients.transport.ElasticsearchTransport' (OnClassCondition) + + ReactiveElasticsearchRepositoriesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.elasticsearch.client.elc.ReactiveElasticsearchClient' (OnClassCondi +tion) + + ReactiveMultipartAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.config.WebFluxConfigurer' (OnClassCondition) + + ReactiveOAuth2ClientAutoConfiguration: + Did not match: + - NoneNestedConditions 1 matched 0 did not; NestedCondition on ReactiveOAuth2ClientAutoConfiguration.NonServletApplicationCondition.ServletAppl +icationCondition found 'session' scope (ReactiveOAuth2ClientAutoConfiguration.NonServletApplicationCondition) + Matched: + - @ConditionalOnClass found required classes 'reactor.core.publisher.Flux', 'org.springframework.security.oauth2.client.registration.ClientRegi +stration' (OnClassCondition) + + ReactiveOAuth2ClientWebSecurityAutoConfiguration: + Did not match: + - @ConditionalOnWebApplication did not find reactive web application classes (OnWebApplicationCondition) + + ReactiveOAuth2ResourceServerAutoConfiguration: + Did not match: + - @ConditionalOnWebApplication did not find reactive web application classes (OnWebApplicationCondition) + + ReactiveSecurityAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.config.WebFluxConfigurer' (OnClassCondition) + + ReactiveUserDetailsServiceAutoConfiguration: + Did not match: + - AnyNestedCondition 0 matched 3 did not; NestedCondition on ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConf +igured.PasswordConfigured @ConditionalOnProperty (spring.security.user.password) did not find property 'spring.security.user.password'; NestedCondition +on ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.NameConfigured @ConditionalOnProperty (spring.security.user. +name) did not find property 'spring.security.user.name'; NestedCondition on ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserProperti +esConfigured.MissingAlternative @ConditionalOnMissingClass found unwanted class 'org.springframework.security.oauth2.client.registration.ClientRegistrat +ionRepository' (ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured) + Matched: + - @ConditionalOnClass found required class 'org.springframework.security.authentication.ReactiveAuthenticationManager' (OnClassCondition) + + ReactiveWebServerFactoryAutoConfiguration: + Did not match: + - @ConditionalOnWebApplication did not find reactive web application classes (OnWebApplicationCondition) + + RedisReactiveAutoConfiguration#reactiveRedisTemplate: + Did not match: + - @ConditionalOnBean (types: org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; SearchStrategy: all) did not find any be +ans of type org.springframework.data.redis.connection.ReactiveRedisConnectionFactory (OnBeanCondition) + + RedisReactiveAutoConfiguration#reactiveStringRedisTemplate: + Did not match: + - @ConditionalOnBean (types: org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; SearchStrategy: all) did not find any be +ans of type org.springframework.data.redis.connection.ReactiveRedisConnectionFactory (OnBeanCondition) + + RepositoryRestMvcAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration' (OnClassConditio +n) + + Saml2LoginConfiguration: + Did not match: + - AllNestedConditions 1 matched 1 did not; NestedCondition on DefaultWebSecurityCondition.Beans @ConditionalOnMissingBean (types: org.springfra +mework.security.web.SecurityFilterChain; SearchStrategy: all) found beans of type 'org.springframework.security.web.SecurityFilterChain' securityFilterC +hain; NestedCondition on DefaultWebSecurityCondition.Classes @ConditionalOnClass found required classes 'org.springframework.security.web.SecurityFilter +Chain', 'org.springframework.security.config.annotation.web.builders.HttpSecurity' (DefaultWebSecurityCondition) + + Saml2RelyingPartyRegistrationConfiguration: + Did not match: + - Relying Party Registration Condition did not find any registrations (RegistrationConfiguredCondition) + + SecurityDataConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.security.data.repository.query.SecurityEvaluationContextExtension' (OnCl +assCondition) + + SendGridAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'com.sendgrid.SendGrid' (OnClassCondition) + + ServletWebServerFactoryAutoConfiguration.ForwardedHeaderFilterConfiguration: + Did not match: + - @ConditionalOnProperty (server.forward-headers-strategy=framework) did not find property 'server.forward-headers-strategy' (OnPropertyConditi +on) + + ServletWebServerFactoryConfiguration.EmbeddedJetty: + Did not match: + - @ConditionalOnClass did not find required classes 'org.eclipse.jetty.server.Server', 'org.eclipse.jetty.util.Loader', 'org.eclipse.jetty.ee10 +.webapp.WebAppContext' (OnClassCondition) + + ServletWebServerFactoryConfiguration.EmbeddedTomcat: + Did not match: + - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.server.ServletWebServerFactory; SearchStrategy: current) found beans o +f type 'org.springframework.boot.web.servlet.server.ServletWebServerFactory' configurableServletWebServerFactory (OnBeanCondition) + Matched: + - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'org.apache.catalina.startup.Tomcat', 'org.apache.coyote.UpgradeProtoco +l' (OnClassCondition) + + ServletWebServerFactoryConfiguration.EmbeddedUndertow: + Did not match: + - @ConditionalOnClass did not find required classes 'io.undertow.Undertow', 'org.xnio.SslClientAuthMode' (OnClassCondition) + + SpringApplicationAdminJmxAutoConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (spring.application.admin.enabled=true) did not find property 'spring.application.admin.enabled' (OnPropertyCon +dition) + + SpringBootWebSecurityConfiguration.SecurityFilterChainConfiguration: + Did not match: + - AllNestedConditions 1 matched 1 did not; NestedCondition on DefaultWebSecurityCondition.Beans @ConditionalOnMissingBean (types: org.springfra +mework.security.web.SecurityFilterChain; SearchStrategy: all) found beans of type 'org.springframework.security.web.SecurityFilterChain' securityFilterC +hain; NestedCondition on DefaultWebSecurityCondition.Classes @ConditionalOnClass found required classes 'org.springframework.security.web.SecurityFilter +Chain', 'org.springframework.security.config.annotation.web.builders.HttpSecurity' (DefaultWebSecurityCondition) + + SpringBootWebSecurityConfiguration.WebSecurityEnablerConfiguration: + Did not match: + - @ConditionalOnMissingBean (names: springSecurityFilterChain; SearchStrategy: all) found beans named springSecurityFilterChain (OnBeanConditio +n) + Matched: + - @ConditionalOnClass found required class 'org.springframework.security.config.annotation.web.configuration.EnableWebSecurity' (OnClassConditi +on) + + TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration#simpleAsyncTaskExecutorBuilderVirtualThreads: + Did not match: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; SearchStrategy: all) found beans of type 'org +.springframework.boot.task.SimpleAsyncTaskExecutorBuilder' simpleAsyncTaskExecutorBuilder (OnBeanCondition) + + TaskExecutorConfigurations.TaskExecutorConfiguration#applicationTaskExecutorVirtualThreads: + Did not match: + - @ConditionalOnThreading did not find VIRTUAL (OnThreadingCondition) + + TaskSchedulingConfigurations.SimpleAsyncTaskSchedulerBuilderConfiguration#simpleAsyncTaskSchedulerBuilderVirtualThreads: + Did not match: + - @ConditionalOnMissingBean (types: org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; SearchStrategy: all) found beans of type 'or +g.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder' simpleAsyncTaskSchedulerBuilder (OnBeanCondition) + + TaskSchedulingConfigurations.TaskSchedulerConfiguration#taskSchedulerVirtualThreads: + Did not match: + - @ConditionalOnThreading did not find VIRTUAL (OnThreadingCondition) + + ThymeleafAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.thymeleaf.spring6.SpringTemplateEngine' (OnClassCondition) + + TransactionAutoConfiguration#transactionalOperator: + Did not match: + - @ConditionalOnSingleCandidate (types: org.springframework.transaction.ReactiveTransactionManager; SearchStrategy: all) did not find any beans + (OnBeanCondition) + + TransactionAutoConfiguration.AspectJTransactionManagementConfiguration: + Did not match: + - @ConditionalOnBean did not find required type 'org.springframework.transaction.aspectj.AbstractTransactionAspect' (OnBeanCondition) + - @ConditionalOnBean (types: org.springframework.transaction.aspectj.AbstractTransactionAspect; SearchStrategy: all) did not find any beans of +type org.springframework.transaction.aspectj.AbstractTransactionAspect (OnBeanCondition) + + TransactionAutoConfiguration.EnableTransactionManagementConfiguration: + Did not match: + - @ConditionalOnBean (types: org.springframework.transaction.TransactionManager; SearchStrategy: all) did not find any beans of type org.spring +framework.transaction.TransactionManager (OnBeanCondition) + + TransactionAutoConfiguration.EnableTransactionManagementConfiguration.CglibAutoProxyConfiguration: + Did not match: + - Ancestor org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration$EnableTransactionManagementConfiguration did not mat +ch (ConditionEvaluationReport.AncestorsMatchedCondition) + Matched: + - @ConditionalOnBooleanProperty (spring.aop.proxy-target-class=true) matched (OnPropertyCondition) + + TransactionAutoConfiguration.EnableTransactionManagementConfiguration.JdkDynamicAutoProxyConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (spring.aop.proxy-target-class=false) did not find property 'spring.aop.proxy-target-class' (OnPropertyConditio +n) + - Ancestor org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration$EnableTransactionManagementConfiguration did not mat +ch (ConditionEvaluationReport.AncestorsMatchedCondition) + + TransactionAutoConfiguration.TransactionTemplateConfiguration: + Did not match: + - @ConditionalOnSingleCandidate (types: org.springframework.transaction.PlatformTransactionManager; SearchStrategy: all) did not find any beans + (OnBeanCondition) + + UserDetailsServiceAutoConfiguration: + Did not match: + - AnyNestedCondition 0 matched 3 did not; NestedCondition on UserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.P +asswordConfigured @ConditionalOnProperty (spring.security.user.password) did not find property 'spring.security.user.password'; NestedCondition on UserD +etailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.NameConfigured @ConditionalOnProperty (spring.security.user.name) did not fi +nd property 'spring.security.user.name'; NestedCondition on UserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.MissingAlte +rnative @ConditionalOnMissingClass found unwanted classes 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository', 'org.s +pringframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository' (UserDetailsServiceAutoConfiguration.MissingAlternativeO +rUserPropertiesConfigured) + Matched: + - @ConditionalOnClass found required class 'org.springframework.security.authentication.AuthenticationManager' (OnClassCondition) + - found 'session' scope (OnWebApplicationCondition) + + ValidationAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.validation.executable.ExecutableValidator' (OnClassCondition) + + WebClientAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.function.client.WebClient' (OnClassCondition) + + WebFluxAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.reactive.config.WebFluxConfigurer' (OnClassCondition) + + WebMvcAutoConfiguration#hiddenHttpMethodFilter: + Did not match: + - @ConditionalOnBooleanProperty (spring.mvc.hiddenmethod.filter.enabled=true) did not find property 'spring.mvc.hiddenmethod.filter.enabled' (O +nPropertyCondition) + + WebMvcAutoConfiguration.ProblemDetailsErrorHandlingConfiguration: + Did not match: + - @ConditionalOnBooleanProperty (spring.mvc.problemdetails.enabled=true) did not find property 'spring.mvc.problemdetails.enabled' (OnPropertyC +ondition) + + WebMvcAutoConfiguration.ResourceChainCustomizerConfiguration: + Did not match: + - @ConditionalOnEnabledResourceChain did not find class org.webjars.WebJarVersionLocator (OnEnabledResourceChainCondition) + + WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter#beanNameViewResolver: + Did not match: + - @ConditionalOnBean (types: org.springframework.web.servlet.View; SearchStrategy: all) did not find any beans of type org.springframework.web. +servlet.View (OnBeanCondition) + + WebServiceTemplateAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.ws.client.core.WebServiceTemplate' (OnClassCondition) + + WebServicesAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.ws.transport.http.MessageDispatcherServlet' (OnClassCondition) + + WebSessionIdResolverAutoConfiguration: + Did not match: + - @ConditionalOnWebApplication did not find reactive web application classes (OnWebApplicationCondition) + + WebSocketMessagingAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.springframework.web.socket.config.annotation.DelegatingWebSocketMessageBrokerConfigurati +on' (OnClassCondition) + + WebSocketReactiveAutoConfiguration: + Did not match: + - @ConditionalOnWebApplication did not find reactive web application classes (OnWebApplicationCondition) + + WebSocketServletAutoConfiguration.JettyWebSocketConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerIniti +alizer' (OnClassCondition) + + WebSocketServletAutoConfiguration.UndertowWebSocketConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'io.undertow.websockets.jsr.Bootstrap' (OnClassCondition) + + XADataSourceAutoConfiguration: + Did not match: + - @ConditionalOnClass did not find required class 'jakarta.transaction.TransactionManager' (OnClassCondition) + + +Exclusions: +----------- + + org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + + org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + + +Unconditional classes: +---------------------- + + org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration + + org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration + + org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration + + org.springframework.boot.autoconfigure.context.LifecycleAutoConfiguration + + org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration + + org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration + + + +2026-07-21 10:05:40.225 INFO  com.structurizr.server.Local - Started Local in 31.296 seconds (process running for 32.458) +2026-07-21 10:05:40.227 DEBUG org.springframework.boot.availability.ApplicationAvailabilityBean - Application availability state + LivenessState changed to CORRECT +2026-07-21 10:05:40.230 DEBUG org.springframework.boot.availability.ApplicationAvailabilityBean - Application availability state + ReadinessState changed to ACCEPTING_TRAFFIC \ No newline at end of file From ba8493e29afda129a8f234440846b9ac66a5dae4 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Thu, 30 Jul 2026 12:15:03 -0700 Subject: [PATCH 5/8] Push workspaces by key instead of workspace ID. --- README.md | 30 +++- pom.xml | 1 + structurizr-admin-api/pom.xml | 25 +++ structurizr-application/pom.xml | 2 + .../java/com/structurizr/Application.java | 1 + .../structurizr/command/CreateCommand.java | 8 +- .../structurizr/command/PushKeyCommand.java | 156 ++++++++++++++++++ .../workspace/WorkspaceComponentImpl.java | 13 +- .../server/domain/WorkspaceMetadata.java | 6 +- .../java/com/structurizr/util/Version.java | 18 +- .../src/main/resources/application.properties | 2 +- .../WorkspaceComponentImplTests.java | 14 ++ .../web/api/AdminApiControllerTests.java | 106 ++++++++++++ .../com/structurizr/api/AdminApiClient.java | 108 +++++++++++- 14 files changed, 461 insertions(+), 29 deletions(-) create mode 100644 structurizr-admin-api/pom.xml create mode 100644 structurizr-application/src/main/java/com/structurizr/command/PushKeyCommand.java create mode 100644 structurizr-application/src/test/java/com/structurizr/server/web/api/AdminApiControllerTests.java diff --git a/README.md b/README.md index 33f9b539..5d628c3f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This repository can be configured for enterprise-hosted deployments that require - OIDC sign-in for the web UI. - Token-based API authentication for workspace uploads. +- Admin-key based workspace creation and key-based pushes. The key properties are: @@ -47,12 +48,35 @@ structurizr.authentication.api.scopes=structurizr.upload structurizr.authentication.api.sharedtoken= ``` -When configured, API uploads can pass a client-credentials OAuth access token via `-key`, e.g.: +When configured, numeric-ID workspace uploads can pass a client-credentials OAuth access token via `-key`, e.g.: ```bash java -jar structurizr-1.0.0.war push -url https://structurizr-app.example.com/api -id 2 -workspace ./workspace.json -key "$ACCESS_TOKEN" -merge false -archive true ``` -## Build +For key-based pushes, define a stable workspace key in the workspace DSL: + +```dsl +workspace { + properties { + key "dewey" + } +} +``` -`.\mvnw.cmd -pl structurizr-application -Pexclude-playwright -DskipTests package` \ No newline at end of file +Then use `push-key` with the server admin key: + +```bash +java -jar structurizr-1.0.0.war push-key -url https://structurizr-app.example.com/api -workspace ./workspace.dsl --adminApiKey "$ADMIN_API_KEY" -merge false -archive true +``` + +`push-key` reads `properties.key` from the workspace definition and creates or resolves the target workspace automatically. It does not accept `-workspace-key`. + +Credential summary: + +- `push` uses a workspace API key or configured API bearer token for an existing numeric workspace ID. +- `push-key` uses `--adminApiKey` with the server `ADMIN_API_KEY` because it may create the workspace before pushing to it. + +## Build + +`.\mvnw.cmd -pl structurizr-application -am -Pexclude-playwright -DskipTests package` \ No newline at end of file diff --git a/pom.xml b/pom.xml index 263f8d4b..d949a97b 100644 --- a/pom.xml +++ b/pom.xml @@ -167,6 +167,7 @@ structurizr-import structurizr-inspection structurizr-application + structurizr-admin-api structurizr-mcp \ No newline at end of file diff --git a/structurizr-admin-api/pom.xml b/structurizr-admin-api/pom.xml new file mode 100644 index 00000000..36e92e8a --- /dev/null +++ b/structurizr-admin-api/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + com.structurizr + structurizr + 6.2.0 + + + structurizr-admin-api + jar + structurizr-admin-api + + + + org.springframework.boot + spring-boot-starter-web + 3.5.13 + provided + + + \ No newline at end of file diff --git a/structurizr-application/pom.xml b/structurizr-application/pom.xml index 49739a90..ca694adf 100644 --- a/structurizr-application/pom.xml +++ b/structurizr-application/pom.xml @@ -20,6 +20,7 @@ 1.0.0 + 6.2.0 UTF-8 21 @@ -336,6 +337,7 @@ org.springframework.boot spring-boot-maven-plugin + true org.jetbrains.kotlin diff --git a/structurizr-application/src/main/java/com/structurizr/Application.java b/structurizr-application/src/main/java/com/structurizr/Application.java index 6041463a..7d51f5ca 100644 --- a/structurizr-application/src/main/java/com/structurizr/Application.java +++ b/structurizr-application/src/main/java/com/structurizr/Application.java @@ -17,6 +17,7 @@ public class Application { register(new ServerCommand()); register(new PushCommand()); + register(new PushKeyCommand()); register(new PullCommand()); register(new LockCommand()); register(new UnlockCommand()); diff --git a/structurizr-application/src/main/java/com/structurizr/command/CreateCommand.java b/structurizr-application/src/main/java/com/structurizr/command/CreateCommand.java index 866c6833..b00c6ab9 100644 --- a/structurizr-application/src/main/java/com/structurizr/command/CreateCommand.java +++ b/structurizr-application/src/main/java/com/structurizr/command/CreateCommand.java @@ -26,6 +26,10 @@ public void run(String... args) throws Exception { option.setRequired(false); options.addOption(option); + option = new Option("workspace-key", "workspaceKey", true, "Workspace routing key to create or resolve"); + option.setRequired(false); + options.addOption(option); + option = new Option("json", "json", false, "Output JSON"); option.setRequired(false); options.addOption(option); @@ -34,6 +38,7 @@ public void run(String... args) throws Exception { String apiUrl = ""; String apiKey = ""; + String workspaceKey = ""; boolean json = false; try { @@ -41,6 +46,7 @@ public void run(String... args) throws Exception { apiUrl = cmd.getOptionValue("apiUrl"); apiKey = cmd.getOptionValue("apiKey"); + workspaceKey = cmd.getOptionValue("workspaceKey"); json = cmd.hasOption("json"); } catch (ParseException e) { log.error(e.getMessage()); @@ -53,7 +59,7 @@ public void run(String... args) throws Exception { AdminApiClient client = new AdminApiClient(apiUrl, apiKey); client.setAgent(getAgent()); - WorkspaceMetadata workspace = client.createWorkspace(); + WorkspaceMetadata workspace = client.createWorkspace(workspaceKey); if (json) { ObjectMapper mapper = new ObjectMapper(); diff --git a/structurizr-application/src/main/java/com/structurizr/command/PushKeyCommand.java b/structurizr-application/src/main/java/com/structurizr/command/PushKeyCommand.java new file mode 100644 index 00000000..9bf0016d --- /dev/null +++ b/structurizr-application/src/main/java/com/structurizr/command/PushKeyCommand.java @@ -0,0 +1,156 @@ +package com.structurizr.command; + +import com.structurizr.Workspace; +import com.structurizr.api.AdminApiClient; +import com.structurizr.api.WorkspaceApiClient; +import com.structurizr.api.WorkspaceMetadata; +import com.structurizr.encryption.AesEncryptionStrategy; +import com.structurizr.io.json.JsonWriter; +import com.structurizr.util.StringUtils; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.io.StringWriter; + +public class PushKeyCommand extends AbstractCommand { + + private static final Log log = LogFactory.getLog(PushKeyCommand.class); + + public PushKeyCommand() { + super("push-key"); + } + + @Override + public void run(String... args) throws Exception { + Options options = new Options(); + + Option option = new Option("url", "apiUrl", true, "Structurizr API URL"); + option.setRequired(true); + options.addOption(option); + + option = new Option(null, "adminApiKey", true, "Admin API key"); + option.setRequired(false); + options.addOption(option); + + option = new Option("w", "workspace", true, "Path or URL to the workspace JSON/DSL file"); + option.setRequired(true); + options.addOption(option); + + option = new Option("branch", "branch", true, "Branch name"); + option.setRequired(false); + options.addOption(option); + + option = new Option("passphrase", "passphrase", true, "Client-side encryption passphrase"); + option.setRequired(false); + options.addOption(option); + + option = new Option("merge", "mergeFromRemote", true, "Whether to merge layout information from the remote workspace (default=true)"); + option.setRequired(false); + options.addOption(option); + + option = new Option("trim", "trim", true, "Whether to trim the workspace before pushing (default=false)"); + option.setRequired(false); + options.addOption(option); + + option = new Option("archive", "archive", true, "Stores the previous version of the remote workspace"); + option.setRequired(false); + options.addOption(option); + + option = new Option("debug", "debug", false, "Enable debug logging"); + option.setRequired(false); + options.addOption(option); + + CommandLineParser commandLineParser = new DefaultParser(); + + String apiUrl; + String apiKey; + String workspaceKey; + String workspacePath; + String branch; + String passphrase; + boolean mergeFromRemote; + boolean trim; + boolean archive; + boolean debug; + + try { + CommandLine cmd = commandLineParser.parse(options, args); + + apiUrl = cmd.getOptionValue("apiUrl"); + apiKey = cmd.getOptionValue("adminApiKey"); + workspacePath = cmd.getOptionValue("workspace"); + branch = cmd.getOptionValue("branch"); + passphrase = cmd.getOptionValue("passphrase"); + mergeFromRemote = Boolean.parseBoolean(cmd.getOptionValue("merge", "true")); + trim = Boolean.parseBoolean(cmd.getOptionValue("trim", "false")); + archive = Boolean.parseBoolean(cmd.getOptionValue("archive", "true")); + debug = cmd.hasOption("debug"); + } catch (ParseException e) { + log.error(e.getMessage()); + showHelp(options); + System.exit(1); + return; + } + + if (debug) { + configureDebugLogging(); + } + + Workspace workspace = loadWorkspace(workspacePath); + + workspaceKey = workspace.getProperties().get("key"); + + if (StringUtils.isNullOrEmpty(workspaceKey)) { + log.error("define a workspace key in you workspace.dsl as properties {\nkey \"uniqueSystemName\"\n}"); + System.exit(1); + return; + } + + if (trim) { + log.info(" - trimming workspace"); + workspace.trim(); + } + + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(true); + jsonWriter.write(workspace, stringWriter); + + AdminApiClient client = new AdminApiClient(apiUrl, apiKey); + client.setAgent(getAgent()); + WorkspaceMetadata workspaceMetadata = client.createWorkspace(workspaceKey); + + WorkspaceApiClient workspaceApiClient = new WorkspaceApiClient(apiUrl, workspaceMetadata.getId(), workspaceMetadata.getApiKey()); + workspaceApiClient.setBranch(branch); + workspaceApiClient.setAgent(getAgent()); + workspaceApiClient.setMergeFromRemote(mergeFromRemote); + workspaceApiClient.setWorkspaceArchiveLocation(null); + + if (!StringUtils.isNullOrEmpty(passphrase)) { + log.info(" - using client-side encryption"); + workspaceApiClient.setEncryptionStrategy(new AesEncryptionStrategy(passphrase)); + } + + File archivePath = new File(workspacePath).getParentFile(); + if (archive) { + workspaceApiClient.setWorkspaceArchiveLocation(archivePath); + log.info(" - storing previous version of workspace in " + workspaceApiClient.getWorkspaceArchiveLocation()); + } + + if (StringUtils.isNullOrEmpty(branch)) { + log.info("Pushing workspace " + workspaceKey + " to " + apiUrl); + } else { + log.info("Pushing workspace " + workspaceKey + " to " + apiUrl + " (branch=" + branch + ")"); + } + log.info(" - merge layout from remote: " + mergeFromRemote); + log.info(" - pushing workspace"); + workspaceApiClient.putWorkspace(workspace); + log.info(" - finished"); + } +} \ No newline at end of file diff --git a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java index 0ac24653..9a21b77a 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java +++ b/structurizr-application/src/main/java/com/structurizr/server/component/workspace/WorkspaceComponentImpl.java @@ -289,15 +289,10 @@ public long createWorkspace(User user) { workspaceId = workspaceIds.stream().reduce(0L, Long::max) + 1; } - try { - // create and write the workspace metadata - WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(workspaceId); - workspaceMetadata.regenerateApiKey(); - - putWorkspaceMetadata(workspaceMetadata); - } catch (Exception e) { - log.error(e); - } + // create and write the workspace metadata before storing workspace content + WorkspaceMetadata workspaceMetadata = new WorkspaceMetadata(workspaceId); + workspaceMetadata.regenerateApiKey(); + putWorkspaceMetadata(workspaceMetadata); NumberFormat format = new DecimalFormat("0000"); String dsl = DslTemplate.generate("Workspace " + format.format(workspaceId), "Description"); diff --git a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java index f3534764..652a2158 100644 --- a/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java +++ b/structurizr-application/src/main/java/com/structurizr/server/domain/WorkspaceMetadata.java @@ -191,7 +191,11 @@ public void setSharingToken(String sharingToken) { } public String getSharingTokenTruncated() { - return (sharingToken == null ? "" : sharingToken.substring(0, 6)) + "..."; + if (StringUtils.isNullOrEmpty(sharingToken)) { + return ""; + } + + return sharingToken.substring(0, Math.min(6, sharingToken.length())) + "..."; } public boolean isArchived() { diff --git a/structurizr-application/src/main/java/com/structurizr/util/Version.java b/structurizr-application/src/main/java/com/structurizr/util/Version.java index 441473b4..ad4b3bde 100644 --- a/structurizr-application/src/main/java/com/structurizr/util/Version.java +++ b/structurizr-application/src/main/java/com/structurizr/util/Version.java @@ -6,17 +6,23 @@ public class Version { private static final String APP_VERSION_KEY = "app.version"; + private static final String APP_VERSION_ENVIRONMENT_VARIABLE = "APP_VERSION"; private static String version = ""; static { try { - Properties properties = new Properties(); - InputStream in = Version.class.getClassLoader().getResourceAsStream("application.properties"); - properties.load(in); - if (in != null) { - version = properties.getProperty(APP_VERSION_KEY); - in.close(); + String environmentVersion = System.getenv(APP_VERSION_ENVIRONMENT_VARIABLE); + if (environmentVersion != null && !environmentVersion.isBlank()) { + version = environmentVersion; + } else { + Properties properties = new Properties(); + InputStream in = Version.class.getClassLoader().getResourceAsStream("application.properties"); + if (in != null) { + properties.load(in); + version = properties.getProperty(APP_VERSION_KEY); + in.close(); + } } } catch (Exception e) { e.printStackTrace(); diff --git a/structurizr-application/src/main/resources/application.properties b/structurizr-application/src/main/resources/application.properties index 0651a555..2c62cd39 100644 --- a/structurizr-application/src/main/resources/application.properties +++ b/structurizr-application/src/main/resources/application.properties @@ -1,4 +1,4 @@ -app.version=@project.version@ +app.version=@app.displayVersion@ spring.mvc.view.prefix=/WEB-INF/jsp/ spring.mvc.view.suffix=.jsp diff --git a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java index d435c38e..82819644 100644 --- a/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java +++ b/structurizr-application/src/test/java/com/structurizr/server/component/workspace/WorkspaceComponentImplTests.java @@ -901,6 +901,20 @@ public void putWorkspace(WorkspaceMetadata workspaceMetaData, String json, Strin assertEquals("Workspace 0001", workspace.getName()); } + @Test + void createWorkspace_ThrowsException_WhenWorkspaceMetadataCannotBePersisted() { + WorkspaceComponent workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { + @Override + public void putWorkspaceMetadata(WorkspaceMetadata wmd) { + throw new RuntimeException("boom"); + } + }); + + WorkspaceComponentException exception = assertThrows(WorkspaceComponentException.class, () -> workspaceComponent.createWorkspace(null)); + + assertEquals("Could not create workspace", exception.getMessage()); + } + @Test void deleteWorkspace() { WorkspaceComponent workspaceComponent = new WorkspaceComponentImpl(new MockWorkspaceAdapter() { diff --git a/structurizr-application/src/test/java/com/structurizr/server/web/api/AdminApiControllerTests.java b/structurizr-application/src/test/java/com/structurizr/server/web/api/AdminApiControllerTests.java new file mode 100644 index 00000000..493e7971 --- /dev/null +++ b/structurizr-application/src/test/java/com/structurizr/server/web/api/AdminApiControllerTests.java @@ -0,0 +1,106 @@ +package com.structurizr.server.web.api; + +import com.structurizr.server.domain.WorkspaceMetadata; +import com.structurizr.server.web.MockHttpServletResponse; +import com.structurizr.server.web.MockWorkspaceComponent; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class AdminApiControllerTests { + + @Test + void createWorkspace_ReturnsExistingWorkspace_WhenRoutingKeyAlreadyExists() { + WorkspaceMetadata existing = new WorkspaceMetadata(7); + existing.setRoutingKey("dewey"); + + AdminApiController controller = new AdminApiController(); + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) { + return existing; + } + }); + + WorkspaceMetadata result = controller.createWorkspace(null, System.getenv("ADMIN_API_KEY"), "dewey"); + + assertSame(existing, result); + } + + @Test + void createWorkspace_PersistsRoutingKey_OnNewWorkspace() { + WorkspaceMetadata created = new WorkspaceMetadata(1); + WorkspaceMetadata[] persisted = new WorkspaceMetadata[1]; + + AdminApiController controller = new AdminApiController(); + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) { + return null; + } + + @Override + public long createWorkspace(com.structurizr.server.domain.User user) { + return 1; + } + + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return created; + } + + @Override + public void putWorkspaceMetadata(WorkspaceMetadata workspaceMetaData) { + persisted[0] = workspaceMetaData; + } + }); + + WorkspaceMetadata result = controller.createWorkspace(null, System.getenv("ADMIN_API_KEY"), "dewey"); + + assertSame(created, result); + assertEquals("dewey", created.getRoutingKey()); + assertSame(created, persisted[0]); + } + + @Test + void createWorkspace_ThrowsApiException_WhenMetadataCannotBeLoadedAfterCreation() { + AdminApiController controller = new AdminApiController(); + controller.setWorkspaceComponent(new MockWorkspaceComponent() { + @Override + public WorkspaceMetadata getWorkspaceMetadataByRoutingKey(String routingKey) { + return null; + } + + @Override + public long createWorkspace(com.structurizr.server.domain.User user) { + return 1; + } + + @Override + public WorkspaceMetadata getWorkspaceMetadata(long workspaceId) { + return null; + } + }); + + ApiException exception = assertThrows(ApiException.class, + () -> controller.createWorkspace(null, System.getenv("ADMIN_API_KEY"), "dewey")); + + assertEquals("Workspace metadata could not be loaded after creation", exception.getMessage()); + } + + @Test + void error_ReturnsInternalServerErrorResponse_ForUncheckedExceptions() { + AdminApiController controller = new AdminApiController(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + ApiResponse apiResponse = controller.error(new IllegalStateException("Lookup failed"), response); + + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + assertFalse(apiResponse.isSuccess()); + assertEquals("Lookup failed", apiResponse.getMessage()); + } +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java b/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java index cafa48bb..833eb6c6 100644 --- a/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java +++ b/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java @@ -8,8 +8,11 @@ import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -54,7 +57,7 @@ public List getWorkspaces() throws StructurizrClientException String json = EntityUtils.toString(response.getEntity()); debugResponse(response, json); - return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, json); + return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, response.getCode(), json); }); checkResponseIsJson(result.getContent()); @@ -83,29 +86,93 @@ public List getWorkspaces() throws StructurizrClientException * @throws StructurizrClientException if an error occurs */ public WorkspaceMetadata createWorkspace() throws StructurizrClientException { + return createWorkspace(null); + } + + /** + * Creates a new workspace, or resolves an existing workspace by routing key. + * + * @param routingKey the routing key to create or resolve + * @return a WorkspaceMetadata object representing the workspace + * @throws StructurizrClientException if an error occurs + */ + public WorkspaceMetadata createWorkspace(String routingKey) throws StructurizrClientException { try (CloseableHttpClient httpClient = HttpClients.createSystem()) { log.debug("Creating workspace"); - HttpUriRequestBase httpRequest = new HttpPost(url + WORKSPACE_PATH); + String path = url + WORKSPACE_PATH; + if (routingKey != null && !routingKey.isBlank()) { + path += "?key=" + URLEncoder.encode(routingKey.trim(), StandardCharsets.UTF_8); + } + + HttpUriRequestBase httpRequest = new HttpPost(path); addHeaders(httpRequest, ""); debugRequest(httpRequest, null); HttpClientResult result = httpClient.execute(httpRequest, response -> { - String json = EntityUtils.toString(response.getEntity()); + String json = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; debugResponse(response, json); - return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, json); + return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, response.getCode(), json); }); - checkResponseIsJson(result.getContent()); - ApiResponse apiResponse = ApiResponse.parse(result.getContent()); - if (result.isSuccess()) { + checkResponseIsJson(result.getContent()); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return objectMapper.readValue(result.getContent(), WorkspaceMetadata.class); } else { + String content = result.getContent() == null ? "" : result.getContent().trim(); + if (content.startsWith("{")) { + ApiResponse apiResponse = ApiResponse.parse(content); + throw new StructurizrClientException(apiResponse.getMessage()); + } + + String message = "Admin API request failed with HTTP " + result.getStatusCode(); + if (!content.isEmpty()) { + message += ": " + content; + } + + throw new StructurizrClientException(message); + } + } catch (StructurizrClientException e) { + log.error(e); + throw e; + } catch (Exception e) { + log.error(e); + throw new StructurizrClientException(e); + } + } + + /** + * Pushes a workspace JSON document by routing key. + * + * @param routingKey the routing key + * @param json the workspace JSON + * @throws StructurizrClientException if an error occurs + */ + public void putWorkspaceByKey(String routingKey, String json) throws StructurizrClientException { + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + log.debug("Pushing workspace by key " + routingKey); + + HttpPut httpRequest = new HttpPut(url + WORKSPACE_PATH + "/key/" + URLEncoder.encode(routingKey.trim(), StandardCharsets.UTF_8)); + httpRequest.setEntity(new StringEntity(json)); + + addHeaders(httpRequest, "application/json; charset=UTF-8"); + debugRequest(httpRequest, json); + + HttpClientResult result = httpClient.execute(httpRequest, response -> { + String responseJson = EntityUtils.toString(response.getEntity()); + debugResponse(response, responseJson); + + return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, response.getCode(), responseJson); + }); + + checkResponseIsJson(result.getContent()); + ApiResponse apiResponse = ApiResponse.parse(result.getContent()); + + if (!result.isSuccess()) { throw new StructurizrClientException(apiResponse.getMessage()); } } catch (Exception e) { @@ -134,7 +201,7 @@ public boolean deleteWorkspace(long workspaceId) throws StructurizrClientExcepti String json = EntityUtils.toString(response.getEntity()); debugResponse(response, json); - return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, json); + return new HttpClientResult(response.getCode() == HttpStatus.SC_OK, response.getCode(), json); }); checkResponseIsJson(result.getContent()); @@ -151,4 +218,29 @@ public boolean deleteWorkspace(long workspaceId) throws StructurizrClientExcepti } } + private static final class HttpClientResult { + + private final boolean success; + private final int statusCode; + private final String content; + + private HttpClientResult(boolean success, int statusCode, String content) { + this.success = success; + this.statusCode = statusCode; + this.content = content; + } + + private boolean isSuccess() { + return success; + } + + private int getStatusCode() { + return statusCode; + } + + private String getContent() { + return content; + } + } + } \ No newline at end of file From 76fb8fa71bb642158f0e4dee52e469e9e47d4862 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Thu, 6 Aug 2026 15:24:00 -0700 Subject: [PATCH 6/8] Handle null docs --- README.md | 2 +- .../documentation/DocumentationContent.java | 4 ++++ .../AbstractDocumentableInspection.java | 4 ++++ .../EmbeddedViewMissingInspectionTests.java | 15 +++++++++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d628c3f..aff776f2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ workspace { Then use `push-key` with the server admin key: ```bash -java -jar structurizr-1.0.0.war push-key -url https://structurizr-app.example.com/api -workspace ./workspace.dsl --adminApiKey "$ADMIN_API_KEY" -merge false -archive true +java -jar structurizr-1.0.0.war push-key -url https://structurizr-app.example.com/api -workspace ./workspace.json --adminApiKey "$ADMIN_API_KEY" -merge false -archive true ``` `push-key` reads `properties.key` from the workspace definition and creates or resolves the target workspace automatically. It does not accept `-workspace-key`. diff --git a/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java b/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java index 6f33d04e..e6bfa027 100644 --- a/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java @@ -76,6 +76,10 @@ public void setFormat(Format format) { public Set findImages() { Set images = new HashSet<>(); + if (content == null) { + return images; + } + Pattern pattern; if (format == Format.Markdown) { pattern = IMAGE_IN_MARKDOWN_PATTERN; diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java index 7b2eb945..beaae38f 100644 --- a/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java @@ -56,6 +56,10 @@ protected Set findEmbeddedViewKeys(Documentable documentable) { private Set findEmbeddedViewKeys(DocumentationContent content) { Set keys = new LinkedHashSet<>(); + if (content.getContent() == null) { + return keys; + } + String[] lines = content.getContent().split("\n"); for (String line : lines) { if (content.getFormat() == Format.Markdown) { diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java index 9cf023b4..f4f4124f 100644 --- a/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java @@ -7,8 +7,6 @@ import com.structurizr.inspection.DefaultInspector; import com.structurizr.inspection.Severity; import com.structurizr.inspection.Violation; -import com.structurizr.inspection.model.SoftwareSystemDocumentationInspection; -import com.structurizr.model.Container; import com.structurizr.model.SoftwareSystem; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -88,4 +86,17 @@ public void run_WithoutMissingView() { assertNull(violation); } + @Test + public void run_WithNullDocumentationContent() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Section section = new Section(); + section.setFormat(Format.Markdown); + softwareSystem.getDocumentation().addSection(section); + + Violation violation = new EmbeddedViewMissingInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + } From ab40531d37c332a221a1300c95ee5c8c2a100fd7 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Sat, 8 Aug 2026 16:28:12 -0700 Subject: [PATCH 7/8] Add idesign theme --- structurizr-themes/idesign/theme.json | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 structurizr-themes/idesign/theme.json diff --git a/structurizr-themes/idesign/theme.json b/structurizr-themes/idesign/theme.json new file mode 100644 index 00000000..a6d05e35 --- /dev/null +++ b/structurizr-themes/idesign/theme.json @@ -0,0 +1,61 @@ +{ +"name": "IDesign", +"description": "This theme is based on the iDesign color palette.", +"elements": [ +{ +"tag": "idesign-client", +"background": "#d9ead3", +"color": "#252a2e" +}, +{ +"tag": "idesign-mgr", +"background": "#fff2cc", +"color": "#252a2e" +}, +{ +"tag": "idesign-engine", +"background": "#fce5cd", +"color": "#252a2e" +}, +{ +"tag": "idesign-resource-access", +"background": "#cccccc", +"color": "#252a2e" +}, +{ +"tag": "idesign-resource", +"background": "#cfe2f3", +"color": "#252a2e" +}, +{ +"tag": "idesign-utility", +"background": "#ead1dc", +"color": "#252a2e" +}, +{ +"tag": "database", +"shape": "Cylinder" +}, +{ +"tag": "messaging", +"shape": "Pipe" +} +], +"relationships": [ +{ +"tag": "sync", +"color": "#000000", +"dashed": false +}, +{ +"tag": "async-durable", +"color": "#000000", +"dashed": true +}, +{ +"tag": "async-transient", +"color": "#999999", +"dashed": true +} +] +} \ No newline at end of file From abfa006a6f2bbe4c690df29de4169ca39d8feb86 Mon Sep 17 00:00:00 2001 From: Tyler Ulrich Date: Sat, 8 Aug 2026 17:25:28 -0700 Subject: [PATCH 8/8] release script --- .github/workflows/arch-repo-release.yml | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/arch-repo-release.yml diff --git a/.github/workflows/arch-repo-release.yml b/.github/workflows/arch-repo-release.yml new file mode 100644 index 00000000..8403a5a0 --- /dev/null +++ b/.github/workflows/arch-repo-release.yml @@ -0,0 +1,79 @@ +name: Arch Repo Release + +on: + push: + tags: + - "release-*" + release: + types: + - published + workflow_dispatch: + inputs: + tag: + description: "Existing tag to release (for example: release-2026.08.08.1)" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.release.tag_name || github.ref_name || github.event.inputs.tag }} + cancel-in-progress: false + +jobs: + build-and-release: + runs-on: ubuntu-latest + + steps: + - name: Resolve release tag + id: tag + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + echo "value=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_EVENT_NAME}" = "release" ]; then + echo "value=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + else + echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout tag source + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.value }} + fetch-depth: 0 + + - name: Validate manual tag exists + if: github.event_name == 'workflow_dispatch' + run: | + git rev-parse --verify "refs/tags/${{ steps.tag.outputs.value }}" >/dev/null + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + cache: "maven" + + - name: Build WAR + run: mvn -B -pl structurizr-application -am -Pexclude-playwright -DskipTests package + + - name: Generate checksum + run: | + cd structurizr-application/target + sha256sum structurizr-1.0.0.war > structurizr-1.0.0.war.sha256 + + - name: Create or update release with assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.value }} + name: ${{ steps.tag.outputs.value }} + make_latest: true + append_body: true + body: | + Automated build from tag `${{ steps.tag.outputs.value }}`. + files: | + structurizr-application/target/structurizr-1.0.0.war + structurizr-application/target/structurizr-1.0.0.war.sha256 + fail_on_unmatched_files: true + overwrite_files: true