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