From 2037dd7d1f52b8f41962a51b7313c3e9c548eeaf Mon Sep 17 00:00:00 2001 From: Jake Huneau Date: Tue, 8 Sep 2026 22:04:23 +0000 Subject: [PATCH] Add PATCH endpoints so objects can be updated without being deleted There was no update operation anywhere: "add" refuses to overwrite an existing entry, so the only way to change a partner or a partnership was to delete it and recreate it. That loses the definition outright if the recreate then fails, and it is what makes an editing UI destructive by construction. Adds "update" commands for partner and partnership that merge only what is supplied and leave everything else alone, implemented on both the XML and the database partnership store, and a PATCH API endpoint that exposes them: PATCH /api/partner/ PATCH /api/partnership/ PATCH /api/cert/ with the attributes to change as form fields. A partnership also accepts pollerConfig. to change one poller attribute, and sender.name or receiver.name to point it at a different partner. A certificate PATCH takes either a base64 certificate in "data", which replaces a partner certificate, or a base64 PKCS12 in "data" plus the password that opens it in "password", which replaces a certificate and its private key together so an identity of our own can be rotated. Replacing a key pair did not work before. importPrivateKey staged the certificate with setCertificateEntry, which a keystore refuses on an alias that already holds a private key because it would orphan the key, so the import failed for exactly the alias anyone would want to rotate. The key entry is now written in a single operation instead, which replaces whatever the alias held, so there is no window where the alias has a certificate but no key. The previous key pair is put back if the write fails, leaving the running server able to keep signing with the identity it already had. The entry is stored under the keystore password rather than the password of the file the key came from, because that is what getPrivateKey reads it back with. This fixes the console "cert import " path as well. Neither a partner nor a partnership can be renamed by an update. Other records reference them by name, so a rename would silently orphan them; the error says to add the replacement and delete the old one instead. An unknown partner or partnership is returned as a command error rather than raised, so an API caller gets the reason back instead of a server error, which matches how the delete command already behaves. Also fixes the neighbouring verbs, which were unusable: PUT and DELETE dropped the first character of the item name because they pass the ID to a method that expects it to still carry the leading path separator, and PUT and HEAD bound a path parameter named "param" that is not in their path template, so the resource name always arrived null. --- RELEASE-NOTES.md | 17 + Server/src/config/commands.xml | 2 + .../app/partner/UpdatePartnerCommand.java | 76 ++++ .../app/partner/UpdatePartnershipCommand.java | 102 +++++ .../openas2/cert/X509CertificateFactory.java | 81 +++- .../cmd/processor/restapi/ApiResource.java | 106 ++++- .../openas2/partner/DbPartnershipFactory.java | 132 ++++++ .../partner/XMLPartnershipFactory.java | 130 ++++++ .../java/org/openas2/app/PatchApiTest.java | 298 +++++++++++++ .../app/partner/UpdateCommandsTest.java | 418 ++++++++++++++++++ changes.txt | 17 + 11 files changed, 1373 insertions(+), 6 deletions(-) create mode 100644 Server/src/main/java/org/openas2/app/partner/UpdatePartnerCommand.java create mode 100644 Server/src/main/java/org/openas2/app/partner/UpdatePartnershipCommand.java create mode 100644 Server/src/test/java/org/openas2/app/PatchApiTest.java create mode 100644 Server/src/test/java/org/openas2/app/partner/UpdateCommandsTest.java diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 93aa9ab2..010ddf8b 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -20,6 +20,23 @@ This is a minor enhancement release. unresolved so placeholders such as $properties.storageBaseDir$ keep working. Pass --dry-run to validate the file and see what would be written without touching the database, or --replace to overwrite partnerships already stored there. The whole migration runs in one transaction, so a file that cannot be represented in the schema leaves it untouched. +2. Add partial update support so a partner, partnership or certificate no longer has to be deleted and recreated to change + it. Previously "add" refused to overwrite an existing entry, so the only route was delete followed by recreate, which + loses the definition outright if the recreate fails. New "update" commands for partner and partnership merge only what + is supplied and leave everything else alone, working against both the XML and the database partnership store, and a new + PATCH API endpoint exposes them: PATCH /api/partner/, PATCH /api/partnership/ and PATCH /api/cert/ + with the attributes to change as form fields. Use pollerConfig. to change a partnership poller attribute and + sender.name or receiver.name to point a partnership at a different partner. For a certificate, send the base64 encoded + certificate in the "data" field to replace a partner certificate, or a base64 encoded PKCS12 in "data" plus the password + that opens it in "password" to replace a certificate and its private key together, which is what rotating an identity of + your own needs. +3. Fix the PUT and DELETE API endpoints, which dropped the first character of the item name, and PUT and HEAD, which bound + a path parameter that was not in their path template and so never received the resource name. +4. Fix replacing the key pair held under a keystore alias. Importing a PKCS12 over an alias that already held a private key + failed because the certificate was staged with setCertificateEntry first, which a keystore refuses on such an alias. The + key entry is now written in one operation, so "cert import " and the certificate PATCH can + both rotate an existing identity. The previous key pair is left in place unless the new one is written successfully, and + the entry is stored under the keystore password so it can be read back. Version 4.11.0 - 2026-09-02 =========================== diff --git a/Server/src/config/commands.xml b/Server/src/config/commands.xml index 9c5f9df8..199797cc 100644 --- a/Server/src/config/commands.xml +++ b/Server/src/config/commands.xml @@ -12,6 +12,7 @@ description="Partner commands"> + @@ -21,6 +22,7 @@ + diff --git a/Server/src/main/java/org/openas2/app/partner/UpdatePartnerCommand.java b/Server/src/main/java/org/openas2/app/partner/UpdatePartnerCommand.java new file mode 100644 index 00000000..c33be475 --- /dev/null +++ b/Server/src/main/java/org/openas2/app/partner/UpdatePartnerCommand.java @@ -0,0 +1,76 @@ +package org.openas2.app.partner; + +import org.openas2.OpenAS2Exception; +import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; +import org.openas2.partner.Partnership; +import org.openas2.partner.PartnershipFactory; +import org.openas2.partner.XMLPartnershipFactory; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Merges attributes into an existing partner without deleting and recreating it. + *

+ * Only the attributes supplied are changed, so this is a partial update. It exists because "add" + * refuses to overwrite an existing entry, which previously left callers no way to change a partner + * except to delete it first and lose the definition if the recreate then failed. + */ +public class UpdatePartnerCommand extends AliasedPartnershipsCommand { + public String getDefaultDescription() { + return "Update attributes of an existing partner, leaving the ones not supplied unchanged."; + } + + public String getDefaultName() { + return "update"; + } + + public String getDefaultUsage() { + return "update [attribute-2=value-2] ... [attribute-n=value-n]"; + } + + public CommandResult execute(PartnershipFactory partFx, Object[] params) throws OpenAS2Exception { + if (params.length < 2) { + return new CommandResult(CommandResult.TYPE_INVALID_PARAM_COUNT, getUsage()); + } + + synchronized (partFx) { + String name = params[0].toString(); + Map attributes = new LinkedHashMap(); + for (int i = 1; i < params.length; i++) { + String param = params[i].toString(); + int equalsPos = param.indexOf('='); + if (equalsPos == 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name"); + } else if (equalsPos < 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value"); + } + String attrName = param.substring(0, equalsPos); + if (Partnership.PID_NAME.equals(attrName)) { + // The name identifies the partner and the partnerships reference it, so changing + // it here would silently orphan them + return new CommandResult(CommandResult.TYPE_ERROR, + "A partner cannot be renamed by an update. Add the new partner, repoint the partnerships, then delete the old one."); + } + attributes.put(attrName, param.substring(equalsPos + 1)); + } + + if (!(partFx instanceof DbPartnershipFactory) && !(partFx instanceof XMLPartnershipFactory)) { + return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store"); + } + try { + if (partFx instanceof DbPartnershipFactory) { + ((DbPartnershipFactory) partFx).updatePartner(name, attributes); + } else { + ((XMLPartnershipFactory) partFx).updatePartner(name, attributes); + } + } catch (OpenAS2Exception e) { + // Report an unknown partner as a command error so an API caller gets the reason back + // rather than a server error, matching how the delete command behaves + return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage()); + } + return new CommandResult(CommandResult.TYPE_OK, "Updated partner: " + name); + } + } +} diff --git a/Server/src/main/java/org/openas2/app/partner/UpdatePartnershipCommand.java b/Server/src/main/java/org/openas2/app/partner/UpdatePartnershipCommand.java new file mode 100644 index 00000000..b580c55a --- /dev/null +++ b/Server/src/main/java/org/openas2/app/partner/UpdatePartnershipCommand.java @@ -0,0 +1,102 @@ +package org.openas2.app.partner; + +import org.openas2.OpenAS2Exception; +import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; +import org.openas2.partner.Partnership; +import org.openas2.partner.PartnershipFactory; +import org.openas2.partner.XMLPartnershipFactory; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Merges attributes, poller configuration and partner references into an existing partnership + * without deleting and recreating it. + *

+ * Only what is supplied is changed, so this is a partial update. It exists because "add" refuses to + * overwrite an existing entry, which previously left callers no way to change a partnership except + * to delete it first and lose the definition if the recreate then failed. + */ +public class UpdatePartnershipCommand extends AliasedPartnershipsCommand { + + private static final String POLLER_CONFIG_PREFIX = Partnership.PCFG_POLLER + "."; + private static final String SENDER_NAME_PARAM = Partnership.PTYPE_SENDER + "." + Partnership.PID_NAME; + private static final String RECEIVER_NAME_PARAM = Partnership.PTYPE_RECEIVER + "." + Partnership.PID_NAME; + + public String getDefaultDescription() { + return "Update an existing partnership, leaving anything not supplied unchanged."; + } + + public String getDefaultName() { + return "update"; + } + + public String getDefaultUsage() { + return "update [attribute-1=value-1] ... [attribute-n=value-n] [pollerConfig.attr=value ...]" + + " [" + SENDER_NAME_PARAM + "=] [" + RECEIVER_NAME_PARAM + "=]\n" + + "\t- subject=\"New subject\" replaces the value of the existing subject attribute or adds it\n" + + "\t- pollerConfig.interval=30 replaces that attribute of the pollerConfig element or adds it\n" + + "\t- " + SENDER_NAME_PARAM + "=PartnerB points the partnership at a different partner"; + } + + public CommandResult execute(PartnershipFactory partFx, Object[] params) throws OpenAS2Exception { + if (params.length < 2) { + return new CommandResult(CommandResult.TYPE_INVALID_PARAM_COUNT, getUsage()); + } + + synchronized (partFx) { + String name = params[0].toString(); + Map attributes = new LinkedHashMap(); + Map pollerConfig = new LinkedHashMap(); + String senderName = null; + String receiverName = null; + + for (int i = 1; i < params.length; i++) { + String param = params[i].toString(); + int equalsPos = param.indexOf('='); + if (equalsPos == 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name"); + } else if (equalsPos < 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value"); + } + String key = param.substring(0, equalsPos); + String value = param.substring(equalsPos + 1); + if (SENDER_NAME_PARAM.equals(key)) { + senderName = value; + } else if (RECEIVER_NAME_PARAM.equals(key)) { + receiverName = value; + } else if (key.startsWith(POLLER_CONFIG_PREFIX)) { + pollerConfig.put(key.substring(POLLER_CONFIG_PREFIX.length()), value); + } else if (Partnership.PID_NAME.equals(key)) { + // The name identifies the partnership, so changing it here would leave callers + // unable to tell which definition they are now looking at + return new CommandResult(CommandResult.TYPE_ERROR, + "A partnership cannot be renamed by an update. Add the new partnership then delete the old one."); + } else { + attributes.put(key, value); + } + } + + if (attributes.isEmpty() && pollerConfig.isEmpty() && senderName == null && receiverName == null) { + return new CommandResult(CommandResult.TYPE_ERROR, "Nothing to update.\n" + getUsage()); + } + + if (!(partFx instanceof DbPartnershipFactory) && !(partFx instanceof XMLPartnershipFactory)) { + return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store"); + } + try { + if (partFx instanceof DbPartnershipFactory) { + ((DbPartnershipFactory) partFx).updatePartnership(name, attributes, pollerConfig, senderName, receiverName); + } else { + ((XMLPartnershipFactory) partFx).updatePartnership(name, attributes, pollerConfig, senderName, receiverName); + } + } catch (OpenAS2Exception e) { + // Report an unknown partnership or partner as a command error so an API caller gets + // the reason back rather than a server error, matching how the delete command behaves + return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage()); + } + return new CommandResult(CommandResult.TYPE_OK, "Updated partnership: " + name); + } + } +} diff --git a/Server/src/main/java/org/openas2/cert/X509CertificateFactory.java b/Server/src/main/java/org/openas2/cert/X509CertificateFactory.java index cc764771..9a697f87 100644 --- a/Server/src/main/java/org/openas2/cert/X509CertificateFactory.java +++ b/Server/src/main/java/org/openas2/cert/X509CertificateFactory.java @@ -228,15 +228,92 @@ public boolean importPrivateKey(String alias, KeyStore ks, String password) thro String certAlias = aliases.nextElement(); Certificate cert = ks.getCertificate(certAlias); if (cert instanceof X509Certificate) { - addCertificate(alias, (X509Certificate) cert, true); Key certKey = ks.getKey(certAlias, password.toCharArray()); - addPrivateKey(alias, certKey, password); + if (certKey == null) { + // A certificate with no key in the source: not what this method is importing + continue; + } + Certificate[] chain = ks.getCertificateChain(certAlias); + if (chain == null || chain.length == 0) { + chain = selfSignedChain((X509Certificate) cert, alias); + } + setPrivateKeyEntry(alias, certKey, chain); return true; } } return false; } + /** + * Replaces the certificate and private key held under an alias in one operation. + *

+ * This is what makes it possible to rotate an identity: the alias for a key pair of our own + * already holds a private key, and {@link java.security.KeyStore#setCertificateEntry} refuses to + * touch such an alias because replacing only the certificate would orphan the key. Setting the + * key entry replaces whatever the alias held, so the previous entry stays in place until the new + * one is written and there is no window where the alias has a certificate but no key. + *

+ * The entry is protected with the keystore password rather than the password of the file the key + * came from, because that is what {@link #getPrivateKey(String)} reads it back with. + * + * @param alias - the alias to write the entry to, whether or not it already exists + * @param key - the private key to store + * @param chain - the certificate chain for the key, leaf first + * @throws OpenAS2Exception if the entry could not be written + */ + private void setPrivateKeyEntry(String alias, Key key, Certificate[] chain) throws OpenAS2Exception { + KeyStore ks = getKeyStore(); + Key previousKey = null; + Certificate[] previousChain = null; + try { + if (ks.containsAlias(alias)) { + previousChain = ks.getCertificateChain(alias); + try { + previousKey = ks.getKey(alias, getPassword()); + } catch (GeneralSecurityException e) { + // Nothing recoverable to keep, so there is nothing to put back on failure + previousKey = null; + } + } + ks.setKeyEntry(alias, key, getPassword(), chain); + save(getFilename(), getPassword()); + } catch (Exception e) { + /* + * Put the previous key pair back so a failed rotation leaves the running server able to + * keep signing with the identity it already had. + */ + if (previousKey != null && previousChain != null) { + try { + ks.setKeyEntry(alias, previousKey, getPassword(), previousChain); + } catch (Exception restoreFailure) { + logger.error("Failed to restore the previous key entry for alias " + alias + + " after the replacement failed. The keystore may need to be restored from a backup.", restoreFailure); + } + } + if (e instanceof OpenAS2Exception) { + throw (OpenAS2Exception) e; + } + throw new WrappedException(e); + } + } + + /** + * A key entry needs a chain, so a self-signed certificate that arrives without one stands as its + * own chain. The certificate is repeated to keep the stored chain the same shape as the one the + * two step import produced before this method existed. + */ + private Certificate[] selfSignedChain(X509Certificate cert, String alias) throws OpenAS2Exception { + if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) { + throw new OpenAS2Exception("No certificate chain was supplied for alias " + alias + + " and the certificate is not self-signed, so the chain cannot be established." + + " Import a keystore that contains the full chain for the key."); + } + if (logger.isInfoEnabled()) { + logger.info("Detected self-signed certificate and allowed import. Alias: " + alias); + } + return new X509Certificate[]{cert, cert}; + } + public void clearCertificates() throws OpenAS2Exception { KeyStore ks = getKeyStore(); diff --git a/Server/src/main/java/org/openas2/cmd/processor/restapi/ApiResource.java b/Server/src/main/java/org/openas2/cmd/processor/restapi/ApiResource.java index 40074c71..e2bc5823 100644 --- a/Server/src/main/java/org/openas2/cmd/processor/restapi/ApiResource.java +++ b/Server/src/main/java/org/openas2/cmd/processor/restapi/ApiResource.java @@ -14,6 +14,7 @@ import org.openas2.processor.ProcessorModule; import org.openas2.processor.msgtracking.DbTrackingModule; import org.openas2.processor.msgtracking.TrackingModule; +import org.openas2.util.AS2Util; import jakarta.annotation.security.RolesAllowed; import jakarta.ws.rs.Consumes; @@ -25,6 +26,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.DELETE; @@ -41,6 +43,7 @@ import jakarta.ws.rs.core.UriInfo; import java.io.ByteArrayInputStream; import java.io.File; +import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -199,13 +202,77 @@ public Response postCommand(@PathParam("resource") String resource, @PathParam(" } } + /** + * Partially updates one object: only what is supplied changes and anything omitted is left alone. + *

+ * For a partner or a partnership this runs the "update" command, which exists so a caller does + * not have to delete and recreate an entry to change it. For a certificate it runs the same + * import the POST endpoint uses, because importing already replaces the certificate held under + * an alias in place. + * + * @param resource - "partner", "partnership" or "cert" + * @param itemId - the name of the partner or partnership, or the certificate alias + * @param formParams - the attributes to set, or for a certificate the "data" field + * @return the command result as JSON + */ + @RolesAllowed({"ADMIN"}) + @PATCH + @Path("/{resource}/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response patchCommand(@PathParam("resource") String resource, @PathParam("id") String itemId, MultivaluedMap formParams) throws Exception { + try { + if (itemId == null || itemId.trim().length() == 0) { + CommandResult error = new CommandResult(CommandResult.TYPE_ERROR, "The name of the item to update must be supplied."); + return Response.status(400).entity(this.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(error)) + .type(MediaType.APPLICATION_JSON).build(); + } + CommandResult output; + if ("cert".equalsIgnoreCase(resource)) { + // Importing a certificate already overwrites the alias, so there is no separate + // update command for one + try { + String keyStorePassword = formParams == null ? null : formParams.getFirst("password"); + if (keyStorePassword != null && keyStorePassword.length() > 0) { + // A password means the payload is a keystore holding a key pair, so the + // certificate and its private key are replaced together + output = this.importKeyPairByStream(itemId, formParams, keyStorePassword); + } else { + output = this.importCertificateByStream(itemId, formParams); + } + } catch (Exception e) { + /* + * The keystore refuses to replace the certificate of an alias that holds a + * private key, since that would orphan the key. Report it to the caller instead + * of failing the request. + */ + LoggerFactory.getLogger(ApiResource.class.getName()).error("Failed to replace the certificate for alias " + itemId, e); + output = new CommandResult(CommandResult.TYPE_ERROR, + "Could not replace the certificate for alias \"" + itemId + "\": " + e.getMessage()); + } + } else { + /* + * processRequest expects the item ID with the leading path separator still on it + * because the generic endpoints capture it that way, so put one back before handing + * the bare ID from this endpoint's path over. + */ + output = processRequest(resource, "update", "/" + itemId, formParams); + } + String jsonResult = this.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(output); + return Response.status(200).entity(jsonResult).type(MediaType.APPLICATION_JSON).build(); + } catch (Exception ex) { + LoggerFactory.getLogger(ApiResource.class.getName()).error(ex.getMessage(), ex); + throw ex; + } + } + @RolesAllowed({"ADMIN"}) @PUT @Path("/{resource}/{id}") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response putCommand(@PathParam("param") String resource, @PathParam("id") String itemId, MultivaluedMap formParams) throws Exception { - return postCommand(resource, "add", itemId, formParams); + public Response putCommand(@PathParam("resource") String resource, @PathParam("id") String itemId, MultivaluedMap formParams) throws Exception { + return postCommand(resource, "add", "/" + itemId, formParams); } @RolesAllowed({"ADMIN"}) @@ -213,13 +280,13 @@ public Response putCommand(@PathParam("param") String resource, @PathParam("id") @Path("/{resource}/{id}") @Produces(MediaType.APPLICATION_JSON) public Response deleteCommand(@PathParam("resource") String resource, @PathParam("id") String itemId) throws Exception { - return getCommand(resource, "delete", itemId); + return getCommand(resource, "delete", "/" + itemId); } @RolesAllowed({"ADMIN"}) @HEAD @Path("/{resource}{action:(/[^/]+?)?}{id:(/[^/]+?)?}") - public Response headCommand(@PathParam("param") String command) { + public Response headCommand(@PathParam("resource") String resource) { // Just an Empty response return Response.status(200).build(); } @@ -316,6 +383,37 @@ private Response errorResponse(int status, String message) throws Exception { .build(); } + /** + * Replaces the certificate and private key held under an alias from a PKCS12 keystore supplied as + * base64 in the "data" field, with the password that opens it in the "password" field. + *

+ * This is the path for rotating an identity of our own: the alias already holds a private key, and + * replacing only the certificate is refused because it would orphan the key. The previous key pair + * stays in place unless the new one is written successfully. + * + * @param alias - the keystore alias to write the key pair to + * @param formParams - carries "data", the base64 encoded PKCS12 + * @param keyStorePassword - the password that opens the supplied PKCS12 + * @return the command result to return to the caller + */ + private CommandResult importKeyPairByStream(String alias, MultivaluedMap formParams, String keyStorePassword) throws Exception { + String payload = formParams.getFirst("data"); + if (payload == null || payload.length() == 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "No \"data\" field holding a base64 encoded PKCS12 keystore was supplied."); + } + AliasedCertificateFactory certFx = (AliasedCertificateFactory) getProcessor().getSession() + .getCertificateFactory(CertificateFactory.COMPID_AS2_CERTIFICATE_FACTORY); + KeyStore sourceKeyStore; + try (ByteArrayInputStream bais = new ByteArrayInputStream(Base64.getDecoder().decode(payload))) { + sourceKeyStore = AS2Util.getCryptoHelper().loadKeyStore(bais, keyStorePassword.toCharArray()); + } + if (!certFx.importPrivateKey(alias, sourceKeyStore, keyStorePassword)) { + return new CommandResult(CommandResult.TYPE_ERROR, + "The supplied keystore holds no certificate with a private key, so there is nothing to replace the key pair with."); + } + return new CommandResult(CommandResult.TYPE_OK, "Replaced the certificate and private key for alias: " + alias); + } + private CommandResult importCertificateByStream(String itemId, MultivaluedMap formParams) throws Exception { try { List params = new ArrayList(); diff --git a/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java index 4cdb629d..155ddb1a 100644 --- a/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java +++ b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java @@ -366,6 +366,138 @@ public synchronized void addPartner(Map attributes) throws OpenA * @param name - the name of the partner to delete * @throws OpenAS2Exception if the partner does not exist or is referenced by a partnership */ + /** + * Merges attributes into an existing partner. Attributes that are not supplied are left as they + * are, so this is a partial update rather than a replacement. + * + * @param name - the name of the partner to update. Partners are not renamed by this method + * @param attributes - the attributes to set, replacing the value of any that already exist + * @throws OpenAS2Exception if the partner does not exist + */ + public synchronized void updatePartner(String name, Map attributes) throws OpenAS2Exception { + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + Long id = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name); + if (id == null) { + throw new OpenAS2Exception("Unknown partner name: " + name); + } + for (Map.Entry attribute : attributes.entrySet()) { + upsertPartnerAttribute(conn, id, attribute.getKey(), attribute.getValue()); + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + /** + * Merges attributes, poller configuration and partner references into an existing partnership. + * Anything not supplied is left as it is, so this is a partial update rather than a replacement. + * + * @param name - the name of the partnership to update. Partnerships are not renamed by this method + * @param attributes - partnership attributes to set, or null to leave them alone + * @param pollerConfig - poller configuration attributes to set, or null to leave them alone + * @param senderName - the partner to point the sender at, or null to leave it alone + * @param receiverName - the partner to point the receiver at, or null to leave it alone + * @throws OpenAS2Exception if the partnership, or a partner it is being pointed at, does not exist + */ + public synchronized void updatePartnership(String name, Map attributes, Map pollerConfig, String senderName, String receiverName) throws OpenAS2Exception { + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + Long id = findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name); + if (id == null) { + throw new OpenAS2Exception("Partnership not found: " + name); + } + if (senderName != null) { + repointPartnership(conn, id, name, "SENDER_PARTNER_ID", Partnership.PTYPE_SENDER, senderName); + } + if (receiverName != null) { + repointPartnership(conn, id, name, "RECEIVER_PARTNER_ID", Partnership.PTYPE_RECEIVER, receiverName); + } + if (attributes != null) { + for (Map.Entry attribute : attributes.entrySet()) { + upsertPartnershipAttribute(conn, id, CATEGORY_ATTRIBUTE, attribute.getKey(), attribute.getValue()); + } + } + if (pollerConfig != null) { + for (Map.Entry attribute : pollerConfig.entrySet()) { + upsertPartnershipAttribute(conn, id, CATEGORY_POLLER_CONFIG, attribute.getKey(), attribute.getValue()); + } + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + private void repointPartnership(Connection conn, long partnershipId, String partnershipName, String column, String partnerType, String partnerName) throws Exception { + Long partnerId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", partnerName); + if (partnerId == null) { + throw new OpenAS2Exception("Partnership " + partnershipName + " has an undefined " + partnerType + ": " + partnerName); + } + try (PreparedStatement s = conn.prepareStatement("UPDATE partnership SET " + column + " = ? WHERE ID = ?")) { + s.setLong(1, partnerId); + s.setLong(2, partnershipId); + s.executeUpdate(); + } + } + + private void upsertPartnerAttribute(Connection conn, long partnerId, String attrName, String attrValue) throws Exception { + try (PreparedStatement s = conn.prepareStatement( + "UPDATE partner_attribute SET ATTRIBUTE_VALUE = ? WHERE PARTNER_ID = ? AND ATTRIBUTE_NAME = ?")) { + s.setString(1, attrValue); + s.setLong(2, partnerId); + s.setString(3, attrName); + if (s.executeUpdate() > 0) { + return; + } + } + try (PreparedStatement s = conn.prepareStatement( + "INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?)")) { + s.setLong(1, partnerId); + s.setString(2, attrName); + s.setString(3, attrValue); + s.executeUpdate(); + } + } + + private void upsertPartnershipAttribute(Connection conn, long partnershipId, String category, String attrName, String attrValue) throws Exception { + try (PreparedStatement s = conn.prepareStatement( + "UPDATE partnership_attribute SET ATTRIBUTE_VALUE = ? WHERE PARTNERSHIP_ID = ? AND CATEGORY = ? AND ATTRIBUTE_NAME = ?")) { + s.setString(1, attrValue); + s.setLong(2, partnershipId); + s.setString(3, category); + s.setString(4, attrName); + if (s.executeUpdate() > 0) { + return; + } + } + try (PreparedStatement s = conn.prepareStatement( + "INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?, ?)")) { + s.setLong(1, partnershipId); + s.setString(2, category); + s.setString(3, attrName); + s.setString(4, attrValue); + s.executeUpdate(); + } + } + public synchronized void deletePartner(String name) throws OpenAS2Exception { try (Connection conn = dbHandler.getConnection()) { conn.setAutoCommit(false); diff --git a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java index 4f246ca2..265ee1d4 100644 --- a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java +++ b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java @@ -355,6 +355,136 @@ public void setupPartnershipPoller(Node node, Partnership partnership) throws Op } } + /** + * Merges attributes into an existing partner element and reloads the in-memory partnerships so + * they match. Attributes that are not supplied are left as they are, so this is a partial update + * rather than a replacement. The caller is responsible for persisting the document afterwards. + * + * @param name - the name of the partner to update. Partners are not renamed by this method + * @param attributes - the attributes to set, replacing the value of any that already exist + * @throws OpenAS2Exception if the partner does not exist + */ + public void updatePartner(String name, Map attributes) throws OpenAS2Exception { + Element partner = findNamedElement("partner", name); + if (partner == null) { + throw new OpenAS2Exception("Unknown partner name: " + name); + } + for (Map.Entry attribute : attributes.entrySet()) { + partner.setAttribute(attribute.getKey(), attribute.getValue()); + } + // Rebuild the in-memory partners and partnerships from the changed document so a partnership + // that inherits from this partner picks the change up too + refreshConfig(); + } + + /** + * Merges attributes, poller configuration and partner references into an existing partnership + * element and reloads the in-memory partnerships so they match. Anything not supplied is left as + * it is. The caller is responsible for persisting the document afterwards. + * + * @param name - the name of the partnership to update. Partnerships are not renamed by this method + * @param attributes - partnership attributes to set, or null to leave them alone + * @param pollerConfig - poller configuration attributes to set, or null to leave them alone + * @param senderName - the partner to point the sender at, or null to leave it alone + * @param receiverName - the partner to point the receiver at, or null to leave it alone + * @throws OpenAS2Exception if the partnership, or a partner it is being pointed at, does not exist + */ + public void updatePartnership(String name, Map attributes, Map pollerConfig, String senderName, String receiverName) throws OpenAS2Exception { + Element partnership = findNamedElement("partnership", name); + if (partnership == null) { + throw new OpenAS2Exception("Partnership not found: " + name); + } + if (senderName != null) { + repointPartnership(partnership, name, Partnership.PTYPE_SENDER, senderName); + } + if (receiverName != null) { + repointPartnership(partnership, name, Partnership.PTYPE_RECEIVER, receiverName); + } + if (attributes != null) { + for (Map.Entry attribute : attributes.entrySet()) { + setPartnershipAttribute(partnership, attribute.getKey(), attribute.getValue()); + } + } + if (pollerConfig != null && !pollerConfig.isEmpty()) { + Node pollerNode = XMLUtil.findChildNode(partnership, Partnership.PCFG_POLLER); + Element pollerElem; + if (pollerNode == null) { + pollerElem = getPartnershipsXml().createElement(Partnership.PCFG_POLLER); + partnership.appendChild(pollerElem); + } else { + pollerElem = (Element) pollerNode; + } + for (Map.Entry attribute : pollerConfig.entrySet()) { + pollerElem.setAttribute(attribute.getKey(), attribute.getValue()); + } + } + refreshConfig(); + } + + private void repointPartnership(Element partnership, String partnershipName, String partnerType, String partnerName) throws OpenAS2Exception { + if (getPartners().get(partnerName) == null) { + throw new OpenAS2Exception("Partnership " + partnershipName + " has an undefined " + partnerType + ": " + partnerName); + } + Node partnerRef = XMLUtil.findChildNode(partnership, partnerType); + Element partnerElem; + if (partnerRef == null) { + partnerElem = getPartnershipsXml().createElement(partnerType); + partnership.appendChild(partnerElem); + } else { + partnerElem = (Element) partnerRef; + } + partnerElem.setAttribute(Partnership.PID_NAME, partnerName); + } + + /** + * Sets an "attribute" child of a partnership, replacing the value if one with that name is + * already present rather than appending a second one. + */ + private void setPartnershipAttribute(Element partnership, String attrName, String attrValue) { + NodeList children = partnership.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (!"attribute".equals(child.getNodeName())) { + continue; + } + Node nameAttrib = child.getAttributes().getNamedItem("name"); + if (nameAttrib != null && attrName.equals(nameAttrib.getNodeValue())) { + ((Element) child).setAttribute("value", attrValue); + return; + } + } + Element elem = getPartnershipsXml().createElement("attribute"); + elem.setAttribute("name", attrName); + elem.setAttribute("value", attrValue); + partnership.appendChild(elem); + } + + /** + * Finds a top level element of the given type by its name attribute. Walking the children rather + * than using XPath keeps names containing quotes from breaking the lookup. + * + * @return the matching element, or null if there is not exactly one + */ + private Element findNamedElement(String elementName, String name) { + Element found = null; + NodeList children = getPartnershipsXml().getDocumentElement().getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (!elementName.equals(child.getNodeName())) { + continue; + } + Node nameAttrib = child.getAttributes().getNamedItem(Partnership.PID_NAME); + if (nameAttrib != null && name.equals(nameAttrib.getNodeValue())) { + if (found != null) { + logger.error("More than one " + elementName + " element is named \"" + name + "\" so it cannot be updated."); + return null; + } + found = (Element) child; + } + } + return found; + } + /** * Appends the passed element as a child of the root in the partnership document. * It does NOT check if the passed element is a valid element. diff --git a/Server/src/test/java/org/openas2/app/PatchApiTest.java b/Server/src/test/java/org/openas2/app/PatchApiTest.java new file mode 100644 index 00000000..cdf9900a --- /dev/null +++ b/Server/src/test/java/org/openas2/app/PatchApiTest.java @@ -0,0 +1,298 @@ +package org.openas2.app; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.http.HttpEntity; +import org.apache.http.NameValuePair; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.openas2.cert.CertificateFactory; +import org.openas2.cert.X509CertificateFactory; +import org.openas2.cmd.processor.restapi.AuthenticationRequestFilter; +import org.openas2.util.Properties; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.Key; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.util.Base64; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the PATCH endpoint that partially updates one partner, partnership or certificate. + *

+ * PATCH exists so a caller no longer has to delete an entry and recreate it to change it, which + * leaves nothing behind if the recreate fails. These tests go over real HTTP because the routing is + * the part that was missing: the update commands are covered directly by UpdateCommandsTest. + */ +@TestInstance(Lifecycle.PER_CLASS) +public class PatchApiTest extends BaseServerSetup { + + private static final String REST_HOST = "http://127.0.0.1:8087"; + private static final String BASE_URL = REST_HOST + "/api/"; + private static final String AUTH_USER = "admin"; + private static final String AUTH_PWD = "admin"; + + private static OpenAS2Server serverInstance; + + @BeforeAll + public void setUp() throws Exception { + super.createFileSystemResources(this.getClass().getName()); + try (FileOutputStream fos = new FileOutputStream(openAS2PropertiesFile)) { + fos.write("restapi.command.processor.enabled=true\n".getBytes()); + fos.write(("restapi.command.processor.baseuri=" + REST_HOST + "\n").getBytes()); + fos.write(("restapi.command.processor.userid=" + AUTH_USER + "\n").getBytes()); + fos.write(("restapi.command.processor.password=" + AUTH_PWD + "\n").getBytes()); + } + System.setProperty(Properties.OPENAS2_PROPERTIES_FILE_PROP, openAS2PropertiesFile.getAbsolutePath()); + try { + serverInstance = new OpenAS2Server.Builder().run(configDir.getAbsolutePath() + "/config.xml"); + } catch (Throwable e) { + System.err.println("ERROR occurred: " + ExceptionUtils.getStackTrace(e)); + throw new Exception(e); + } + } + + @AfterAll + public void tearDown() throws Exception { + if (serverInstance != null) { + serverInstance.shutdown(); + } + System.clearProperty(Properties.OPENAS2_PROPERTIES_FILE_PROP); + } + + @Test + public void patchUpdatesAPartnerAttribute() throws Exception { + String body = patch("partner/PartnerA", true, param("email", "patched@example.com")); + assertTrue(body.contains("\"OK\""), body); + + String view = doGet("partner/view/PartnerA"); + assertTrue(view.contains("patched@example.com"), "the change should be visible: " + view); + } + + @Test + public void patchUsesTheWholeNameFromThePath() throws Exception { + // The item ID must not lose its first character on the way to the command, which is what + // happens if the leading path separator handling is got wrong + String body = patch("partner/PartnerB", true, param("email", "wholename@example.com")); + assertTrue(body.contains("\"OK\""), body); + assertTrue(body.contains("PartnerB"), "the result should name the partner that was patched: " + body); + + String view = doGet("partner/view/PartnerB"); + assertTrue(view.contains("wholename@example.com"), view); + // If the first character were dropped the command would have been handed "artnerB", which is + // not a partner at all, so asking for that name must still be an error + assertTrue(doGet("partner/view/artnerB").contains("ERROR"), + "a truncated partner name must not resolve to anything"); + } + + @Test + public void patchLeavesAttributesThatWereNotSuppliedAlone() throws Exception { + String before = doGet("partner/view/PartnerA"); + assertTrue(before.contains("PartnerA_OID"), "fixture should have an as2_id: " + before); + + patch("partner/PartnerA", true, param("email", "another@example.com")); + + String after = doGet("partner/view/PartnerA"); + assertTrue(after.contains("PartnerA_OID"), "an attribute that was not patched must survive: " + after); + } + + @Test + public void patchUpdatesAPartnershipAttributeAndPollerConfig() throws Exception { + String body = patch("partnership/MyCompany-to-PartnerA", true, + param("subject", "Patched subject"), param("pollerConfig.interval", "25")); + assertTrue(body.contains("\"OK\""), body); + + String view = doGet("partnership/view/MyCompany-to-PartnerA"); + assertTrue(view.contains("Patched subject"), view); + } + + @Test + public void patchingAnUnknownPartnerReportsTheReason() throws Exception { + String body = patch("partner/NoSuchPartner", true, param("email", "x@y.com")); + + assertTrue(body.contains("ERROR"), body); + assertTrue(body.contains("Unknown partner name"), "the reason should come back to the caller: " + body); + } + + @Test + public void patchingWithNothingToChangeReportsTheReason() throws Exception { + String body = patch("partnership/MyCompany-to-PartnerA", true); + + assertTrue(body.contains("ERROR") || body.contains("INVALID"), body); + } + + @Test + public void patchRequiresAuthentication() throws Exception { + String body = patch("partner/PartnerA", false, param("email", "nope@example.com")); + + assertTrue(body.contains(AuthenticationRequestFilter.ACCESS_DENIED_ERROR_MSG), + "an unauthenticated PATCH must not be applied: " + body); + assertFalse(doGet("partner/view/PartnerA").contains("nope@example.com"), "the change must not have been applied"); + } + + @Test + public void patchImportsACertificateUnderAnAlias() throws Exception { + // Importing already overwrites the alias in place, so PATCH on a certificate routes to the + // same import the POST endpoint uses rather than needing an update command of its own + String encoded = certificateData("partnera"); + + String body = patch("cert/patched_partner", true, param("data", encoded)); + + assertTrue(body.contains("\"OK\""), body); + assertTrue(doGet("cert/list").contains("patched_partner"), "the alias must now be present: " + body); + assertTrue(doGet("cert/view/patched_partner").contains("\"data\""), "the certificate must be readable back"); + } + + @Test + public void patchingACertificateOverAPrivateKeyAliasIsReportedNotAServerError() throws Exception { + // The keystore will not replace the certificate of an alias holding a private key, because + // that would orphan the key. The caller should be told, not handed a failed request. + String encoded = certificateData("partnera"); + + String body = patch("cert/partnerb", true, param("data", encoded)); + + assertTrue(body.contains("ERROR"), body); + assertTrue(body.contains("Could not replace the certificate"), body); + assertFalse(body.contains(" form = new ArrayList(Arrays.asList(params)); + request.setEntity(new UrlEncodedFormEntity(form)); + return execute(request, withAuth); + } + + private String doGet(String uriSuffix) throws IOException { + return execute(new HttpGet(BASE_URL + uriSuffix), true); + } + + private String execute(org.apache.http.client.methods.HttpUriRequest request, boolean withAuth) throws IOException { + HttpClientBuilder builder = HttpClientBuilder.create(); + if (withAuth) { + CredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(AUTH_USER, AUTH_PWD)); + builder = builder.setDefaultCredentialsProvider(provider); + } + try (CloseableHttpClient client = builder.build(); + CloseableHttpResponse response = client.execute(request)) { + HttpEntity entity = response.getEntity(); + return entity == null ? "" : EntityUtils.toString(entity); + } + } + + /** Guards against the fixture silently not starting, which would make every test vacuous. */ + @Test + public void fixtureIsUsable() throws Exception { + assertEquals(true, serverInstance != null); + assertTrue(doGet("partner/list").contains("PartnerA"), "the server should be serving the API"); + } +} diff --git a/Server/src/test/java/org/openas2/app/partner/UpdateCommandsTest.java b/Server/src/test/java/org/openas2/app/partner/UpdateCommandsTest.java new file mode 100644 index 00000000..ef717bbd --- /dev/null +++ b/Server/src/test/java/org/openas2/app/partner/UpdateCommandsTest.java @@ -0,0 +1,418 @@ +package org.openas2.app.partner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openas2.TestUtils; +import org.openas2.XMLSession; +import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; +import org.openas2.partner.Partnership; +import org.openas2.partner.PartnershipFactory; +import org.openas2.partner.XMLPartnershipFactory; +import org.openas2.util.Properties; + +/** + * Verifies the partner and partnership update commands against both partnership stores. + *

+ * These exist so a caller does not have to delete an entry to change it: "add" refuses to overwrite, + * so the only previous route was delete followed by recreate, which loses the definition outright if + * the recreate fails. The behaviour that matters is therefore that an update changes what was asked + * for, leaves everything else alone, and refuses rather than inventing an entry that is not there. + */ +public class UpdateCommandsTest { + + private static final Path SRC_CONFIG_DIR = Paths.get("src", "test", "resources", "config").toAbsolutePath(); + + private File configDir; + private XMLSession session; + private String connectString; + private Connection seedConn; + + @BeforeEach + public void setUp() throws Exception { + configDir = Files.createTempDirectory("update-commands").toFile(); + for (String f : SRC_CONFIG_DIR.toFile().list()) { + Files.copy(SRC_CONFIG_DIR.resolve(f), configDir.toPath().resolve(f), StandardCopyOption.REPLACE_EXISTING); + } + System.clearProperty(Properties.OPENAS2_PROPERTIES_FILE_PROP); + } + + @AfterEach + public void tearDown() throws Exception { + if (session != null) { + session.stop(); + session = null; + } + if (seedConn != null) { + seedConn.close(); + seedConn = null; + } + TestUtils.deleteDirectory(configDir); + } + + /* ------------------------------------------------------------------ XML store */ + + @Test + public void xmlPartnerUpdateChangesOnlyWhatWasSupplied() throws Exception { + PartnershipFactory partFx = xmlFactory(); + Map before = partnerAttributes(partFx, "PartnerA"); + assertNotNull(before.get("as2_id"), "fixture partner should have an as2_id"); + + CommandResult result = new UpdatePartnerCommand().execute(partFx, new Object[]{"PartnerA", "email=new@example.com"}); + + assertEquals(CommandResult.TYPE_OK, result.getType(), result.getResult()); + Map after = partnerAttributes(partFx, "PartnerA"); + assertEquals("new@example.com", after.get("email")); + assertEquals(before.get("as2_id"), after.get("as2_id"), "an attribute that was not supplied must not change"); + assertEquals(before.get("x509_alias"), after.get("x509_alias"), "an attribute that was not supplied must not change"); + } + + @Test + public void xmlPartnerUpdatePropagatesToPartnershipsThatInheritFromIt() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + new UpdatePartnerCommand().execute(partFx, new Object[]{"PartnerA", "x509_alias=partnera_rotated"}); + + Partnership partnership = partnership(partFx, "MyCompany-to-PartnerA"); + assertEquals("partnera_rotated", partnership.getReceiverIDs().get("x509_alias"), + "the partnership inherits its receiver IDs from the partner so it must see the change"); + } + + @Test + public void xmlPartnershipUpdateReplacesAnAttributeInsteadOfAddingASecond() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + CommandResult result = new UpdatePartnershipCommand().execute(partFx, + new Object[]{"MyCompany-to-PartnerA", "subject=Replaced subject"}); + + assertEquals(CommandResult.TYPE_OK, result.getType(), result.getResult()); + assertEquals("Replaced subject", partnership(partFx, "MyCompany-to-PartnerA").getAttribute("subject")); + assertEquals(1, countAttributeElements("MyCompany-to-PartnerA", "subject"), + "updating must not leave two attribute elements with the same name behind"); + } + + @Test + public void xmlPartnershipUpdateAddsAnAttributeThatWasNotThere() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + new UpdatePartnershipCommand().execute(partFx, + new Object[]{"MyCompany-to-PartnerA", "content_transfer_encoding_receive=base64"}); + + assertEquals("base64", + partnership(partFx, "MyCompany-to-PartnerA").getAttribute("content_transfer_encoding_receive")); + } + + @Test + public void xmlPartnershipUpdateMergesPollerConfig() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + new UpdatePartnershipCommand().execute(partFx, + new Object[]{"MyCompany-to-PartnerA", "pollerConfig.interval=30"}); + + assertEquals("30", pollerConfigAttribute("MyCompany-to-PartnerA", "interval")); + assertEquals("true", pollerConfigAttribute("MyCompany-to-PartnerA", "enabled"), + "the poller config attribute that was already there must survive"); + } + + @Test + public void xmlPartnershipCanBeRepointedAtADifferentPartner() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + CommandResult result = new UpdatePartnershipCommand().execute(partFx, + new Object[]{"MyCompany-to-PartnerA", "receiver.name=PartnerB"}); + + assertEquals(CommandResult.TYPE_OK, result.getType(), result.getResult()); + Partnership partnership = partnership(partFx, "MyCompany-to-PartnerA"); + assertEquals("PartnerB", partnership.getReceiverIDs().get(Partnership.PID_NAME)); + } + + @Test + public void xmlUpdateOfAnUnknownEntryIsRejectedRatherThanCreatingIt() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + assertTrue(errorMessage(new UpdatePartnerCommand(), partFx, "NoSuchPartner", "email=x@y.com") + .contains("Unknown partner name")); + assertTrue(errorMessage(new UpdatePartnershipCommand(), partFx, "NoSuchPartnership", "subject=x") + .contains("Partnership not found")); + } + + @Test + public void xmlRepointingAtAnUnknownPartnerIsRejected() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + assertTrue(errorMessage(new UpdatePartnershipCommand(), partFx, "MyCompany-to-PartnerA", "sender.name=Ghost") + .contains("undefined sender")); + } + + /* ------------------------------------------------------------------ DB store */ + + @Test + public void dbPartnerUpdateChangesOnlyWhatWasSupplied() throws Exception { + DbPartnershipFactory partFx = dbFactory(); + try { + CommandResult result = new UpdatePartnerCommand().execute(partFx, + new Object[]{"CompanyA", "email=changed@example.com"}); + + assertEquals(CommandResult.TYPE_OK, result.getType(), result.getResult()); + assertEquals("changed@example.com", partnerAttributeRow("CompanyA", "email")); + assertEquals("A_OID", partnerAttributeRow("CompanyA", "as2_id"), "the untouched attribute must survive"); + } finally { + partFx.destroy(); + } + } + + @Test + public void dbPartnerUpdateAddsAnAttributeThatWasNotThere() throws Exception { + DbPartnershipFactory partFx = dbFactory(); + try { + new UpdatePartnerCommand().execute(partFx, new Object[]{"CompanyA", "x509_alias=companya"}); + + assertEquals("companya", partnerAttributeRow("CompanyA", "x509_alias")); + } finally { + partFx.destroy(); + } + } + + @Test + public void dbPartnershipUpdateMergesAttributesAndPollerConfig() throws Exception { + DbPartnershipFactory partFx = dbFactory(); + try { + CommandResult result = new UpdatePartnershipCommand().execute(partFx, + new Object[]{"A-to-B", "subject=Changed", "pollerConfig.interval=45"}); + + assertEquals(CommandResult.TYPE_OK, result.getType(), result.getResult()); + assertEquals("Changed", partnershipAttributeRow("A-to-B", DbPartnershipFactory.CATEGORY_ATTRIBUTE, "subject")); + assertEquals("as2", partnershipAttributeRow("A-to-B", DbPartnershipFactory.CATEGORY_ATTRIBUTE, "protocol"), + "the untouched attribute must survive"); + assertEquals("45", partnershipAttributeRow("A-to-B", DbPartnershipFactory.CATEGORY_POLLER_CONFIG, "interval")); + assertEquals("true", partnershipAttributeRow("A-to-B", DbPartnershipFactory.CATEGORY_POLLER_CONFIG, "enabled"), + "the poller config attribute that was already there must survive"); + } finally { + partFx.destroy(); + } + } + + @Test + public void dbPartnershipCanBeRepointedAtADifferentPartner() throws Exception { + DbPartnershipFactory partFx = dbFactory(); + try { + new UpdatePartnershipCommand().execute(partFx, new Object[]{"A-to-B", "receiver.name=CompanyC"}); + + assertEquals("CompanyC", partnership(partFx, "A-to-B").getReceiverIDs().get(Partnership.PID_NAME)); + } finally { + partFx.destroy(); + } + } + + @Test + public void dbUpdateOfAnUnknownEntryIsRejectedRatherThanCreatingIt() throws Exception { + DbPartnershipFactory partFx = dbFactory(); + try { + assertTrue(errorMessage(new UpdatePartnerCommand(), partFx, "NoSuchPartner", "email=x@y.com") + .contains("Unknown partner name")); + assertTrue(errorMessage(new UpdatePartnershipCommand(), partFx, "NoSuchPartnership", "subject=x") + .contains("Partnership not found")); + assertEquals(0, countRows("partner WHERE NAME = 'NoSuchPartner'")); + } finally { + partFx.destroy(); + } + } + + /* ------------------------------------------------------------------ shared rules */ + + @Test + public void renamingIsRefusedBecauseOtherRecordsReferenceTheName() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + assertTrue(errorMessage(new UpdatePartnerCommand(), partFx, "PartnerA", "name=PartnerRenamed") + .contains("cannot be renamed")); + assertTrue(errorMessage(new UpdatePartnershipCommand(), partFx, "MyCompany-to-PartnerA", "name=Renamed") + .contains("cannot be renamed")); + } + + @Test + public void anUpdateWithNothingToChangeIsRejected() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + CommandResult result = new UpdatePartnershipCommand().execute(partFx, new Object[]{"MyCompany-to-PartnerA"}); + assertEquals(CommandResult.TYPE_INVALID_PARAM_COUNT, result.getType()); + } + + @Test + public void malformedParametersAreRejected() throws Exception { + PartnershipFactory partFx = xmlFactory(); + + assertEquals(CommandResult.TYPE_ERROR, + new UpdatePartnerCommand().execute(partFx, new Object[]{"PartnerA", "novalue"}).getType()); + assertEquals(CommandResult.TYPE_ERROR, + new UpdatePartnerCommand().execute(partFx, new Object[]{"PartnerA", "=noname"}).getType()); + } + + /* ------------------------------------------------------------------ helpers */ + + private PartnershipFactory xmlFactory() throws Exception { + session = new XMLSession(configDir.getAbsolutePath() + File.separator + "config.xml"); + return session.getPartnershipFactory(); + } + + private DbPartnershipFactory dbFactory() throws Exception { + connectString = "jdbc:h2:mem:update_commands_" + System.nanoTime() + ";DB_CLOSE_DELAY=-1"; + seedConn = DriverManager.getConnection(connectString, "sa", ""); + try (Statement s = seedConn.createStatement()) { + String ddl = new String(Files.readAllBytes(Paths.get("src", "config", "db_ddl.sql")), StandardCharsets.UTF_8); + for (String statement : ddl.split(";")) { + if (!statement.trim().isEmpty()) { + s.execute(statement); + } + } + s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (1, 'CompanyA')"); + s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (2, 'CompanyB')"); + s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (3, 'CompanyC')"); + s.executeUpdate("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (1, 'as2_id', 'A_OID')"); + s.executeUpdate("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (1, 'email', 'a@example.com')"); + s.executeUpdate("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (2, 'as2_id', 'B_OID')"); + s.executeUpdate("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (3, 'as2_id', 'C_OID')"); + s.executeUpdate("INSERT INTO partnership (ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (1, 'A-to-B', 1, 2)"); + s.executeUpdate("INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (1, 'attribute', 'protocol', 'as2')"); + s.executeUpdate("INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (1, 'attribute', 'subject', 'Original')"); + s.executeUpdate("INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (1, 'pollerConfig', 'enabled', 'true')"); + } + DbPartnershipFactory factory = new DbPartnershipFactory(); + Map params = new HashMap(); + params.put(DbPartnershipFactory.PARAM_USE_EMBEDDED_DB, "true"); + params.put("tcp_server_start", "false"); + params.put(DbPartnershipFactory.PARAM_DB_USER, "sa"); + params.put(DbPartnershipFactory.PARAM_DB_PWD, ""); + params.put(DbPartnershipFactory.PARAM_JDBC_CONNECT_STRING, connectString); + factory.init(null, params); + return factory; + } + + private String errorMessage(AliasedPartnershipsCommand command, PartnershipFactory partFx, String... params) throws Exception { + Object[] args = new Object[params.length]; + System.arraycopy(params, 0, args, 0, params.length); + CommandResult result = command.execute(partFx, args); + assertEquals(CommandResult.TYPE_ERROR, result.getType(), "expected an error but got: " + result.getResult()); + return result.getResult(); + } + + /** The factory's own lookup by name is protected, so find it in the loaded list instead. */ + private Partnership partnership(PartnershipFactory partFx, String name) { + for (Partnership p : partFx.getPartnerships()) { + if (name.equals(p.getName())) { + return p; + } + } + return null; + } + + @SuppressWarnings("unchecked") + private Map partnerAttributes(PartnershipFactory partFx, String name) { + return (Map) partFx.getPartners().get(name); + } + + /** Counts attribute elements with a given name so a duplicate append would be caught. */ + private int countAttributeElements(String partnershipName, String attributeName) throws Exception { + org.w3c.dom.NodeList partnerships = + ((XMLPartnershipFactory) session.getPartnershipFactory()).getPartnershipsXml() + .getDocumentElement().getChildNodes(); + for (int i = 0; i < partnerships.getLength(); i++) { + org.w3c.dom.Node node = partnerships.item(i); + if (!"partnership".equals(node.getNodeName())) { + continue; + } + org.w3c.dom.Node nameAttrib = node.getAttributes().getNamedItem("name"); + if (nameAttrib == null || !partnershipName.equals(nameAttrib.getNodeValue())) { + continue; + } + int count = 0; + org.w3c.dom.NodeList children = node.getChildNodes(); + for (int j = 0; j < children.getLength(); j++) { + org.w3c.dom.Node child = children.item(j); + if ("attribute".equals(child.getNodeName())) { + org.w3c.dom.Node childName = child.getAttributes().getNamedItem("name"); + if (childName != null && attributeName.equals(childName.getNodeValue())) { + count++; + } + } + } + return count; + } + return 0; + } + + private String pollerConfigAttribute(String partnershipName, String attributeName) throws Exception { + org.w3c.dom.NodeList partnerships = + ((XMLPartnershipFactory) session.getPartnershipFactory()).getPartnershipsXml() + .getDocumentElement().getChildNodes(); + for (int i = 0; i < partnerships.getLength(); i++) { + org.w3c.dom.Node node = partnerships.item(i); + if (!"partnership".equals(node.getNodeName())) { + continue; + } + org.w3c.dom.Node nameAttrib = node.getAttributes().getNamedItem("name"); + if (nameAttrib == null || !partnershipName.equals(nameAttrib.getNodeValue())) { + continue; + } + org.w3c.dom.Node poller = org.openas2.util.XMLUtil.findChildNode(node, Partnership.PCFG_POLLER); + if (poller == null) { + return null; + } + org.w3c.dom.Node attrib = poller.getAttributes().getNamedItem(attributeName); + return attrib == null ? null : attrib.getNodeValue(); + } + return null; + } + + private String partnerAttributeRow(String partnerName, String attributeName) throws Exception { + try (PreparedStatement s = seedConn.prepareStatement( + "SELECT pa.ATTRIBUTE_VALUE FROM partner_attribute pa JOIN partner p ON p.ID = pa.PARTNER_ID" + + " WHERE p.NAME = ? AND pa.ATTRIBUTE_NAME = ?")) { + s.setString(1, partnerName); + s.setString(2, attributeName); + try (ResultSet rs = s.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + private String partnershipAttributeRow(String partnershipName, String category, String attributeName) throws Exception { + try (PreparedStatement s = seedConn.prepareStatement( + "SELECT pa.ATTRIBUTE_VALUE FROM partnership_attribute pa JOIN partnership p ON p.ID = pa.PARTNERSHIP_ID" + + " WHERE p.NAME = ? AND pa.CATEGORY = ? AND pa.ATTRIBUTE_NAME = ?")) { + s.setString(1, partnershipName); + s.setString(2, category); + s.setString(3, attributeName); + try (ResultSet rs = s.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + private long countRows(String tableAndWhere) throws Exception { + try (PreparedStatement s = seedConn.prepareStatement("SELECT COUNT(*) FROM " + tableAndWhere); + ResultSet rs = s.executeQuery()) { + rs.next(); + return rs.getLong(1); + } + } +} diff --git a/changes.txt b/changes.txt index 39add56b..4c3a79c6 100644 --- a/changes.txt +++ b/changes.txt @@ -11,6 +11,23 @@ This is a minor enhancement release. unresolved so placeholders such as $properties.storageBaseDir$ keep working. Pass --dry-run to validate the file and see what would be written without touching the database, or --replace to overwrite partnerships already stored there. The whole migration runs in one transaction, so a file that cannot be represented in the schema leaves it untouched. +2. Add partial update support so a partner, partnership or certificate no longer has to be deleted and recreated to change + it. Previously "add" refused to overwrite an existing entry, so the only route was delete followed by recreate, which + loses the definition outright if the recreate fails. New "update" commands for partner and partnership merge only what + is supplied and leave everything else alone, working against both the XML and the database partnership store, and a new + PATCH API endpoint exposes them: PATCH /api/partner/, PATCH /api/partnership/ and PATCH /api/cert/ + with the attributes to change as form fields. Use pollerConfig. to change a partnership poller attribute and + sender.name or receiver.name to point a partnership at a different partner. For a certificate, send the base64 encoded + certificate in the "data" field to replace a partner certificate, or a base64 encoded PKCS12 in "data" plus the password + that opens it in "password" to replace a certificate and its private key together, which is what rotating an identity of + your own needs. +3. Fix the PUT and DELETE API endpoints, which dropped the first character of the item name, and PUT and HEAD, which bound + a path parameter that was not in their path template and so never received the resource name. +4. Fix replacing the key pair held under a keystore alias. Importing a PKCS12 over an alias that already held a private key + failed because the certificate was staged with setCertificateEntry first, which a keystore refuses on such an alias. The + key entry is now written in one operation, so "cert import " and the certificate PATCH can + both rotate an existing identity. The previous key pair is left in place unless the new one is written successfully, and + the entry is stored under the keystore password so it can be read back. Version 4.11.0 - 2026-09-02 ===========================