diff --git a/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc b/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc index 2933d50257..331aba3bc3 100644 --- a/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc +++ b/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc @@ -79,16 +79,61 @@ When selecting its permissions, scope the token to the tenant you want to regist See https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for the full walkthrough, including screenshots of the token generation dialog. -=== Export the token +[#_provide_the_token] +=== Provide the token -The plugin reads your PAT from the `TIMEFOLD_PAT` environment variable. -Export it before building: +The plugin takes the PAT from the `TIMEFOLD_PAT` environment variable, and falls back to your Maven settings when that variable is not set. + +Export the variable when you deploy from CI, where the token comes from the pipeline's secret store, or for a one-off deploy from your own shell: [source,bash,options="nowrap"] ---- export TIMEFOLD_PAT= ---- +On your own machine, prefer storing the token in your Maven settings instead, so that you configure it once rather than in every shell. +Maven encrypts server passwords with a master password of your own, so the token is not written to disk in clear text. +Create that master password once, and put the result in `~/.m2/settings-security.xml`: + +[source,bash,options="nowrap"] +---- +mvn --encrypt-master-password +---- + +[source,xml,options="nowrap"] +---- + + {jSMOWnoPFgsHVpMvz5VrIt5kRbzGpI8u+9EF1iFQyJQ=} + +---- + +Then encrypt the PAT, and add it as a `timefold-platform` server in `~/.m2/settings.xml`: + +[source,bash,options="nowrap"] +---- +mvn --encrypt-password +---- + +[source,xml,options="nowrap"] +---- + + + + timefold-platform + {COQLCE6DU6GtcS5P=} + + + +---- + +Both commands prompt for the value, so it never reaches your shell history. +If you keep tokens for several platform environments, point the build at another entry with `-Dtimefold.serverId=`. +See https://maven.apache.org/guides/mini/guide-encryption.html[Maven Password Encryption] for the full details. + +CAUTION: Maven 3 encrypts the master password itself with a hardcoded key, so `settings-security.xml` protects your token against casual reading rather than against someone who can read both files. Maven 4 lifts that limitation: its `mvnenc` tool can take the master password from a GPG agent, a pinentry prompt, an environment variable or 1Password, and the plugin reads those tokens too. + +NOTE: `TIMEFOLD_PAT` wins when both are configured, so unset it if a stale value in your shell shadows the token in your settings. + [#_build_and_deploy] == Build and deploy diff --git a/service/tools/maven-plugin/README.adoc b/service/tools/maven-plugin/README.adoc index 721632db70..451ae117d7 100644 --- a/service/tools/maven-plugin/README.adoc +++ b/service/tools/maven-plugin/README.adoc @@ -20,7 +20,7 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the - Fails the build when the project does not inherit from `ai.timefold.solver:timefold-solver-service-parent`, which Timefold Platform requires - Fails the build when no `ai.timefold.solver.enterprise` artifact is on the resolved classpath, i.e. the model was built with the Community Edition, which Timefold Platform does not accept. Both checks only run when `timefold:deploy` is among the requested goals, run even when `timefold.model.configuration.skip` is set. - Fetches platform identity/config by calling GET /api/platform/v1/aboutme?includeConfig=true -- Requires a platform personal access token available via environment variable `TIMEFOLD_PAT` (see "Authentication" below) +- Requires a platform personal access token, taken from the `TIMEFOLD_PAT` environment variable or from an encrypted `` entry in your Maven settings (see "Authentication" below) - If a single namespace is returned by the platform and `timefold.namespace` wasn't provided, the plugin will use it. The platform reports the namespaces either as `namespaces` or, before the migration to that name is complete, as `accountIds`; both are accepted - Fails the build when the namespace can neither be taken from `timefold.namespace` nor derived from the platform response, i.e. when the personal access token is associated with no namespace or with several of them. In the latter case `timefold.namespace` has to be set explicitly - Writes a properties file at `target/generated-resources/timefold-build.properties` with entries such as: @@ -60,6 +60,7 @@ These are the most important configuration properties for the plugin. They are s - `timefold.model.handleSubscription` (boolean, default=false) — whether the platform should auto-subscribe when registering/undeploying. Use `-Dtimefold.model.handleSubscription=true` to enable. - `project.build.directory` (String) — standard Maven property for build dir; plugin uses `${project.build.directory}/model-descriptor.zip` by default (configurable via Maven project settings). - `timefold.dryRun` (boolean, default=false) — when true, `configure`, `deploy` and `undeploy` will perform a dry run (no changes or uploads). Used as `-Dtimefold.dryRun=true`. +- `timefold.serverId` (String, default=`timefold-platform`) — id of the `` entry in your Maven settings that holds the personal access token. Set via `` or `-Dtimefold.serverId=...`. See "Authentication" below. === Configure goal specific @@ -77,17 +78,73 @@ These are the most important configuration properties for the plugin. They are s - `tfp.model.undeploy.skip` (boolean, default=false) — skip undeploy goal. Note: the property used by the plugin for undeploy skip is `tfp.model.undeploy.skip` (not `timefold.model.undeploy.skip`). -=== Authentication (environment) +== Authentication -- `TIMEFOLD_PAT` — personal access token for Timefold Platform. The plugin reads this environment variable and sets the `Authorization: Bearer ` header on requests. - The token must have the `registered-model:create` and `registered-model:update` scopes; requests will fail with an authorization error if either scope is missing. - See link:https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for how to create a token. +The plugin authenticates with a Timefold Platform personal access token, which it sends as the `Authorization: Bearer ` header. +The token must have the `registered-model:create` and `registered-model:update` scopes; requests will fail with an authorization error if either scope is missing. +See link:https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for how to create a token. + +The token is resolved in this order: + +1. The `TIMEFOLD_PAT` environment variable. +2. The `` of a `` entry in your Maven settings, `timefold-platform` by default. + +The environment variable is the natural fit for CI, where the token comes from the pipeline's secret store. +The server entry is the natural fit for a developer machine, where re-exporting the token in every shell is tedious — and where writing it to a file in clear text is not acceptable. + +=== Store the token in your Maven settings + +Maven encrypts server passwords with a master password of your own, so the token is not kept in clear text. +Create the master password once, and put it in `~/.m2/settings-security.xml`: + +[source,bash] +---- +mvn --encrypt-master-password +---- + +[source,xml] +---- + + {jSMOWnoPFgsHVpMvz5VrIt5kRbzGpI8u+9EF1iFQyJQ=} + +---- + +Then encrypt the personal access token, and add it as a server entry in `~/.m2/settings.xml`: + +[source,bash] +---- +mvn --encrypt-password +---- + +[source,xml] +---- + + + + timefold-platform + {COQLCE6DU6GtcS5P=} + + + +---- + +Both commands prompt for the value, so it does not end up in your shell history. +The plugin decrypts the password through Maven's own `SettingsDecrypter`, so a token encrypted with Maven 4's `mvnenc encrypt` works just as well. +An unencrypted `` is accepted too, but the plugin logs a warning telling you to encrypt it. +`` on the server entry is ignored; the plugin only reads the password. + +Use a different entry with `-Dtimefold.serverId=` or `` in the plugin configuration, for example to keep separate tokens for separate platform environments. + +See link:https://maven.apache.org/guides/mini/guide-encryption.html[Maven Password Encryption] for the full details, including how to keep the master password on a removable drive. + +CAUTION: Maven 3 encrypts the master password itself with a hardcoded key, so `settings-security.xml` protects the token against casual reading, not against someone who can read both files. +Maven 4 removes that limitation, as `mvnenc` can take the master password from a GPG agent, a pinentry prompt, an environment variable or 1Password. == Headers & HTTP details Requests include the following headers: -- `Authorization: Bearer ` (from TIMEFOLD_PAT) +- `Authorization: Bearer ` (see "Authentication" above) - `Content-Type: application/octet-stream` for model upload requests - `Accept: application/json` - `X-TF-TENANT-ID` — set to the first tenant if `timefold.model.tenants` is provided @@ -134,7 +191,7 @@ TIP: To find your tenant ID, log in to the Timefold Platform UI, select *Manage With this binding in place, `mvn clean package timefold:deploy` runs `configure` during the `initialize` phase (before the container image is built later in `package`). Without it, `configure` never runs unless invoked explicitly, and the container image build won't have the platform's required registry/group configuration. -Or call the plugin directly from CLI with system properties and environment var: +Or call the plugin directly from CLI with system properties, with the token either configured in your Maven settings or exported for this shell: [source,bash] ---- @@ -159,4 +216,5 @@ Goals are implemented as Mojos in `src/main/java/ai/timefold/solver/tools/maven` - `UndeployModelMojo` — handles deletion - `AbstractPlatformModelMojo` — common behavior, HTTP client, descriptor reading -The plugin relies on the environment variable `TIMEFOLD_PAT` for authentication; tests provide a test helper to mock token retrieval. +Token resolution lives in `AccessTokenProvider`, which reads the environment and, failing that, the Maven settings; `AbstractPlatformModelMojo` builds it from the session and hands it to every goal. +Tests provide a test helper (`TestAccessTokenProvider`) to mock token retrieval. diff --git a/service/tools/maven-plugin/pom.xml b/service/tools/maven-plugin/pom.xml index 7541c04a5a..8fa3c98365 100644 --- a/service/tools/maven-plugin/pom.xml +++ b/service/tools/maven-plugin/pom.xml @@ -44,6 +44,17 @@ maven-artifact provided + + org.apache.maven + maven-settings + provided + + + + org.apache.maven + maven-settings-builder + provided + org.apache.maven.plugin-tools maven-plugin-annotations @@ -56,6 +67,18 @@ test + + + org.codehaus.plexus + plexus-cipher + test + + + org.codehaus.plexus + plexus-sec-dispatcher + test + + org.junit.jupiter junit-jupiter-api diff --git a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java index 91dd2bbd4e..a5982ca9f9 100644 --- a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java +++ b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java @@ -21,15 +21,16 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.settings.crypto.SettingsDecrypter; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; public abstract class AbstractPlatformModelMojo extends AbstractMojo { - private AccessTokenProvider accessTokenProvider = new AccessTokenProvider(); - private static final String DESCRIPTOR_FILE_NAME = "timefold-model-descriptor.json"; public static final String PROP_DRY_RUN = "timefold.dryRun"; @@ -42,9 +43,26 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo { protected static final String PROP_MODEL_SUBS = "timefold.model.handleSubscription"; + protected static final String PROP_SERVER_ID = "timefold.serverId"; + @Parameter(defaultValue = "${session}", readonly = true) protected MavenSession session; + @Component + private SettingsDecrypter settingsDecrypter; + + /** + * Id of the {@code } entry in the Maven settings that holds the personal access token, as an alternative + * to exporting it as {@code TIMEFOLD_PAT} + */ + @Parameter(property = PROP_SERVER_ID, required = false, defaultValue = AccessTokenProvider.DEFAULT_SERVER_ID) + protected String serverId; + + /** + * Built lazily, as it needs the settings of the session the mojo runs in; tests replace it with a double. + */ + private AccessTokenProvider accessTokenProvider; + /** * URL to the platform that model should be deployed to */ @@ -82,20 +100,19 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo { .connectTimeout(Duration.ofSeconds(10)).build(); protected AccessTokenProvider getAccessTokenProvider() { + if (accessTokenProvider == null) { + accessTokenProvider = new AccessTokenProvider(session == null ? null : session.getSettings(), + settingsDecrypter, getConfiguredServerId(), getLog()); + } return accessTokenProvider; } - protected void setAccessTokenProvider(AccessTokenProvider accessTokenProvider) { - this.accessTokenProvider = accessTokenProvider; + protected void setAccessTokenProvider(AccessTokenProvider provider) { + this.accessTokenProvider = provider; } - protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig) { - var platformPAT = accessTokenProvider.getAccessToken(); - - if (platformPAT == null) { - throw new IllegalArgumentException( - "Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable"); - } + protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig) throws MojoExecutionException { + var platformPAT = requireAccessToken(); var requestBuilder = HttpRequest.newBuilder().GET(); requestBuilder.header("Accept", "application/json"); @@ -109,22 +126,63 @@ protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig) return mapper.readValue(authResponse.body(), PlatformIdentityInfo.class); } else { getLog().debug(authResponse.body()); - throw new IllegalStateException( - "Platform authentication failed with " + authResponse.statusCode() + " status code"); + throw new MojoExecutionException("Platform authentication failed with " + authResponse.statusCode() + + " status code: " + readErrorMessage(authResponse.body())); } - } catch (IllegalStateException e) { + } catch (MojoExecutionException e) { throw e; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("Unexpected error while making platform info call", e); - } catch (Exception e) { - throw new RuntimeException("Unexpected error while making platform info call", e); + throw new MojoExecutionException("Interrupted while making platform info call", e); + } catch (IOException e) { + throw new MojoExecutionException("Unexpected error while making platform info call", e); } } - protected void configureHttpRequest(Builder builder) { + /** + * Resolves the personal access token, failing the build when none is configured. Without this the request goes out + * with an empty bearer token and the platform answers with an authentication error, which points at the token + * being wrong rather than at it never having been configured. + *

+ * Deliberately called while a request is built rather than up front, so that the goals which send nothing on a + * dry run still run without a token: {@code deploy} and {@code undeploy} only reach here once they have decided + * to actually call the platform. {@code configure} reads the platform configuration even on a dry run, as it has + * to write the registry and account id it would build with, so that goal needs a token either way. + * + * @throws MojoExecutionException when no token is configured, or when the configured one cannot be read + */ + protected String requireAccessToken() throws MojoExecutionException { + String accessToken = getAccessTokenProvider().getAccessToken(); + if (accessToken == null || accessToken.isBlank()) { + throw new MojoExecutionException(""" + Personal Access Token for Timefold Platform is required. + Either export it for this build: + export %s= + or store it, encrypted, in your Maven settings (~/.m2/settings.xml): + + %s + {encrypted token} + + Encrypt the token with 'mvn --encrypt-password', after creating a master password with \ + 'mvn --encrypt-master-password'; see %s + See https://docs.timefold.ai/timefold-solver/latest/deploying-to-platform/guide""" + .formatted(AccessTokenProvider.PAT_ENV_VARIABLE, getAccessTokenProvider().getServerId(), + AccessTokenProvider.ENCRYPTION_GUIDE_URL)); + } + return accessToken; + } + + /** + * The raw configured value, which {@link AccessTokenProvider} normalizes; read it back from there rather than + * here whenever it is reported, so that it names the entry that is actually looked up. + */ + private String getConfiguredServerId() { + return session == null ? serverId : getPropertyOrParameter(PROP_SERVER_ID, serverId); + } + + protected void configureHttpRequest(Builder builder) throws MojoExecutionException { builder.timeout(Duration.ofSeconds(30)); - builder.header("Authorization", "Bearer " + accessTokenProvider.getAccessToken()); + builder.header("Authorization", "Bearer " + requireAccessToken()); builder.header("Content-Type", "application/octet-stream"); builder.header("Accept", "application/json"); var tenants = getTenants(); diff --git a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AccessTokenProvider.java b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AccessTokenProvider.java index 62f4c9ddc4..1870dc387d 100644 --- a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AccessTokenProvider.java +++ b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AccessTokenProvider.java @@ -1,8 +1,211 @@ package ai.timefold.solver.tools.maven; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.apache.maven.settings.Server; +import org.apache.maven.settings.Settings; +import org.apache.maven.settings.building.SettingsProblem; +import org.apache.maven.settings.building.SettingsProblem.Severity; +import org.apache.maven.settings.crypto.DefaultSettingsDecryptionRequest; +import org.apache.maven.settings.crypto.SettingsDecrypter; +import org.apache.maven.settings.crypto.SettingsDecryptionResult; + +/** + * Resolves the personal access token that authenticates the build against Timefold Platform, either from the + * environment, or from a {@code } entry of the Maven settings. + *

+ * The latter exists so that the token does not have to be exported into every shell the build runs in, without having + * to keep it in clear text on disk either: it is stored the same way as any other Maven credential, encrypted with + * {@code mvn --encrypt-password} and decrypted here through Maven's own {@link SettingsDecrypter}. + * + * @see Password Encryption + */ public class AccessTokenProvider { - public String getAccessToken() { - return System.getenv("TIMEFOLD_PAT"); + public static final String PAT_ENV_VARIABLE = "TIMEFOLD_PAT"; + + /** + * Id of the {@code } entry the token is read from, unless the build configures a different one. + */ + public static final String DEFAULT_SERVER_ID = "timefold-platform"; + + protected static final String ENCRYPTION_GUIDE_URL = "https://maven.apache.org/guides/mini/guide-encryption.html"; + + private final Settings settings; + + private final SettingsDecrypter settingsDecrypter; + + private final String serverId; + + private final Log log; + + /** + * Only meant for test doubles, which override {@link #getAccessToken()} and therefore never read either source. + */ + protected AccessTokenProvider() { + this(null, null, DEFAULT_SERVER_ID, null); + } + + public AccessTokenProvider(Settings settings, SettingsDecrypter settingsDecrypter, String serverId, Log log) { + this.settings = settings; + this.settingsDecrypter = settingsDecrypter; + this.serverId = serverId == null || serverId.isBlank() ? DEFAULT_SERVER_ID : serverId.trim(); + this.log = log == null ? new SystemStreamLog() : log; } + + /** + * The id of the {@code } entry that is actually read, i.e. the configured one once normalized, or the + * default when the build configures none. Reporting anything else would point at an entry that was never + * consulted. + */ + public String getServerId() { + return serverId; + } + + /** + * The environment takes precedence, so that a build which already exports the token, typically on CI, keeps + * authenticating with it even when the machine also has a server entry configured. + *

+ * Not finding a token and finding one that cannot be read are told apart: a build that configures none simply has + * none, whereas one that stored an encrypted token clearly meant to authenticate with it, so leaving that token + * unread would only surface later as an authentication error that says nothing about the real problem. + * + * @return null when neither source provides a token + * @throws MojoExecutionException when the server entry holds a token that cannot be turned into a usable one, + * i.e. decryption fails or leaves it in its encrypted form + */ + public String getAccessToken() throws MojoExecutionException { + String environmentToken = readEnvironmentToken(); + if (environmentToken != null && !environmentToken.isBlank()) { + log.debug("Personal access token read from the " + PAT_ENV_VARIABLE + " environment variable"); + return environmentToken.trim(); + } + return readSettingsToken(); + } + + /** + * Overridable so that tests do not have to change the environment of the process they run in. + */ + protected String readEnvironmentToken() { + return System.getenv(PAT_ENV_VARIABLE); + } + + private String readSettingsToken() throws MojoExecutionException { + if (settings == null) { + return null; + } + Server configuredServer = settings.getServer(serverId); + if (configuredServer == null) { + log.warn("No server '" + serverId + "' is configured in the Maven settings"); + return null; + } + if (configuredServer.getPassword() == null || configuredServer.getPassword().isBlank()) { + log.warn("Server '" + serverId + "' in the Maven settings has no , so it does not provide a " + + "personal access token for Timefold Platform"); + return null; + } + // Maven's own settings reader trims the value, but leaving that to it would let whitespace around the token + // decide whether it reads as cipher text. Trimmed on a copy, so that the settings of the session, which the + // rest of the build shares, stay as they are. Kept around to tell a token that is stored unencrypted from + // one that decryption left untouched. + String storedPassword = configuredServer.getPassword().trim(); + Server server = configuredServer.clone(); + server.setPassword(storedPassword); + String token = decrypt(server, storedPassword); + if (isEncrypted(storedPassword) && token.equals(storedPassword)) { + // Decryption left the cipher text untouched, so the token was never decrypted at all. Sending it would + // only produce an authentication error that points at the token being wrong, rather than at it being + // unreadable. + throw new MojoExecutionException(""" + The personal access token in server '%s' of the Maven settings could not be decrypted, so it is \ + still in its encrypted form and cannot authenticate against Timefold Platform. + Check that your master password, in ~/.m2/settings-security.xml unless configured elsewhere, is \ + the one the token was encrypted with; encrypt the token again with 'mvn --encrypt-password' if it \ + is not. + See %s""".formatted(serverId, ENCRYPTION_GUIDE_URL)); + } + if (!isEncrypted(storedPassword)) { + log.warn("The personal access token in server '" + serverId + "' of the Maven settings is stored " + + "unencrypted; encrypt it with 'mvn --encrypt-password'. See " + ENCRYPTION_GUIDE_URL); + } + log.debug("Personal access token read from server '" + serverId + "' of the Maven settings"); + return token.trim(); + } + + /** + * Decrypts the stored token the standard Maven way, which transparently covers both the Maven 3 and the Maven 4 + * encryption formats, as well as a token that is not encrypted at all. + * + * @throws MojoExecutionException when the token cannot be decrypted; sending its encrypted form to the platform + * would only fail with an authentication error that does not point at the actual problem + */ + private String decrypt(Server server, String storedPassword) throws MojoExecutionException { + if (settingsDecrypter == null) { + // Outside a Maven build there is nothing to decrypt with; an encrypted token is caught by the caller. + return storedPassword; + } + SettingsDecryptionResult result = settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(server)); + for (SettingsProblem problem : result.getProblems()) { + if (problem.getSeverity() == Severity.WARNING) { + log.warn(problem.getMessage()); + } else { + throw new MojoExecutionException(""" + Unable to decrypt the personal access token in server '%s' of the Maven settings: %s + Check that your master password, in ~/.m2/settings-security.xml unless configured elsewhere, \ + is the one the token was encrypted with; encrypt the token again with 'mvn --encrypt-password' \ + if it is not. + See %s""".formatted(serverId, problem.getMessage(), ENCRYPTION_GUIDE_URL)); + } + } + Server decryptedServer = result.getServer(); + String decryptedPassword = decryptedServer == null ? null : decryptedServer.getPassword(); + // A decrypter that reports nothing usable leaves the stored token as it is; the caller rejects it when that + // means handing on cipher text. + return decryptedPassword == null || decryptedPassword.isBlank() ? storedPassword : decryptedPassword; + } + + /** + * Whether Maven reads the stored value as cipher text, and therefore whether it attempts to decrypt it at all. + * That is what tells a token stored in clear text from one that failed to decrypt, so it has to agree with + * {@code DefaultPlexusCipher.ENCRYPTED_STRING_PATTERN}: the value carries a non-empty {@code {...}} whose closing + * brace is not escaped as {@code \}}. It therefore also accepts a comment outside the braces. + *

+ * Spelled out rather than copied as that expression, whose backtracking is needlessly super-linear. The two agree + * on every value that holds no line terminator, which {@code AccessTokenProviderTest} pins for every value that + * can be built from the characters involved. On one that does hold a line terminator this is only ever the more + * careful of the two, so that a token is never wrongly rejected as undecryptable. + */ + static boolean isEncrypted(String password) { + if (containsLineTerminator(password)) { + return false; + } + int open = password.indexOf('{'); + if (open < 0) { + return false; + } + // Looking at the first opening brace is enough: whatever closing brace a later one pairs with, the first one + // pairs with it too, as it only leaves more content in between. + for (int close = open + 2; close < password.length(); close++) { + if (password.charAt(close) == '}' && password.charAt(close - 1) != '\\') { + return true; + } + } + return false; + } + + /** + * Maven matches the parts around the cipher text with {@code .}, which no line terminator is. + */ + private static boolean containsLineTerminator(String password) { + for (int i = 0; i < password.length(); i++) { + char character = password.charAt(i); + if (character == '\n' || character == '\r' || character == '\u0085' || character == '\u2028' + || character == '\u2029') { + return true; + } + } + return false; + } + } diff --git a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/ConfigureMojo.java b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/ConfigureMojo.java index 65d57c1348..cfd38a4d80 100644 --- a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/ConfigureMojo.java +++ b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/ConfigureMojo.java @@ -125,7 +125,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { // configure container registry credentials as system properties to not write them to any files System.setProperty("quarkus.container-image.username", "token"); - System.setProperty("quarkus.container-image.password", getAccessTokenProvider().getAccessToken()); + System.setProperty("quarkus.container-image.password", requireAccessToken()); } if (!getPropertyOrParameter(PROP_MODEL_NATIVE_SUPPORTED, nativeSupported)) { // allow to use jvm image for native use cases diff --git a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/DeployModelMojo.java b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/DeployModelMojo.java index aaa6b3745e..45f73ffefc 100644 --- a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/DeployModelMojo.java +++ b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/DeployModelMojo.java @@ -1,5 +1,6 @@ package ai.timefold.solver.tools.maven; +import java.io.IOException; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; @@ -184,11 +185,13 @@ public void execute() throws MojoExecutionException { + readErrorMessage(response.body())); } } - } catch (Exception e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new RuntimeException("Unexpected error while deploying model", e); + } catch (MojoExecutionException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted while deploying model", e); + } catch (IOException e) { + throw new MojoExecutionException("Unexpected error while deploying model", e); } } diff --git a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/UndeployModelMojo.java b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/UndeployModelMojo.java index 9059c075d0..0e0266cf75 100644 --- a/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/UndeployModelMojo.java +++ b/service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/UndeployModelMojo.java @@ -1,5 +1,6 @@ package ai.timefold.solver.tools.maven; +import java.io.IOException; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpRequest.Builder; @@ -48,7 +49,7 @@ public void execute() throws MojoExecutionException { Path modelDescriptorArchivePath = Paths.get(buildDirectory, "model-descriptor.zip"); if (!Files.exists(modelDescriptorArchivePath)) { - throw new IllegalStateException("Model descriptor not found in target folder"); + throw new MojoExecutionException("Model descriptor not found in target folder"); } ObjectNode modelDescriptor = readModelDescriptor(modelDescriptorArchivePath); getLog().info(String.format("Model %s (%s) is going to be undeployed from platform %s with registration key %s", @@ -76,18 +77,18 @@ public void execute() throws MojoExecutionException { key)); } else { printErrorInfo(response.body()); - throw new IllegalStateException( + throw new MojoExecutionException( "Model undeploy failed with " + response.statusCode() + " status code: " + readErrorMessage(response.body())); } } - } catch (IllegalStateException e) { + } catch (MojoExecutionException e) { throw e; - } catch (Exception e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new RuntimeException("Unexpected error while undeploying model", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted while undeploying model", e); + } catch (IOException e) { + throw new MojoExecutionException("Unexpected error while undeploying model", e); } } diff --git a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/AccessTokenProviderTest.java b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/AccessTokenProviderTest.java new file mode 100644 index 0000000000..f38324123a --- /dev/null +++ b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/AccessTokenProviderTest.java @@ -0,0 +1,431 @@ +package ai.timefold.solver.tools.maven; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.tools.maven.utils.InMemoryMojoLog; +import ai.timefold.solver.tools.maven.utils.InMemoryMojoLog.Level; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.settings.Proxy; +import org.apache.maven.settings.Server; +import org.apache.maven.settings.Settings; +import org.apache.maven.settings.building.DefaultSettingsProblem; +import org.apache.maven.settings.building.SettingsProblem; +import org.apache.maven.settings.building.SettingsProblem.Severity; +import org.apache.maven.settings.crypto.DefaultSettingsDecrypter; +import org.apache.maven.settings.crypto.SettingsDecrypter; +import org.apache.maven.settings.crypto.SettingsDecryptionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sonatype.plexus.components.cipher.DefaultPlexusCipher; +import org.sonatype.plexus.components.sec.dispatcher.DefaultSecDispatcher; + +class AccessTokenProviderTest { + + private static final String TOKEN = "dummy-access-token"; + + @TempDir + private Path tempDir; + + private InMemoryMojoLog log = new InMemoryMojoLog(); + + @BeforeEach + void setUp() throws Exception { + log.clear(); + } + + @Test + void environmentTokenTakesPrecedenceOverTheSettings() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, "from-settings"); + + AccessTokenProvider provider = provider(" from-environment ", settings, plainDecrypter()); + + // trimmed, as an exported value easily picks up trailing whitespace + assertThat(provider.getAccessToken()).isEqualTo("from-environment"); + } + + @Test + void blankEnvironmentTokenFallsBackToTheSettings() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, "from-settings"); + + AccessTokenProvider provider = provider(" ", settings, plainDecrypter()); + + assertThat(provider.getAccessToken()).isEqualTo("from-settings"); + } + + @Test + void encryptedTokenInTheSettingsIsDecrypted() throws Exception { + String masterPassword = "the-master-password"; + SettingsDecrypter decrypter = mavenDecrypter(masterPassword); + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, + new DefaultPlexusCipher().encryptAndDecorate(TOKEN, masterPassword)); + + AccessTokenProvider provider = provider(null, settings, decrypter); + + assertThat(provider.getAccessToken()).isEqualTo(TOKEN); + // an encrypted token is the expected way of storing it, so nothing is reported + assertThat(log.contains("unencrypted", Level.WARN)).isFalse(); + } + + @Test + void tokenIsReadFromTheConfiguredServerId() throws Exception { + Settings settings = settingsWithServer("my-platform", "from-settings"); + + assertThat(provider(null, settings, plainDecrypter(), "my-platform").getAccessToken()).isEqualTo("from-settings"); + // the default server id is not configured, so there is nothing to read + assertThat(provider(null, settings, plainDecrypter()).getAccessToken()).isNull(); + } + + @Test + void unencryptedTokenInTheSettingsIsUsedButReported() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, TOKEN); + + AccessTokenProvider provider = provider(null, settings, plainDecrypter()); + + assertThat(provider.getAccessToken()).isEqualTo(TOKEN); + log.assertContains("stored unencrypted", Level.WARN); + log.assertContains("mvn --encrypt-password", Level.WARN); + } + + @Test + void serverWithoutPasswordProvidesNoToken() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, null); + + AccessTokenProvider provider = provider(null, settings, plainDecrypter()); + + assertThat(provider.getAccessToken()).isNull(); + log.assertContains("has no ", Level.WARN); + } + + @Test + void noTokenAtAllResolvesToNull() throws Exception { + assertThat(provider(null, null, plainDecrypter()).getAccessToken()).isNull(); + assertThat(provider(null, new Settings(), plainDecrypter()).getAccessToken()).isNull(); + } + + /** + * A token that cannot be decrypted must fail the build rather than reach the platform in its encrypted form, where + * it would only produce an authentication error that says nothing about the actual problem. + */ + @Test + void failedDecryptionFailsWithTheEncryptedTokenKeptOut() throws Exception { + String encryptedToken = "{dummy-cipher-text}"; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, encryptedToken); + SettingsDecrypter failing = request -> new StubDecryptionResult(request.getServers().get(0), + List.of(new DefaultSettingsProblem("master password is not set", Severity.ERROR, "settings.xml", -1, -1, + null))); + + AccessTokenProvider provider = provider(null, settings, failing); + + assertThatThrownBy(provider::getAccessToken).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Unable to decrypt the personal access token in server 'timefold-platform'") + .hasMessageContaining("master password is not set") + .hasMessageContaining("~/.m2/settings-security.xml") + .hasMessageContaining("mvn --encrypt-password") + .hasMessageContaining(AccessTokenProvider.ENCRYPTION_GUIDE_URL) + .hasMessageNotContaining(encryptedToken); + } + + @Test + void decryptionWarningsAreReportedWithoutFailing() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, "{encrypted}"); + SettingsDecrypter warning = request -> { + Server decrypted = request.getServers().get(0).clone(); + decrypted.setPassword(TOKEN); + return new StubDecryptionResult(decrypted, List.of(new DefaultSettingsProblem("deprecated encryption", + Severity.WARNING, "settings.xml", -1, -1, null))); + }; + + assertThat(provider(null, settings, warning).getAccessToken()).isEqualTo(TOKEN); + log.assertContains("deprecated encryption", Level.WARN); + } + + /** + * Maven treats braces escaped as {@code \{} and {@code \}} as characters of the password and hands the value + * back untouched, so it is a token stored in clear text rather than one that failed to decrypt. + */ + @Test + void escapedBracesAreNotMistakenForCipherText() throws Exception { + String escapedToken = "dummy\\{abc\\}value"; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, escapedToken); + + // Maven leaves the escaping in place, so the token is used exactly as it was stored + assertThat(provider(null, settings, mavenDecrypter("the-master-password")).getAccessToken()) + .isEqualTo(escapedToken); + log.assertContains("stored unencrypted", Level.WARN); + } + + /** + * Maven allows a comment outside the braces, so a token stored that way is encrypted and must not be reported as + * if it were sitting there in clear text. + */ + @Test + void encryptedTokenWithACommentIsNotReportedAsUnencrypted() throws Exception { + String masterPassword = "the-master-password"; + SettingsDecrypter decrypter = mavenDecrypter(masterPassword); + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, + "rotate before 2026-12-01 " + new DefaultPlexusCipher().encryptAndDecorate(TOKEN, masterPassword)); + + assertThat(provider(null, settings, decrypter).getAccessToken()).isEqualTo(TOKEN); + assertThat(log.contains("unencrypted", Level.WARN)).isFalse(); + } + + /** + * The check for an undecrypted token compares against what was stored, rather than looking for curly braces in + * the result, so that a token that legitimately contains them is not rejected after being decrypted correctly. + */ + @Test + void decryptedTokenMayContainCurlyBraces() throws Exception { + String masterPassword = "the-master-password"; + String bracedToken = "dummy-{not}-cipher-text"; + SettingsDecrypter decrypter = mavenDecrypter(masterPassword); + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, + new DefaultPlexusCipher().encryptAndDecorate(bracedToken, masterPassword)); + + assertThat(provider(null, settings, decrypter).getAccessToken()).isEqualTo(bracedToken); + } + + /** + * Without a decrypter there is nothing that could turn the cipher text into a usable token, so it must not be + * handed on as if it were one. + */ + @Test + void encryptedTokenWithoutADecrypterFails() throws Exception { + String encryptedToken = "{dummy-cipher-text}"; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, encryptedToken); + + assertThatThrownBy(provider(null, settings, null)::getAccessToken).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("could not be decrypted") + .hasMessageContaining("still in its encrypted form") + .hasMessageContaining("mvn --encrypt-password") + .hasMessageNotContaining(encryptedToken); + } + + /** + * A build that configures a blank server id falls back to the default entry, so that is the id to report; naming + * the blank value would point at an entry that is never looked up. + */ + @ParameterizedTest + @NullSource + @ValueSource(strings = { "", " " }) + void aBlankServerIdReadsTheDefaultEntry(String configured) throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, TOKEN); + AccessTokenProvider provider = provider(null, settings, plainDecrypter(), configured); + + assertThat(provider.getServerId()).isEqualTo(AccessTokenProvider.DEFAULT_SERVER_ID); + assertThat(provider.getAccessToken()).isEqualTo(TOKEN); + } + + @Test + void aPaddedServerIdReadsTheEntryItNames() throws Exception { + Settings settings = settingsWithServer("my-platform", TOKEN); + AccessTokenProvider provider = provider(null, settings, plainDecrypter(), " my-platform "); + + assertThat(provider.getServerId()).isEqualTo("my-platform"); + assertThat(provider.getAccessToken()).isEqualTo(TOKEN); + } + + /** + * Whitespace around the stored token, which an XML element that holds it on a line of its own carries, must not + * decide whether it reads as cipher text, or the safeguard above would let the cipher text through. + */ + @Test + void whitespaceAroundAnEncryptedTokenDoesNotHideIt() throws Exception { + String encryptedToken = "{dummy-cipher-text}"; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, "\n " + encryptedToken + "\n "); + + assertThatThrownBy(provider(null, settings, null)::getAccessToken).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("could not be decrypted") + .hasMessageNotContaining(encryptedToken); + } + + /** + * The settings belong to the session that the rest of the build shares, so reading the token must leave them be. + */ + @Test + void readingTheTokenLeavesTheSettingsAlone() throws Exception { + String storedPassword = " " + TOKEN + " "; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, storedPassword); + + assertThat(provider(null, settings, plainDecrypter()).getAccessToken()).isEqualTo(TOKEN); + + assertThat(settings.getServer(AccessTokenProvider.DEFAULT_SERVER_ID).getPassword()).isEqualTo(storedPassword); + } + + /** + * A token that is not encrypted needs no decrypter, so the absence of one is not a failure in itself. + */ + @Test + void unencryptedTokenWithoutADecrypterIsStillUsable() throws Exception { + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, TOKEN); + + assertThat(provider(null, settings, null).getAccessToken()).isEqualTo(TOKEN); + log.assertContains("stored unencrypted", Level.WARN); + } + + /** + * A decrypter that reports neither a result nor a problem must not be taken as a successful decryption either. + */ + @Test + void decrypterThatLeavesTheTokenEncryptedFails() throws Exception { + String encryptedToken = "{dummy-cipher-text}"; + Settings settings = settingsWithServer(AccessTokenProvider.DEFAULT_SERVER_ID, encryptedToken); + SettingsDecrypter reportsNothing = request -> new StubDecryptionResult(null, List.of()); + + assertThatThrownBy(provider(null, settings, reportsNothing)::getAccessToken) + .isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("could not be decrypted") + .hasMessageNotContaining(encryptedToken); + } + + /** + * Reading a value as cipher text only means anything while it agrees with what Maven itself decides to decrypt, + * so it is held against Maven's own expression for every value that the characters involved can spell. + */ + @Test + void cipherTextIsRecognizedTheSameWayMavenDoes() throws Exception { + DefaultPlexusCipher maven = new DefaultPlexusCipher(); + + List disagreed = everyValueUpTo(6, '{', '}', '\\', 'a').stream() + .filter(value -> AccessTokenProvider.isEncrypted(value) != maven.isEncryptedString(value)) + .toList(); + + assertThat(disagreed).isEmpty(); + } + + /** + * Maven's expression lets a single line terminator sit right in front of the opening brace, which this check does + * not follow: it reads such a value as clear text, so that the token is used rather than rejected as one that + * failed to decrypt. + */ + @Test + void aValueHoldingALineTerminatorIsNeverReadAsCipherText() throws Exception { + DefaultPlexusCipher maven = new DefaultPlexusCipher(); + + assertThat(AccessTokenProvider.isEncrypted("{a}\n")).isFalse(); + assertThat(maven.isEncryptedString("{a}\n")).isFalse(); + assertThat(AccessTokenProvider.isEncrypted("{a\nb}")).isFalse(); + assertThat(maven.isEncryptedString("{a\nb}")).isFalse(); + + // the one value the two read differently, and only ever in the direction that keeps the token usable + assertThat(AccessTokenProvider.isEncrypted("\n{a}")).isFalse(); + assertThat(maven.isEncryptedString("\n{a}")).isTrue(); + } + + private static List everyValueUpTo(int maxLength, char... alphabet) { + List values = new ArrayList<>(List.of("")); + List shorter = List.of(""); + for (int length = 1; length <= maxLength; length++) { + List current = new ArrayList<>(); + for (String value : shorter) { + for (char character : alphabet) { + current.add(value + character); + } + } + values.addAll(current); + shorter = current; + } + return values; + } + + /** + * Mimics how Maven decrypts a server password: an encrypted master password in {@code settings-security.xml}, + * against which the stored token was encrypted. + */ + private SettingsDecrypter mavenDecrypter(String masterPassword) throws Exception { + DefaultPlexusCipher cipher = new DefaultPlexusCipher(); + Path securitySettings = tempDir.resolve("settings-security.xml"); + Files.writeString(securitySettings, """ + + %s + + """.formatted( + cipher.encryptAndDecorate(masterPassword, DefaultSecDispatcher.SYSTEM_PROPERTY_SEC_LOCATION))); + DefaultSecDispatcher secDispatcher = new DefaultSecDispatcher(cipher); + secDispatcher.setConfigurationFile(securitySettings.toString()); + return new DefaultSettingsDecrypter(secDispatcher); + } + + /** + * Stands in for a Maven that has nothing to decrypt, i.e. hands the stored value back unchanged. + */ + private static SettingsDecrypter plainDecrypter() { + return request -> new StubDecryptionResult(request.getServers().get(0), List.of()); + } + + private static Settings settingsWithServer(String serverId, String password) { + Server server = new Server(); + server.setId(serverId); + server.setPassword(password); + Settings settings = new Settings(); + settings.addServer(server); + return settings; + } + + private AccessTokenProvider provider(String environmentToken, Settings settings, SettingsDecrypter decrypter) { + return provider(environmentToken, settings, decrypter, AccessTokenProvider.DEFAULT_SERVER_ID); + } + + private AccessTokenProvider provider(String environmentToken, Settings settings, SettingsDecrypter decrypter, + String serverId) { + return new TestableAccessTokenProvider(settings, decrypter, serverId, log, environmentToken); + } + + private static final class TestableAccessTokenProvider extends AccessTokenProvider { + + private final String environmentToken; + + private TestableAccessTokenProvider(Settings settings, SettingsDecrypter settingsDecrypter, String serverId, + Log log, String environmentToken) { + super(settings, settingsDecrypter, serverId, log); + this.environmentToken = environmentToken; + } + + @Override + protected String readEnvironmentToken() { + return environmentToken; + } + } + + private record StubDecryptionResult(Server server, List problems) + implements + SettingsDecryptionResult { + + @Override + public Server getServer() { + return server; + } + + @Override + public List getServers() { + return server == null ? List.of() : List.of(server); + } + + @Override + public Proxy getProxy() { + return null; + } + + @Override + public List getProxies() { + return List.of(); + } + + @Override + public List getProblems() { + return problems; + } + } + +} diff --git a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/ConfigureMojoTest.java b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/ConfigureMojoTest.java index 5ab282a36b..22c5746c8e 100644 --- a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/ConfigureMojoTest.java +++ b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/ConfigureMojoTest.java @@ -28,6 +28,7 @@ import org.apache.maven.api.plugin.testing.MojoTest; import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Parent; +import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.testing.stubs.ArtifactStub; import org.apache.maven.project.MavenProject; @@ -98,6 +99,15 @@ void setUp() { .willReturn(aResponse() .withStatus(401))); + // a refusal the platform explains, as opposed to the bare 401 above + wm1.stubFor(get(urlPathEqualTo("/api/platform/v1/aboutme")) + .withHeader("Authorization", equalTo("Bearer expired")) + .atPriority(10) + .willReturn(aResponse() + .withStatus(401) + .withHeader("Content-Type", "application/json") + .withBody("{\"code\":\"TFP-10002\",\"message\":\"The personal access token has expired\"}"))); + wm1.stubFor(get(urlPathEqualTo("/api/platform/v1/aboutme")) .withHeader("Authorization", equalTo("Bearer noaccess")) .atPriority(5) @@ -298,12 +308,31 @@ public void testConfigureNotAuthorized(ConfigureMojo mojo) { mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) - .hasMessage("Platform authentication failed with 401 status code"); + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessage("Platform authentication failed with 401 status code: no error message reported by the platform"); wm1.verify(1, getRequestedFor(urlPathEqualTo("/api/platform/v1/aboutme"))); } + /** + * A refusal the platform explains has to carry that explanation, or the build only learns the status code. + */ + @Test + @InjectMojo(goal = "configure", pom = "src/test/resources/project-to-test/pom.xml") + public void testConfigureReportsWhyThePlatformRefusedTheToken(ConfigureMojo mojo) { + + session.getRequest().setGoals(List.of("timefold:deploy")); + setEnterpriseModel(mojo); + + mojo.setAccessTokenProvider(new TestAccessTokenProvider("expired")); + mojo.setLog(log); + mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); + + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessage("Platform authentication failed with 401 status code: " + + "The personal access token has expired"); + } + @Test @InjectMojo(goal = "configure", pom = "src/test/resources/project-to-test/pom.xml") public void testConfigureMissingAccessToken(ConfigureMojo mojo) { @@ -315,13 +344,35 @@ public void testConfigureMissingAccessToken(ConfigureMojo mojo) { mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalArgumentException.class) - .hasMessage( - "Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable"); + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Personal Access Token for Timefold Platform is required") + .hasMessageContaining("export TIMEFOLD_PAT=") + .hasMessageContaining("timefold-platform") + .hasMessageContaining("mvn --encrypt-password"); wm1.verify(0, getRequestedFor(urlPathEqualTo("/api/platform/v1/aboutme"))); } + /** + * Unlike deploy and undeploy, this goal reads the platform configuration even on a dry run, as it has to write + * the registry and account id the build would use, so it needs a token either way. + */ + @Test + @MojoParameter(name = "dryRun", value = "true") + @InjectMojo(goal = "configure", pom = "src/test/resources/project-to-test/pom.xml") + public void testConfigureNeedsAnAccessTokenEvenOnADryRun(ConfigureMojo mojo) throws Exception { + + session.getRequest().setGoals(List.of("timefold:deploy")); + setEnterpriseModel(mojo); + + mojo.setAccessTokenProvider(new TestAccessTokenProvider(null)); + mojo.setLog(log); + mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); + + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Personal Access Token for Timefold Platform is required"); + } + @Test @MojoParameter(name = "namespace", value = "company") @InjectMojo(goal = "configure", pom = "src/test/resources/project-to-test/pom.xml") diff --git a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/DeployModelMojoTest.java b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/DeployModelMojoTest.java index 613f3b4395..0754a749ed 100644 --- a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/DeployModelMojoTest.java +++ b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/DeployModelMojoTest.java @@ -25,6 +25,7 @@ import org.apache.maven.api.plugin.testing.InjectMojo; import org.apache.maven.api.plugin.testing.MojoParameter; import org.apache.maven.api.plugin.testing.MojoTest; +import org.apache.maven.plugin.MojoExecutionException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -153,16 +154,57 @@ void setUp() throws IOException { @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testSkipByParameter(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.execute(); // assert that plugin executed and produced expected logs log.assertContains("Model deployment skipped by configuration", Level.INFO); } + /** + * Deploying without a token has to say so, rather than let the platform answer the empty bearer token with an + * authentication error that reads as if the token were wrong. + */ + @Test + @MojoParameter(name = "descriptorOnly", value = "true") + @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") + public void testFailsWithoutAccessToken(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider(null)); + mojo.setLog(log); + mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); + + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Personal Access Token for Timefold Platform is required") + .hasMessageContaining("export TIMEFOLD_PAT=") + .hasMessageContaining("timefold-platform") + .hasMessageContaining("mvn --encrypt-password"); + + // the build fails before anything is sent, so the platform never sees an unauthenticated request + wm1.verify(0, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))); + } + + /** + * A dry run sends no request, so it must not insist on a token either. + */ + @Test + @MojoParameter(name = "descriptorOnly", value = "true") + @MojoParameter(name = "dryRun", value = "true") + @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") + public void testDryRunDoesNotNeedAnAccessToken(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider(null)); + mojo.setLog(log); + mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); + mojo.execute(); + + log.assertContains("DRY_RUN: Would perform POST on .*", Level.INFO); + wm1.verify(0, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))); + } + @Test @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testRegisterModel(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); mojo.execute(); @@ -183,6 +225,7 @@ public void testRegisterModel(DeployModelMojo mojo) throws Exception { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testRegisterModelWithPatch(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); mojo.execute(); @@ -203,6 +246,7 @@ public void testRegisterModelWithPatch(DeployModelMojo mojo) throws Exception { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testRegisterModelWithPatchOnModelVersionConflict(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); mojo.execute(); @@ -222,10 +266,11 @@ public void testRegisterModelWithPatchOnModelVersionConflict(DeployModelMojo moj @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailWithoutPatchOnModelIdConflict(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).rootCause().isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) .hasMessage( "Model deployment of timefold-test-model_v2-beta failed due to conflict (TFP-14004) that cannot be resolved by updating the registration with key existing-model-id: Existing private model (model_v1), already exists for tenants"); @@ -244,10 +289,11 @@ public void testFailWithoutPatchOnModelIdConflict(DeployModelMojo mojo) { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailWithoutPatchOnUnidentifiedConflict(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).rootCause().isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) .hasMessage( "Model deployment of timefold-test-model_v2-beta failed due to conflict (TFP-99999) that cannot be resolved by updating the registration with key unknown-conflict: Registered model conflicts with existing model (model_v1)"); @@ -266,11 +312,12 @@ public void testFailWithoutPatchOnUnidentifiedConflict(DeployModelMojo mojo) { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailOnUpdateReportsPlatformError(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); // the reason reported by the platform is part of the failure and not only of the build log - assertThatThrownBy(mojo::execute).rootCause().isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) .hasMessage( "Model deployment (override) failed with 404 status code: Model with registration key 'failing-update' was not found"); @@ -285,11 +332,12 @@ public void testFailOnUpdateReportsPlatformError(DeployModelMojo mojo) { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailWithoutPatchOnMalformedConflictBody(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); // a body that cannot be read as JSON reports no error code, so the conflict is not resolvable - assertThatThrownBy(mojo::execute).rootCause().isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) .hasMessage( "Model deployment of timefold-test-model_v2-beta failed due to conflict (TFP-99999) that cannot be resolved by updating the registration with key malformed-conflict: Conflict"); @@ -308,11 +356,12 @@ public void testFailWithoutPatchOnMalformedConflictBody(DeployModelMojo mojo) { @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailWithoutPatchOnEmptyConflictBody(DeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); // a conflict without a body reports no error code, so the conflict is not resolvable - assertThatThrownBy(mojo::execute).rootCause().isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) .hasMessage( "Model deployment of timefold-test-model_v2-beta failed due to conflict (TFP-99999) that cannot be resolved by updating the registration with key empty-conflict: no error message reported by the platform"); @@ -342,6 +391,7 @@ public void testFailOnMissingModelDescriptor(DeployModelMojo mojo) throws Except @MojoParameter(name = "descriptorOnly", value = "true") @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom-shared.xml") public void testRegisterModelSharedType(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); mojo.execute(); @@ -359,6 +409,7 @@ public void testRegisterModelSharedType(DeployModelMojo mojo) throws Exception { @Test @InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml") public void testFailOnIncompleteDeploy(DeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); diff --git a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/PermissionsMojoTest.java b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/PermissionsMojoTest.java index 9412bcf95c..46bc806025 100644 --- a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/PermissionsMojoTest.java +++ b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/PermissionsMojoTest.java @@ -13,6 +13,7 @@ import org.apache.maven.api.plugin.testing.InjectMojo; import org.apache.maven.api.plugin.testing.MojoTest; +import org.apache.maven.plugin.MojoExecutionException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -112,8 +113,8 @@ void testFailsWhenNotAuthorized(PermissionsMojo mojo) { mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) - .hasMessage("Platform authentication failed with 401 status code"); + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessage("Platform authentication failed with 401 status code: no error message reported by the platform"); wm1.verify(1, getRequestedFor(urlPathEqualTo("/api/platform/v1/aboutme"))); } @@ -125,9 +126,11 @@ void testFailsWhenAccessTokenMissing(PermissionsMojo mojo) { mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalArgumentException.class) - .hasMessage( - "Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable"); + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Personal Access Token for Timefold Platform is required") + .hasMessageContaining("export TIMEFOLD_PAT=") + .hasMessageContaining("timefold-platform") + .hasMessageContaining("mvn --encrypt-password"); wm1.verify(0, getRequestedFor(urlPathEqualTo("/api/platform/v1/aboutme"))); } diff --git a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/UndeployModelMojoTest.java b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/UndeployModelMojoTest.java index 7511f29319..83972f88fa 100644 --- a/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/UndeployModelMojoTest.java +++ b/service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/UndeployModelMojoTest.java @@ -20,6 +20,7 @@ import org.apache.maven.api.plugin.testing.InjectMojo; import org.apache.maven.api.plugin.testing.MojoParameter; import org.apache.maven.api.plugin.testing.MojoTest; +import org.apache.maven.plugin.MojoExecutionException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -71,17 +72,41 @@ void setUp() throws IOException { @InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml") public void testSkipByParameter(UndeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.execute(); // assert that plugin executed and produced expected logs log.assertContains("Model undeployment skipped by configuration", Level.INFO); } + /** + * Undeploying without a token has to say so, rather than let the platform answer the empty bearer token with an + * authentication error that reads as if the token were wrong. + */ + @Test + @MojoParameter(name = "key", value = "existing") + @InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml") + public void testFailsWithoutAccessToken(UndeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider(null)); + mojo.setLog(log); + mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); + + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) + .hasMessageContaining("Personal Access Token for Timefold Platform is required") + .hasMessageContaining("export TIMEFOLD_PAT=") + .hasMessageContaining("timefold-platform") + .hasMessageContaining("mvn --encrypt-password"); + + // the build fails before anything is sent, so the platform never sees an unauthenticated request + wm1.verify(0, deleteRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing"))); + } + @Test @MojoParameter(name = "key", value = "existing") @InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml") public void testUndeploy(UndeployModelMojo mojo) throws Exception { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); mojo.execute(); @@ -98,10 +123,11 @@ public void testUndeploy(UndeployModelMojo mojo) throws Exception { @InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml") public void testUndeployNotExisting(UndeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); // the reason reported by the platform is part of the failure and not only of the build log - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) .hasMessage( "Model undeploy failed with 404 status code: Model with registration key 'notexisting' was not found"); @@ -116,9 +142,10 @@ public void testUndeployNotExisting(UndeployModelMojo mojo) { @InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml") public void testUndeployFailureWithoutDetails(UndeployModelMojo mojo) { + mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx")); mojo.setLog(log); mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl(); - assertThatThrownBy(mojo::execute).isInstanceOf(IllegalStateException.class) + assertThatThrownBy(mojo::execute).isInstanceOf(MojoExecutionException.class) .hasMessage("Model undeploy failed with 500 status code: no error message reported by the platform"); wm1.verify(1, deleteRequestedFor(urlPathEqualTo("/api/platform/v1/models/nodetails")));