diff --git a/java/code/src/com/redhat/rhn/common/conf/Config.java b/java/code/src/com/redhat/rhn/common/conf/Config.java index 840004676dfb..f4b489368597 100644 --- a/java/code/src/com/redhat/rhn/common/conf/Config.java +++ b/java/code/src/com/redhat/rhn/common/conf/Config.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2014 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -282,6 +283,36 @@ public Integer getInteger(String s) { return Integer.valueOf(val); } + /** + * get the config entry for string s, if no value is found + * return the defaultValue specified. + * + * @param s string to get the value of + * @param defaultValue Default value if entry is not found. + * @return the value + */ + public long getLong(String s, long defaultValue) { + Long val = getLong(s); + if (val == null) { + return defaultValue; + } + return val; + } + + /** + * get the config entry for string s + * + * @param s string to get the value of + * @return the value + */ + public Long getLong(String s) { + String val = getString(s); + if (val == null) { + return null; + } + return Long.valueOf(val); + } + /** * get the config entry for string s * diff --git a/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java b/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java index 88c27c8c67f6..078745481387 100644 --- a/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java +++ b/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 SUSE LLC + * Copyright (c) 2018--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.common.hibernate; @@ -55,6 +51,7 @@ import com.redhat.rhn.domain.contentmgmt.SoftwareProjectSource; import com.redhat.rhn.domain.credentials.BaseCredentials; import com.redhat.rhn.domain.credentials.CloudRMTCredentials; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; import com.redhat.rhn.domain.credentials.RHUICredentials; import com.redhat.rhn.domain.credentials.RegistryCredentials; import com.redhat.rhn.domain.credentials.ReportDBCredentials; @@ -168,6 +165,10 @@ import com.suse.manager.model.attestation.CoCoResultTypeConverter; import com.suse.manager.model.attestation.ServerCoCoAttestationConfig; import com.suse.manager.model.attestation.ServerCoCoAttestationReport; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssPeripheralChannels; import com.suse.manager.model.maintenance.MaintenanceCalendar; import com.suse.manager.model.maintenance.MaintenanceSchedule; @@ -226,6 +227,7 @@ private AnnotationRegistry() { EnvironmentTarget.class, ErrataFilter.class, GroupRecurringAction.class, + HubSCCCredentials.class, ImageFile.class, ImageInfo.class, ImageInfoCustomDataValue.class, @@ -241,7 +243,11 @@ private AnnotationRegistry() { InstalledPackage.class, InternalState.class, InventoryPath.class, + IssAccessToken.class, + IssHub.class, IssMaster.class, + IssPeripheral.class, + IssPeripheralChannels.class, KiwiProfile.class, MaintenanceCalendar.class, MaintenanceSchedule.class, diff --git a/java/code/src/com/redhat/rhn/common/util/http/HttpClientAdapter.java b/java/code/src/com/redhat/rhn/common/util/http/HttpClientAdapter.java index e60fcb586711..8122a05d6019 100644 --- a/java/code/src/com/redhat/rhn/common/util/http/HttpClientAdapter.java +++ b/java/code/src/com/redhat/rhn/common/util/http/HttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 SUSE LLC + * Copyright (c) 2015--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,16 +7,13 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.common.util.http; import com.redhat.rhn.common.conf.Config; import com.redhat.rhn.common.conf.ConfigDefaults; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpException; import org.apache.http.HttpHost; @@ -34,6 +31,8 @@ import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.conn.routing.HttpRoute; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.cookie.Cookie; +import org.apache.http.impl.client.BasicCookieStore; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.ProxyAuthenticationStrategy; @@ -49,9 +48,11 @@ import java.net.URI; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Optional; import javax.net.ssl.SSLContext; @@ -101,14 +102,41 @@ public class HttpClientAdapter { /** The credentials provider. */ private final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + + /** + * The cookie store + */ + private BasicCookieStore cookieStore; + /** * Initialize an {@link HttpClient} for performing requests. Proxy settings will - * be read from the configuration and applied transparently. + * be read from the configuration and applied transparently. The cookies will not be supported. */ public HttpClientAdapter() { + this(List.of(), false); + } + + /** + * Initialize an {@link HttpClient} for performing requests. Proxy settings will + * be read from the configuration and applied transparently. The cookies will not be supported. + * + * @param additionalCertificates a list of additional certificate to consider when establishing the connection + */ + public HttpClientAdapter(List additionalCertificates) { + this(additionalCertificates, false); + } + + /** + * Initialize an {@link HttpClient} for performing requests. Proxy settings will + * be read from the configuration and applied transparently. + * + * @param allowCookies true, to allow and use cookies. + * @param additionalCertificates a list of additional certificate to consider when establishing the connection + */ + public HttpClientAdapter(List additionalCertificates, boolean allowCookies) { Optional sslSocketFactory = Optional.empty(); try { - SSLContext sslContext = buildSslSocketContext(); + SSLContext sslContext = buildSslSocketContext(additionalCertificates); List supportedProtocols = Arrays.asList(sslContext.getSupportedSSLParameters().getProtocols()); List wantedProtocols = Arrays.asList("TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"); wantedProtocols.retainAll(supportedProtocols); @@ -129,7 +157,7 @@ public HttpClientAdapter() { Builder requestConfigBuilder = RequestConfig.custom() .setConnectTimeout(HttpClientAdapter.getHTTPConnectionTimeout(5)) .setSocketTimeout(HttpClientAdapter.getHTTPSocketTimeout(5 * 60)) - .setCookieSpec(CookieSpecs.IGNORE_COOKIES); + .setCookieSpec(allowCookies ? CookieSpecs.STANDARD : CookieSpecs.IGNORE_COOKIES); // Store the proxy settings String proxyHostname = ConfigDefaults.get().getProxyHost(); @@ -170,15 +198,21 @@ public HttpClientAdapter() { requestConfig = requestConfigBuilder.build(); clientBuilder.setMaxConnPerRoute(Config.get().getInt(MAX_CONNCECTIONS, 1)); clientBuilder.setMaxConnTotal(Config.get().getInt(MAX_CONNCECTIONS, 1)); + if (allowCookies) { + cookieStore = new BasicCookieStore(); + clientBuilder.setDefaultCookieStore(cookieStore); + } + httpClient = clientBuilder.build(); } - private SSLContext buildSslSocketContext() throws NoSuchAlgorithmException { + private SSLContext buildSslSocketContext(List additionalCertificates) throws NoSuchAlgorithmException { LOG.info("Started checking for certificates and if it finds the certificates will be loaded..."); - String keyStoreLoc = System.getProperty("javax.net.ssl.trustStore", - System.getProperty("java.home") + "/lib/security/cacerts"); + String defaultLocation = System.getProperty("java.home") + "/lib/security/cacerts"; + String keyStoreLoc = System.getProperty("javax.net.ssl.trustStore", defaultLocation); + SSLContext context; try (InputStream in = new FileInputStream(keyStoreLoc)) { @@ -186,6 +220,14 @@ private SSLContext buildSslSocketContext() throws NoSuchAlgorithmException { KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); keystore.load(in, null); + // Add any additional certificate to the store, if specified + if (CollectionUtils.isNotEmpty(additionalCertificates)) { + int customCert = 0; + for (Certificate certificate : additionalCertificates) { + keystore.setCertificateEntry("additional_certificate_" + customCert++, certificate); + } + } + // Create a TrustManager that trusts the CAs in our KeyStore String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm); @@ -327,6 +369,25 @@ public HttpResponse executeRequest(HttpRequestBase request, String username, return executeRequest(request, ignoreNoProxy); } + /** + * Set the value of a cookie + * @param cookie the cookie + */ + public void setCookie(Cookie cookie) { + cookieStore.addCookie(cookie); + } + + /** + * Retrieves the cookies with the specified name from the cookie store. + * @param name the name of the cookie to retrieve + * @return the cookie with a matching name. + */ + public List getCookies(String name) { + return cookieStore.getCookies().stream() + .filter(cookie -> Objects.equals(name, cookie.getName())) + .toList(); + } + /** * Check for a given {@link URI} if a proxy should be used or not. * diff --git a/java/code/src/com/redhat/rhn/domain/errata/CustomEnumType.java b/java/code/src/com/redhat/rhn/domain/CustomEnumType.java similarity index 94% rename from java/code/src/com/redhat/rhn/domain/errata/CustomEnumType.java rename to java/code/src/com/redhat/rhn/domain/CustomEnumType.java index 545ec2bc22a8..dc1c0cf74079 100644 --- a/java/code/src/com/redhat/rhn/domain/errata/CustomEnumType.java +++ b/java/code/src/com/redhat/rhn/domain/CustomEnumType.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 SUSE LLC + * Copyright (c) 2021--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,12 +7,8 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ -package com.redhat.rhn.domain.errata; +package com.redhat.rhn.domain; import org.hibernate.HibernateException; import org.hibernate.engine.spi.SharedSessionContractImplementor; diff --git a/java/code/src/com/redhat/rhn/domain/DatabaseEnumType.java b/java/code/src/com/redhat/rhn/domain/DatabaseEnumType.java new file mode 100644 index 000000000000..20cfb5eaa514 --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/DatabaseEnumType.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.redhat.rhn.domain; + +import java.sql.Types; +import java.util.Arrays; +import java.util.Objects; + +/** + * A {@link CustomEnumType} that maps a Java enum to a PostgreSQL enum type. + * + *

This type handles the conversion between Java enum values and their corresponding + * string representations in the PostgreSQL database. + * + *

If the enum type implements the {@link Labeled} interface, the + * {@link Labeled#getLabel()} method is used to obtain the value stored in the database. + * Otherwise, the standard {@link Enum#name()} method is called. + * + * @param the type of the enum + */ +public abstract class DatabaseEnumType> extends CustomEnumType { + + /** + * Construct an instance for the specified enum class. + * + * @param enumClassIn the enum class + */ + protected DatabaseEnumType(Class enumClassIn) { + super(enumClassIn, String.class, e -> getLabel(e), v -> findByLabel(enumClassIn, v)); + } + + // Converts the given enum constant to the string representation used in the database. + private static > String getLabel(T value) { + if (value instanceof Labeled labeled) { + return labeled.getLabel(); + } + + return value.name(); + } + + /** + * Converts a string value from the database to the proper enum value. + * @param enumType the class of the enum + * @param label the database value + * @return an instance of the specified enum + * @param the enum + */ + public static > T findByLabel(Class enumType, String label) { + return Arrays.stream(enumType.getEnumConstants()) + .filter(e -> Objects.equals(label, getLabel(e))) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("Invalid %s value %s".formatted(enumType.getName(), label)) + ); + } + + /** + * {@inheritDoc} + *

+ * @returns {@link Types#OTHER}, as this instance is mapping the enum values to a PostgreSQL enum. + */ + @Override + public int getSqlType() { + return Types.OTHER; + } +} diff --git a/java/code/src/com/redhat/rhn/domain/Label.java b/java/code/src/com/redhat/rhn/domain/Label.java index cbb4acb7f83c..af0452952e33 100644 --- a/java/code/src/com/redhat/rhn/domain/Label.java +++ b/java/code/src/com/redhat/rhn/domain/Label.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2010 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -45,7 +46,7 @@ * @see com.redhat.rhn.domain.server.VirtualInstanceType * */ -public abstract class Label { +public abstract class Label implements Labeled { private Long id; private String name; @@ -84,6 +85,7 @@ private void setName(String newName) { * * @return The label text of this label */ + @Override public String getLabel() { return label; } diff --git a/java/code/src/com/redhat/rhn/domain/Labeled.java b/java/code/src/com/redhat/rhn/domain/Labeled.java new file mode 100644 index 000000000000..468943581103 --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/Labeled.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.redhat.rhn.domain; + +/** + * Indicates that an implementing class has a label. + */ +public interface Labeled { + + /** + * Returns the label associated with this object. + * + * @return the label of this object + */ + String getLabel(); +} diff --git a/java/code/src/com/redhat/rhn/domain/channel/AccessTokenFactory.java b/java/code/src/com/redhat/rhn/domain/channel/AccessTokenFactory.java index 236d93f92dcb..95905cf45d36 100644 --- a/java/code/src/com/redhat/rhn/domain/channel/AccessTokenFactory.java +++ b/java/code/src/com/redhat/rhn/domain/channel/AccessTokenFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016 SUSE LLC + * Copyright (c) 2016--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.domain.channel; @@ -20,7 +16,9 @@ import com.redhat.rhn.domain.server.MinionServer; import com.redhat.rhn.taskomatic.task.TaskConstants; -import com.suse.manager.webui.utils.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenException; import com.suse.utils.Opt; import org.apache.logging.log4j.LogManager; @@ -29,7 +27,6 @@ import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -201,7 +198,7 @@ public static boolean refreshTokens(MinionServer minion, Collection try { return regenerate(token); } - catch (JoseException e) { + catch (TokenException e) { LOG.error("Could not regenerate token with id: {}", token.getId(), e); return token; } @@ -265,25 +262,21 @@ public static void delete(AccessToken token) { public static Optional generate(MinionServer minion, Set channels) { try { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(minion.getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.onlyChannels(channels.stream().map(Channel::getLabel) - .collect(Collectors.toSet())); - String tokenString = tokenBuilder.getToken(); + Token token = new DownloadTokenBuilder(minion.getOrg().getId()) + .usingServerSecret() + .allowingOnlyChannels(channels.stream().map(Channel::getLabel).collect(Collectors.toSet())) + .build(); AccessToken newToken = new AccessToken(); - newToken.setStart(Date.from(tokenBuilder.getIssuedAt())); - newToken.setToken(tokenString); + newToken.setStart(Date.from(token.getIssuingTime())); + newToken.setToken(token.getSerializedForm()); newToken.setMinion(minion); - Instant expiration = tokenBuilder.getIssuedAt() - .plus(tokenBuilder.getExpirationTimeMinutesInTheFuture(), - ChronoUnit.MINUTES); - newToken.setExpiration(Date.from(expiration)); + newToken.setExpiration(Date.from(token.getExpirationTime())); newToken.setChannels(channels); save(newToken); return Optional.of(newToken); } - catch (JoseException e) { + catch (TokenException e) { LOG.error("Could not generate token for minion: {}", minion.getId(), e); return Optional.empty(); } @@ -293,36 +286,32 @@ public static Optional generate(MinionServer minion, * Regenerated an access token by creating a new one with the same information. * If the token is linked to a minion it will be unlinked and linked to the new token. * - * @param token access token to regenerate + * @param accessToken access token to regenerate * @return the new access token * @throws JoseException if token generation fails in this case * the old token will not be unlinked. */ - public static AccessToken regenerate(AccessToken token) throws JoseException { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(token.getMinion().getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.onlyChannels(token.getChannels().stream().map(Channel::getLabel) - .collect(Collectors.toSet())); - String tokenString = tokenBuilder.getToken(); - - //Link new token + public static AccessToken regenerate(AccessToken accessToken) throws TokenException { + Token token = new DownloadTokenBuilder(accessToken.getMinion().getOrg().getId()) + .usingServerSecret() + .allowingOnlyChannels(accessToken.getChannels().stream().map(Channel::getLabel).collect(Collectors.toSet())) + .build(); + + // Link new token AccessToken newToken = new AccessToken(); - newToken.setStart(Date.from(tokenBuilder.getIssuedAt())); - newToken.setToken(tokenString); - newToken.setMinion(token.getMinion()); - Instant expiration = tokenBuilder.getIssuedAt() - .plus(tokenBuilder.getExpirationTimeMinutesInTheFuture(), - ChronoUnit.MINUTES); - newToken.setExpiration(Date.from(expiration)); + newToken.setStart(Date.from(token.getIssuingTime())); + newToken.setToken(token.getSerializedForm()); + newToken.setMinion(accessToken.getMinion()); + newToken.setExpiration(Date.from(token.getExpirationTime())); // We need to copy the collection here because hibernate does not like to share. - newToken.setChannels(new HashSet<>(token.getChannels())); + newToken.setChannels(new HashSet<>(accessToken.getChannels())); AccessTokenFactory.save(newToken); // Unlink the old token - token.setMinion(null); - token.setValid(false); - AccessTokenFactory.save(token); + accessToken.setMinion(null); + accessToken.setValid(false); + AccessTokenFactory.save(accessToken); return newToken; } diff --git a/java/code/src/com/redhat/rhn/domain/channel/ChannelFactory.java b/java/code/src/com/redhat/rhn/domain/channel/ChannelFactory.java index cde44589eeeb..a2b55bbccc95 100644 --- a/java/code/src/com/redhat/rhn/domain/channel/ChannelFactory.java +++ b/java/code/src/com/redhat/rhn/domain/channel/ChannelFactory.java @@ -1,6 +1,6 @@ /* * Copyright (c) 2009--2017 Red Hat, Inc. - * Copyright (c) 2011--2021 SUSE LLC + * Copyright (c) 2011--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -15,6 +15,7 @@ */ package com.redhat.rhn.domain.channel; +import com.redhat.rhn.common.conf.ConfigDefaults; import com.redhat.rhn.common.db.datasource.CallableMode; import com.redhat.rhn.common.db.datasource.DataResult; import com.redhat.rhn.common.db.datasource.ModeFactory; @@ -24,13 +25,26 @@ import com.redhat.rhn.common.hibernate.HibernateFactory; import com.redhat.rhn.domain.common.ChecksumType; import com.redhat.rhn.domain.org.Org; +import com.redhat.rhn.domain.org.OrgFactory; +import com.redhat.rhn.domain.product.ChannelTemplate; +import com.redhat.rhn.domain.product.SUSEProductFactory; import com.redhat.rhn.domain.rhnpackage.Package; import com.redhat.rhn.domain.scc.SCCRepository; import com.redhat.rhn.domain.server.Server; import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.xmlrpc.InvalidChannelLabelException; import com.redhat.rhn.manager.appstreams.AppStreamsManager; +import com.redhat.rhn.manager.content.ContentSyncManager; +import com.redhat.rhn.manager.content.MgrSyncUtils; import com.redhat.rhn.manager.ssm.SsmChannelDto; +import com.suse.manager.model.hub.CustomChannelInfoJson; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.ModifyCustomChannelInfoJson; +import com.suse.scc.SCCEndpoints; +import com.suse.scc.model.SCCRepositoryJson; + +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.hibernate.Session; @@ -43,10 +57,13 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; import javax.persistence.NoResultException; import javax.persistence.TypedQuery; @@ -66,6 +83,7 @@ public class ChannelFactory extends HibernateFactory { private static ChannelFactory singleton = new ChannelFactory(); private static Logger log = LogManager.getLogger(ChannelFactory.class); + private ChannelFactory() { super(); } @@ -81,6 +99,7 @@ protected Logger getLogger() { /** * Lookup a Channel by its id + * * @param id the id to search for * @return the Channel found */ @@ -91,7 +110,8 @@ public static Channel lookupById(Long id) { /** * Lookup a Channel by id and User - * @param id the id to search for + * + * @param id the id to search for * @param userIn User who is doing the looking * @return the Server found (null if not or not member if userIn) */ @@ -105,7 +125,8 @@ public static Channel lookupByIdAndUser(Long id, User userIn) { /** * Lookup a Channel by label and User - * @param label the label to search for + * + * @param label the label to search for * @param userIn User who is doing the looking * @return the Server found (null if not or not member if userIn) */ @@ -119,6 +140,7 @@ public static Channel lookupByLabelAndUser(String label, User userIn) { /** * Lookup a content source type by label + * * @param label the label to lookup * @return the ContentSourceType */ @@ -128,6 +150,7 @@ public static ContentSourceType lookupContentSourceType(String label) { /** * List all available content source types + * * @return list of ContentSourceType */ public static List listContentSourceTypes() { @@ -136,6 +159,7 @@ public static List listContentSourceTypes() { /** * Lookup a content source by org + * * @param org the org to lookup * @return the ContentSource(s) */ @@ -145,6 +169,7 @@ public static List lookupContentSources(Org org) { /** * Lookup orphan vendor content source + * * @return the ContentSource(s) */ public static List lookupOrphanVendorContentSources() { @@ -153,6 +178,7 @@ public static List lookupOrphanVendorContentSources() { /** * Lookup orphan vendor channels + * * @return the Channels(s) */ public static List lookupOrphanVendorChannels() { @@ -170,6 +196,7 @@ public static void cleanupOrphanVendorContentSource() { /** * Lookup repository for given channel + * * @param c the channel * @return repository */ @@ -178,10 +205,32 @@ public static Optional findVendorRepositoryByChannel(Channel c) { Map.of("cid", c.getId()))); } + /** + * List all available Vendor Channels created from a given SCC repository ID + * + * @param sccId the SCC Repository ID + * @return return a list of available {@link Channel}s for this SCC repository ID + */ + public static List findVendorChannelBySccId(Long sccId) { + return getSession().createNativeQuery(""" + SELECT c.*, cl.original_id, + CASE WHEN cl.original_id IS NULL THEN 0 ELSE 1 END AS clazz_ + FROM suseSCCRepository r + JOIN suseChannelTemplate ct ON r.id = ct.repo_id + JOIN rhnChannel c on c.label = ct.channel_label + LEFT JOIN rhnChannelCloned cl ON c.id = cl.id + WHERE r.scc_id = :sccId + ORDER BY c.label + """, Channel.class) + .setParameter("sccId", sccId) + .list(); + } + /** * Lookup a content source by org/channel + * * @param org the org to lookup - * @param c the channel + * @param c the channel * @return the ContentSource(s) */ public static List lookupContentSources(Org org, Channel c) { @@ -198,7 +247,8 @@ public static List lookupContentSources(Org org, Channel c) { /** * Lookup a content source by org and label - * @param org the org to lookup + * + * @param org the org to lookup * @param label repo label * @return the ContentSource(s) */ @@ -209,6 +259,7 @@ public static ContentSource lookupContentSourceByOrgAndLabel(Org org, String lab /** * Lookup a Vendor content source (org is NULL) by label + * * @param label repo label * @return the ContentSource(s) */ @@ -219,9 +270,10 @@ public static ContentSource lookupVendorContentSourceByLabel(String label) { /** * Lookup a content source by org and repo - * @param org the org to lookup + * + * @param org the org to lookup * @param repoType repo type - * @param repoUrl repo url + * @param repoUrl repo url * @return the ContentSource(s) */ public static List lookupContentSourceByOrgAndRepo(Org org, @@ -232,7 +284,8 @@ public static List lookupContentSourceByOrgAndRepo(Org org, /** * lookup content source by id and org - * @param id id of content source + * + * @param id id of content source * @param orgIn org to check * @return content source */ @@ -242,6 +295,7 @@ public static ContentSource lookupContentSource(Long id, Org orgIn) { /** * Lookup a content source's filters by id + * * @param id source id * @return the ContentSourceFilters */ @@ -251,6 +305,7 @@ public static List lookupContentSourceFiltersById(Long id) /** * Retrieve a list of channel ids associated with the labels provided + * * @param labelsIn the labels to search for * @return list of channel ids */ @@ -265,6 +320,7 @@ public static List getChannelIds(List labelsIn) { /** * Insert or Update a Channel. + * * @param c Channel to be stored in database. */ public static void save(Channel c) { @@ -274,6 +330,7 @@ public static void save(Channel c) { /** * Insert or Update a content source. + * * @param c content source to be stored in database. */ public static void save(ContentSource c) { @@ -282,6 +339,7 @@ public static void save(ContentSource c) { /** * Insert or Update a DistChannelMap. + * * @param dcm DistChannelMap to be stored in database. */ public static void save(DistChannelMap dcm) { @@ -290,6 +348,7 @@ public static void save(DistChannelMap dcm) { /** * Insert or Update a content source filter. + * * @param f content source filter to be stored in database. */ public static void save(ContentSourceFilter f) { @@ -298,6 +357,7 @@ public static void save(ContentSourceFilter f) { /** * Remove a Channel from the DB + * * @param c Action to be removed from database. */ public static void remove(Channel c) { @@ -317,6 +377,7 @@ public static void remove(Channel c) { /** * Remove a DistChannelMap from the DB + * * @param dcm Action to be removed from database. */ public static void remove(DistChannelMap dcm) { @@ -325,6 +386,7 @@ public static void remove(DistChannelMap dcm) { /** * Remove a Content Source from the DB + * * @param src to be removed from database */ public static void remove(ContentSource src) { @@ -333,6 +395,7 @@ public static void remove(ContentSource src) { /** * Remove a ContentSourceFilter from the DB + * * @param filter to be removed from database */ public static void remove(ContentSourceFilter filter) { @@ -341,6 +404,7 @@ public static void remove(ContentSourceFilter filter) { /** * Returns the base channel for the given server id. + * * @param sid Server id whose base channel we want. * @return Base Channel for the given server id. */ @@ -350,6 +414,7 @@ public static Channel getBaseChannel(Long sid) { /** * Returns a list of Channels which have clonable errata. + * * @param org Org. * @return List of com.redhat.rhn.domain.Channel objects which have * clonable errata. @@ -360,8 +425,9 @@ public static List getChannelsWithClonableErrata(Org org) { /** * Returns the list of Channel ids which the given orgid has access to. + * * @param orgid Org id - * @param cid Base Channel id. + * @param cid Base Channel id. * @return the list of Channel ids which the given orgid has access to. */ public static List getUserAcessibleChannels(Long orgid, Long cid) { @@ -371,8 +437,9 @@ public static List getUserAcessibleChannels(Long orgid, Long cid) { /** * Returns the accessible child channels associated to a base channel. + * * @param baseChannel the base channel who's child channels are needed - * @param user the user requesting the info.. (has to be globally subscribed etc.) + * @param user the user requesting the info.. (has to be globally subscribed etc.) * @return the accessible child channels.. */ public static List getAccessibleChildChannels(Channel baseChannel, User user) { @@ -383,6 +450,7 @@ public static List getAccessibleChildChannels(Channel baseChannel, User /** * Returns the list of Channels accessible by an org * Channels are accessible if they are owned by an org or public. + * * @param orgid The id for the org * @return A list of Channel Objects. */ @@ -392,6 +460,7 @@ public static List getAccessibleChannelsByOrg(Long orgid) { /** * Returns list of channel architectures + * * @return list of channel architectures */ public static List getChannelArchitectures() { @@ -400,12 +469,13 @@ public static List getChannelArchitectures() { /** * Checks if a channel is accessible by an Org. + * * @param channelLabel the channel label - * @param orgId the Org ID + * @param orgId the Org ID * @return true if it is accessible */ public static boolean isAccessibleBy(String channelLabel, Long orgId) { - return (int)singleton.lookupObjectByNamedQuery("Channel.isAccessibleBy", + return (int) singleton.lookupObjectByNamedQuery("Channel.isAccessibleBy", Map.of("channel_label", channelLabel, ORG_ID, orgId)) > 0; } @@ -413,7 +483,7 @@ public static boolean isAccessibleBy(String channelLabel, Long orgId) { * Checks if a channel is accessible by a User. * * @param channelLabel the channel label - * @param userId user id + * @param userId user id * @return true if it is accessible */ public static boolean isAccessibleByUser(String channelLabel, Long userId) { @@ -423,6 +493,7 @@ public static boolean isAccessibleByUser(String channelLabel, Long userId) { /** * returns a ChannelArch by label + * * @param label ChannelArch label * @return a ChannelArch by label */ @@ -436,7 +507,8 @@ public static ChannelArch findArchByLabel(String label) { /** * Returns the Channel whose label matches the given label. - * @param org The org of the user looking up the channel + * + * @param org The org of the user looking up the channel * @param label Channel label sought. * @return the Channel whose label matches the given label. */ @@ -449,6 +521,7 @@ public static Channel lookupByLabel(Org org, String label) { * Returns the Channel whose label matches the given label. * This was added to allow taskomatic to lookup channels by label, * and should NOT be used from the webui. + * * @param label Channel label sought. * @return the Channel whose label matches the given label. */ @@ -472,8 +545,9 @@ public static Channel lookupByLabel(String label) { /** * Returns true if the given channel is globally subscribable for the * given org. + * * @param org Org - * @param c Channel to validate. + * @param c Channel to validate. * @return true if the given channel is globally subscribable for the */ public static boolean isGloballySubscribable(Org org, Channel c) { @@ -494,10 +568,11 @@ public static boolean isGloballySubscribable(Org org, Channel c) { /** * Set the globally subscribable attribute for a given channel - * @param org The org containing the channel + * + * @param org The org containing the channel * @param channel The channel in question - * @param value True to make the channel globally subscribable, false to make it not - * globally subscribable. + * @param value True to make the channel globally subscribable, false to make it not + * globally subscribable. */ public static void setGloballySubscribable(Org org, Channel channel, boolean value) { //we need to check here, otherwise if we try to remove and it's already removed @@ -521,13 +596,14 @@ public static void setGloballySubscribable(Org org, Channel channel, boolean val /** * Remove an org-channel setting - * @param org The org in question + * + * @param org The org in question * @param channel The channel in question - * @param label the label of the setting to remove + * @param label the label of the setting to remove */ private static void removeOrgChannelSetting(Org org, Channel channel, String label) { WriteMode m = ModeFactory.getWriteMode(CHANNEL_QUERIES, - "remove_org_channel_setting"); + "remove_org_channel_setting"); Map params = new HashMap<>(); params.put(ORG_ID, org.getId()); params.put("cid", channel.getId()); @@ -537,13 +613,14 @@ private static void removeOrgChannelSetting(Org org, Channel channel, String lab /** * Adds an org-channel setting - * @param org The org in question + * + * @param org The org in question * @param channel The channel in question - * @param label the label of the setting to add + * @param label the label of the setting to add */ private static void addOrgChannelSetting(Org org, Channel channel, String label) { WriteMode m = ModeFactory.getWriteMode(CHANNEL_QUERIES, - "add_org_channel_setting"); + "add_org_channel_setting"); Map params = new HashMap<>(); params.put(ORG_ID, org.getId()); params.put("cid", channel.getId()); @@ -552,13 +629,12 @@ private static void addOrgChannelSetting(Org org, Channel channel, String label) } /** - * * @param cid Channel package is being added to * @param pid Package id from rhnPackage */ public static void addChannelPackage(Long cid, Long pid) { WriteMode m = ModeFactory.getWriteMode(CHANNEL_QUERIES, - "add_channel_package"); + "add_channel_package"); Map params = new HashMap<>(); params.put("cid", cid); params.put("pid", pid); @@ -567,6 +643,7 @@ public static void addChannelPackage(Long cid, Long pid) { /** * Creates empty SSL set for repository + * * @return empty SSL set */ public static SslContentSource createRepoSslSet() { @@ -593,7 +670,7 @@ public static void refreshNewestPackageCache(Channel c, String label) { */ public static void refreshNewestPackageCache(Long channelId, String label) { CallableMode m = ModeFactory.getCallableMode(CHANNEL_QUERIES, - "refresh_newest_package"); + "refresh_newest_package"); Map inParams = new HashMap<>(); inParams.put("cid", channelId); inParams.put(LABEL, label); @@ -605,11 +682,11 @@ public static void refreshNewestPackageCache(Long channelId, String label) { * Clones the "newest" channel packages according to clone. * * @param fromChannelId original channel id - * @param toChannelId cloned channle id + * @param toChannelId cloned channle id */ public static void cloneNewestPackageCache(Long fromChannelId, Long toChannelId) { WriteMode m = ModeFactory.getWriteMode(CHANNEL_QUERIES, - "clone_newest_package"); + "clone_newest_package"); Map params = new HashMap<>(); params.put("from_cid", fromChannelId); params.put("to_cid", toChannelId); @@ -618,6 +695,7 @@ public static void cloneNewestPackageCache(Long fromChannelId, Long toChannelId) /** * Returns true if the given label is in use. + * * @param label Label * @return true if the given label is in use. */ @@ -631,6 +709,7 @@ public static boolean doesChannelLabelExist(String label) { /** * Returns true if the given name is in use. + * * @param name name * @return true if the given name is in use. */ @@ -645,6 +724,7 @@ public static boolean doesChannelNameExist(String name) { /** * Return a list of kickstartable tree channels, i.e. channels that can * be used for creating kickstartable trees (distributions). + * * @param org org * @return list of channels */ @@ -656,6 +736,7 @@ public static List getKickstartableTreeChannels(Org org) { /** * Return a list of channels that are kickstartable to the Org passed in, * i.e. channels that can be used for creating kickstart profiles. + * * @param org org * @return list of channels */ @@ -666,6 +747,7 @@ public static List getKickstartableChannels(Org org) { /** * Get a list of base channels that have an org associated + * * @param user the logged in user * @return List of Channels */ @@ -675,6 +757,7 @@ public static List listCustomBaseChannels(User user) { /** * Find yum supported checksum types + * * @return List of ChecksumTypes instances */ public static List listYumSupportedChecksums() { @@ -683,17 +766,18 @@ public static List listYumSupportedChecksums() { /** * Get a list of modular channels in users org + * * @param user the logged in user * @return List of modular channels */ public static List listModularChannels(User user) { - List channels = singleton.listObjectsByNamedQuery("Channel.findModularChannels", - Map.of("org_id", user.getOrg().getId())); - return channels; + return singleton.listObjectsByNamedQuery("Channel.findModularChannels", + Map.of(ORG_ID, user.getOrg().getId())); } /** * Find checksumtype by label + * * @param checksum checksum label * @return ChecksumType instance for given label */ @@ -707,6 +791,7 @@ public static ChecksumType findChecksumTypeByLabel(String checksum) { /** * Get a list of packages ids that are in a channel and in a list of errata. * (The errata do not necessarily have to be associate with the channel) + * * @param chan the channel * @param eids the errata ids * @return list of package ids @@ -719,6 +804,7 @@ public static List getChannelPackageWithErrata(Channel chan, Collection getPackageIds(Long cid) { /** * Get cloned errata ids for a channel + * * @param cid the channel id * @return List of errata ids */ @@ -767,6 +856,7 @@ public static List getClonedErrataIds(Long cid) { /** * Looksup the number of Packages in a channel + * * @param channel the Channel who's package count you are interested in. * @return number of packages in this channel. */ @@ -776,6 +866,7 @@ public static int getPackageCount(Channel channel) { /** * Get the errata count for a channel + * * @param channel the channel * @return the errata count as an int */ @@ -805,6 +896,7 @@ public static ReleaseChannelMap lookupDefaultReleaseChannelMapForChannel(Channel /** * Lookup ChannelSyncFlag object for a specfic channel + * * @param channel The channel object on which the lookup should be performed * @return ChannelSyncFlag object containing all flag settings for a specfic channel */ @@ -818,6 +910,7 @@ public static ChannelSyncFlag lookupChannelReposyncFlag(Channel channel) { /** * Save a ChannelSyncFlag object for a specfic channel + * * @param flags The ChannelSyncFlag object which should be added to channel */ public static void save(ChannelSyncFlag flags) { @@ -826,7 +919,7 @@ public static void save(ChannelSyncFlag flags) { /** * List all defined dist channel maps - * + *

* Returns empty array if none is found. * * @return DistChannelMap[], empty if none is found @@ -837,6 +930,7 @@ public static List listAllDistChannelMaps() { /** * Lists all dist channel maps for an user organization + * * @param org organization * @return list of dist channel maps */ @@ -857,9 +951,10 @@ public static DistChannelMap lookupDistChannelMapById(Long id) { /** * Lookup the dist channel map for the given product name, release, and channel arch. * Returns null if none is found. - * @param org organization + * + * @param org organization * @param productName Product name. - * @param release Version. + * @param release Version. * @param channelArch Channel arch. * @return DistChannelMap, null if none is found */ @@ -874,8 +969,8 @@ public static DistChannelMap lookupDistChannelMapByPnReleaseArch( * Lookup the dist channel map for the given organization according to release and channel arch. * Returns null if none is found. * - * @param org organization - * @param release release + * @param org organization + * @param release release * @param channelArch Channel arch. * @return DistChannelMap, null if none is found */ @@ -888,6 +983,7 @@ public static DistChannelMap lookupDistChannelMapByOrgReleaseArch(Org org, Strin /** * Lists compatible dist channel mappings for a server available within an organization * Returns empty list if none is found. + * * @param server server * @return list of dist channel mappings, empty list if none is found */ @@ -899,7 +995,8 @@ public static List listCompatibleDcmByServerInNullOrg(Server ser /** * Lists *common* compatible channels for all SSM systems subscribed to a common base * Returns empty list if none is found. - * @param user user + * + * @param user user * @param channel channel * @return list of compatible channels, empty list if none is found */ @@ -911,6 +1008,7 @@ public static List listCompatibleDcmForChannelSSMInNullOrg(User user, C /** * Lists *common* compatible channels for all SSM systems subscribed to a common base * Returns empty list if none is found. + * * @param user user * @return list of compatible channels, empty list if none is found */ @@ -922,7 +1020,8 @@ public static List listCompatibleBasesForSSMNoBaseInNullOrg(User user) /** * Lists *common* custom compatible channels * for all SSM systems subscribed to a common base - * @param user user + * + * @param user user * @param channel channel * @return List of channels. */ @@ -934,6 +1033,7 @@ public static List listCustomBaseChannelsForSSM(User user, Channel chan /** * Lists *common* custom compatible channels * for all SSM systems without base channel + * * @param user user * @return List of channels. */ @@ -946,7 +1046,7 @@ public static List listCustomBaseChannelsForSSMNoBase(User user) { * Find child channels that can be subscribed by the user and have the arch compatible * with the servers in the SSM. * - * @param user user + * @param user user * @param parentChannelId id of the parent channel * @return List of child channel ids. */ @@ -956,7 +1056,7 @@ public static List findChildChannelsByParentInSSM(User user, long return res .stream() .map(Arrays::asList) - .map(r -> new SsmChannelDto((long)r.get(0), (String)r.get(1), r.get(2) != null)) + .map(r -> new SsmChannelDto((long) r.get(0), (String) r.get(1), r.get(2) != null)) .toList(); } @@ -976,7 +1076,7 @@ public static List listDistChannelMaps(Channel c) { /** * All channels (including children) based on the following rules - * + *

* 1) Base channels are listed first * 2) Parent channels are ordered by label * 3) Child channels are listed right after the corresponding parent, and ordered by label @@ -992,6 +1092,7 @@ public static List findAllByUserOrderByChild(User user) { /** * Get a list of channels with no org that are not a child + * * @return List of Channels */ public static List listRedHatBaseChannels() { @@ -1001,6 +1102,7 @@ public static List listRedHatBaseChannels() { /** * List all accessible Red Hat base channels for a given user + * * @param user logged in user * @return list of Red Hat base channels */ @@ -1011,6 +1113,7 @@ public static List listRedHatBaseChannels(User user) { /** * Lookup the original channel of a cloned channel + * * @param chan the channel to find the original of * @return The channel that was cloned, null if none */ @@ -1034,6 +1137,7 @@ public static ProductName lookupProductNameByLabel(String label) { /** * Returns a distinct list of ChannelArch labels for all synch'd and custom * channels in the satellite. + * * @return a distinct list of ChannelArch labels for all synch'd and custom * channels in the satellite. */ @@ -1043,6 +1147,7 @@ public static List findChannelArchLabelsSyncdChannels() { /** * List all accessible base channels for an org + * * @param user logged in user. * @return list of custom channels */ @@ -1053,6 +1158,7 @@ public static List listSubscribableBaseChannels(User user) { /** * List all accessible base channels for an org + * * @param user logged in user. * @return list of custom channels */ @@ -1063,6 +1169,7 @@ public static List listAllBaseChannels(User user) { /** * List all accessible base channels for the entire satellite + * * @return list of base channels */ public static List listAllBaseChannels() { @@ -1072,6 +1179,7 @@ public static List listAllBaseChannels() { /** * List all child channels of the given parent regardless of the user + * * @param parent the parent channel * @return list of children of the parent */ @@ -1081,18 +1189,19 @@ public static List listAllChildrenForChannel(Channel parent) { /** * Lookup a Package based on the channel and package file name - * @param channel to look in + * + * @param channel to look in * @param fileName to look up * @return Package if found */ public static Package lookupPackageByFilename(Channel channel, - String fileName) { + String fileName) { List pkgs = HibernateFactory.getSession() - .getNamedQuery("Channel.packageByFileName") - .setParameter("pathlike", "%/" + fileName, StandardBasicTypes.STRING) - .setParameter("channel_id", channel.getId(), StandardBasicTypes.LONG) - .list(); + .getNamedQuery("Channel.packageByFileName") + .setParameter("pathlike", "%/" + fileName, StandardBasicTypes.STRING) + .setParameter("channel_id", channel.getId(), StandardBasicTypes.LONG) + .list(); if (pkgs.isEmpty()) { return null; } @@ -1101,14 +1210,15 @@ public static Package lookupPackageByFilename(Channel channel, /** * Lookup a Package based on the channel, package file name and range - * @param channel to look in - * @param fileName to look up + * + * @param channel to look in + * @param fileName to look up * @param headerStart start of header - * @param headerEnd end of header + * @param headerEnd end of header * @return Package if found */ public static Package lookupPackageByFilenameAndRange(Channel channel, - String fileName, int headerStart, int headerEnd) { + String fileName, int headerStart, int headerEnd) { List pkgs = HibernateFactory.getSession() .getNamedQuery("Channel.packageByFileNameAndRange") @@ -1127,6 +1237,7 @@ public static Package lookupPackageByFilenameAndRange(Channel channel, /** * Method to check if the channel contains any kickstart distributions * associated to it. + * * @param ch the channel to check distros on * @return true of the channels contains any distros */ @@ -1141,6 +1252,7 @@ public static boolean containsDistributions(Channel ch) { /** * Clear a content source's filters + * * @param id source id */ public static void clearContentSourceFilters(Long id) { @@ -1157,7 +1269,8 @@ public static void clearContentSourceFilters(Long id) { /** * returns channel manager id for given channel - * @param org given organization + * + * @param org given organization * @param channelId channel id * @return list of channel managers */ @@ -1177,7 +1290,8 @@ public static List listManagerIdsForChannel(Org org, Long channelId) { /** * returns channel subscriber id for given channel - * @param org given organization + * + * @param org given organization * @param channelId channel id * @return list of channel subscribers */ @@ -1197,6 +1311,7 @@ public static List listSubscriberIdsForChannel(Org org, Long channelId) { /** * Locks the given Channel for update on a database level + * * @param c Channel to lock */ public static void lock(Channel c) { @@ -1205,8 +1320,9 @@ public static void lock(Channel c) { /** * Adds errata to channel mapping. Does nothing else + * * @param eids List of eids to add mappings for - * @param cid channel id we're cloning into + * @param cid channel id we're cloning into */ public static void addErrataToChannel(Set eids, Long cid) { WriteMode m = ModeFactory.getWriteMode(CHANNEL_QUERIES, @@ -1219,8 +1335,18 @@ public static void addErrataToChannel(Set eids, Long cid) { } } + /** + * List all channels + * + * @return list of all channels + */ + public static List listAllChannels() { + return getSession().createQuery("FROM Channel c", Channel.class).getResultList(); + } + /** * List all vendor channels (org is null) + * * @return list of vendor channels */ public static List listVendorChannels() { @@ -1231,23 +1357,35 @@ public static List listVendorChannels() { return new ArrayList<>(); } + /** + * Return a list of all custom channels (org is not null) + * @return the list of custom channels + */ + public static List listCustomChannels() { + return getSession() + .createQuery("FROM Channel c WHERE c.org IS NOT NULL", Channel.class) + .getResultList(); + } + /** * List all custom channels (org is not null) with at least one repository + * * @return list of vendor channels */ public static List listCustomChannelsWithRepositories() { List result = - singleton.listObjectsByNamedQuery("Channel.findCustomChannelsWithRepositories", Map.of()); + singleton.listObjectsByNamedQuery("Channel.findCustomChannelsWithRepositories", Map.of()); if (result != null) { return result; } return new ArrayList<>(); } + /** * List all vendor content sources (org is null) + * * @return list of vendor content sources */ - @SuppressWarnings("unchecked") public static List listVendorContentSources() { return getSession().createNativeQuery("SELECT * FROM rhnContentSource WHERE org_id IS NULL", ContentSource.class).getResultList(); @@ -1255,6 +1393,7 @@ public static List listVendorContentSources() { /** * Find a vendor content source (org is null) for a given repo URL. + * * @param repoUrl url to match against * @return vendor content source if it exists */ @@ -1297,25 +1436,9 @@ public static ContentSource findVendorContentSourceByRepo(String repoUrl) { return contentSource; } - /** - * Find {@link ContentSource} with source url containing urlPart. - * Uses SQL wildcard paramter '%'. When urlPart does contain a wildcard parameter, it is passed directly to - * the query. If not, a wildcard is added and the begining and the end. - * @param urlPart part of the url - * @return list of found {@link ContentSource} - */ - public static List findContentSourceLikeUrl(String urlPart) { - String urllike = urlPart; - if (!urlPart.contains("%")) { - urllike = String.format("%%%s%%", urlPart); - } - return getSession().createNamedQuery("ContentSource.findLikeUrl", ContentSource.class) - .setParameter("urllike", urllike) - .list(); - } - /** * Find a {@link ChannelProduct} for given name and version. + * * @param product the product * @param version the version * @return channel product @@ -1337,6 +1460,7 @@ public static ChannelProduct findChannelProduct(String product, String version) /** * Insert or update a {@link ChannelProduct}. + * * @param channelProduct ChannelProduct to be stored in database. */ public static void save(ChannelProduct channelProduct) { @@ -1345,6 +1469,7 @@ public static void save(ChannelProduct channelProduct) { /** * Insert or update a {@link ProductName}. + * * @param productName ProductName to be stored in database. */ public static void save(ProductName productName) { @@ -1403,7 +1528,7 @@ public static void analyzeServerNeededCache() { * Sets channel modules data from given channel. * * @param from the source Channel - * @param to the target Channel + * @param to the target Channel */ public static void cloneModulesMetadata(Channel from, Channel to) { if (!from.isModular()) { @@ -1425,4 +1550,361 @@ public static void cloneModulesMetadata(Channel from, Channel to) { to.getModules().setRelativeFilename(from.getModules().getRelativeFilename()); } } + + /** + * Converts a custom channel to a custom channel info structure + * + * @param customChannel the custom channel to be converted + * @param peripheralOrgId the peripheral org id this channel will be assigned to in the peripheral + * @param forcedOriginalChannelLabel an optional string setting the original of a cloned channel, + * instead of the pristine one + * @return CustomChannelInfoJson the converted info of the custom channel + */ + public static CustomChannelInfoJson toCustomChannelInfo(Channel customChannel, long peripheralOrgId, + Optional forcedOriginalChannelLabel) { + + if (!customChannel.isCustom()) { + throw new IllegalArgumentException("Channel [" + customChannel.getLabel() + "] is not custom"); + } + + CustomChannelInfoJson customChannelInfo = new CustomChannelInfoJson(customChannel.getLabel()); + + customChannelInfo.setPeripheralOrgId(peripheralOrgId); + + String parentChannelLabel = (null == customChannel.getParentChannel()) ? null : + customChannel.getParentChannel().getLabel(); + customChannelInfo.setParentChannelLabel(parentChannelLabel); + + String channelArchLabel = (null == customChannel.getChannelArch()) ? null : + customChannel.getChannelArch().getLabel(); + customChannelInfo.setChannelArchLabel(channelArchLabel); + + customChannelInfo.setBaseDir(customChannel.getBaseDir()); + customChannelInfo.setName(customChannel.getName()); + customChannelInfo.setSummary(customChannel.getSummary()); + customChannelInfo.setDescription(customChannel.getDescription()); + + String productNameLabel = (null == customChannel.getProductName()) ? null : + customChannel.getProductName().getLabel(); + customChannelInfo.setProductNameLabel(productNameLabel); + + customChannelInfo.setGpgCheck(customChannel.isGPGCheck()); + customChannelInfo.setGpgKeyUrl(customChannel.getGPGKeyUrl()); + customChannelInfo.setGpgKeyId(customChannel.getGPGKeyId()); + customChannelInfo.setGpgKeyFp(customChannel.getGPGKeyFp()); + + customChannelInfo.setEndOfLifeDate(customChannel.getEndOfLife()); + customChannelInfo.setChecksumTypeLabel(customChannel.getChecksumTypeLabel()); + + String channelProductProduct = (null == customChannel.getProduct()) ? null : + customChannel.getProduct().getProduct(); + customChannelInfo.setChannelProductProduct(channelProductProduct); + String channelProductVersion = (null == customChannel.getProduct()) ? null : + customChannel.getProduct().getVersion(); + customChannelInfo.setChannelProductVersion(channelProductVersion); + + customChannelInfo.setChannelAccess(customChannel.getAccess()); + customChannelInfo.setMaintainerName(customChannel.getMaintainerName()); + customChannelInfo.setMaintainerEmail(customChannel.getMaintainerEmail()); + customChannelInfo.setMaintainerPhone(customChannel.getMaintainerPhone()); + customChannelInfo.setSupportPolicy(customChannel.getSupportPolicy()); + customChannelInfo.setUpdateTag(customChannel.getUpdateTag()); + customChannelInfo.setInstallerUpdates(customChannel.isInstallerUpdates()); + + String originalChannelLabel = customChannel.asCloned() + .map(clonedChannel -> + forcedOriginalChannelLabel.orElseGet(() -> clonedChannel.getOriginal().getLabel())) + .orElse(null); + customChannelInfo.setOriginalChannelLabel(originalChannelLabel); + + // obtain repository info + String hostname = ConfigDefaults.get().getJavaHostname(); + String customChannelLabel = customChannel.getLabel(); + + Optional tokenString = SCCEndpoints.buildHubRepositoryToken(customChannelLabel); + if (tokenString.isPresent()) { + SCCRepositoryJson repositoryInfo = SCCEndpoints.buildCustomRepoJson(customChannelLabel, hostname, + tokenString.get()); + customChannelInfo.setRepositoryInfo(repositoryInfo); + } + + return customChannelInfo; + } + + /** + * Converts a custom channel info structure to a custom channel + * + * @param customChannelInfo the custom channel info to be converted + * @return customChannel the converted custom channel + */ + public static Channel toCustomChannel(CustomChannelInfoJson customChannelInfo) { + Org org = OrgFactory.lookupById(customChannelInfo.getPeripheralOrgId()); + Channel parentChannel = ChannelFactory.lookupByLabel(customChannelInfo.getParentChannelLabel()); + ChannelArch channelArch = ChannelFactory.findArchByLabel(customChannelInfo.getChannelArchLabel()); + ChecksumType checksumType = ChannelFactory.findChecksumTypeByLabel(customChannelInfo.getChecksumTypeLabel()); + + Channel customChannel = new Channel(); + if (StringUtils.isNotEmpty(customChannelInfo.getOriginalChannelLabel())) { + Channel originalChannel = ChannelFactory.lookupByLabel(customChannelInfo.getOriginalChannelLabel()); + + ClonedChannel temp = new ClonedChannel(); + temp.setOriginal(originalChannel); + customChannel = temp; + } + + customChannel.setOrg(org); + customChannel.setParentChannel(parentChannel); + customChannel.setChannelArch(channelArch); + + customChannel.setLabel(customChannelInfo.getLabel()); + customChannel.setBaseDir(customChannelInfo.getBaseDir()); + customChannel.setName(customChannelInfo.getName()); + customChannel.setSummary(customChannelInfo.getSummary()); + customChannel.setDescription(customChannelInfo.getDescription()); + + customChannel.setProductName(MgrSyncUtils.findOrCreateProductName(customChannelInfo.getProductNameLabel())); + + customChannel.setGPGCheck(customChannelInfo.isGpgCheck()); + customChannel.setGPGKeyUrl(customChannelInfo.getGpgKeyUrl()); + customChannel.setGPGKeyId(customChannelInfo.getGpgKeyId()); + customChannel.setGPGKeyFp(customChannelInfo.getGpgKeyFp()); + + customChannel.setEndOfLife(customChannelInfo.getEndOfLifeDate()); + customChannel.setChecksumType(checksumType); + + customChannel.setProduct(MgrSyncUtils.findOrCreateChannelProduct( + customChannelInfo.getChannelProductProduct(), customChannelInfo.getChannelProductVersion())); + + customChannel.setAccess(customChannelInfo.getChannelAccess()); + customChannel.setMaintainerName(customChannelInfo.getMaintainerName()); + customChannel.setMaintainerEmail(customChannelInfo.getMaintainerEmail()); + customChannel.setMaintainerPhone(customChannelInfo.getMaintainerPhone()); + customChannel.setSupportPolicy(customChannelInfo.getSupportPolicy()); + customChannel.setUpdateTag(customChannelInfo.getUpdateTag()); + customChannel.setInstallerUpdates(customChannelInfo.isInstallerUpdates()); + + // rebuild repository + ContentSyncManager csm = new ContentSyncManager(); + HubFactory hubFactory = new HubFactory(); + csm.refreshCustomRepo(List.of(customChannelInfo.getRepositoryInfo()), hubFactory.lookupIssHub().orElse(null)); + + return customChannel; + } + + /** + * Ensures that the vendor channels json info structure is valid, as well as all consequent data + * + * @param vendorChannelLabelList list of vendor channel labels to be checked + * @throws IllegalArgumentException if something is wrong + */ + public static void ensureValidVendorChannels(List vendorChannelLabelList) { + for (String vendorChannelLabel : vendorChannelLabelList) { + Optional vendorChannelTemplate = SUSEProductFactory + .lookupByChannelLabelFirst(vendorChannelLabel); + + if (vendorChannelTemplate.isEmpty()) { + throw new InvalidChannelLabelException(vendorChannelLabel, + InvalidChannelLabelException.Reason.IS_MISSING, + "Invalid data: vendor channel label not found", vendorChannelLabel); + } + } + } + + /** + * Ensures that the custom channels json info structure is valid, as well as all consequent data + * + * @param customChannelInfoJsonList list of custom channel info structures to be checked + * @throws IllegalArgumentException if something is wrong + */ + public static void ensureValidCustomChannels(List customChannelInfoJsonList) { + for (CustomChannelInfoJson customChannelInfo : customChannelInfoJsonList) { + + if (ChannelFactory.doesChannelLabelExist(customChannelInfo.getLabel())) { + throw new IllegalArgumentException("Channel already found with the same label " + + "for custom channel [" + customChannelInfo.getLabel() + "]"); + } + + ensureValidOrgIds(customChannelInfoJsonList); + + ensureExistingOrAboutToCreate(customChannelInfoJsonList, false, "channel arch", + CustomChannelInfoJson::getChannelArchLabel, ChannelFactory::findArchByLabel, null); + + ensureExistingOrAboutToCreate(customChannelInfoJsonList, false, "checksum type", + CustomChannelInfoJson::getChecksumTypeLabel, ChannelFactory::findChecksumTypeByLabel, null); + + ensureExistingOrAboutToCreate(customChannelInfoJsonList, true, "parent channel", + CustomChannelInfoJson::getParentChannelLabel, ChannelFactory::lookupByLabel, + CustomChannelInfoJson::getLabel); + + ensureExistingOrAboutToCreate(customChannelInfoJsonList, true, "original channel", + CustomChannelInfoJson::getOriginalChannelLabel, ChannelFactory::lookupByLabel, + CustomChannelInfoJson::getLabel); + } + } + + private static + void ensureExistingOrAboutToCreate(List customChannelInfoJsonList, + boolean emptyIsAllowed, String informationTypeString, + Function searchLabelMethod, + Function lookupLabelMethod, + Function accumulateFollowingMethod) { + Set accumulationSet = new HashSet<>(); + + for (T customChannelInfo : customChannelInfoJsonList) { + String searchLabel = searchLabelMethod.apply(customChannelInfo); + boolean isEmptySearchLabel = StringUtils.isEmpty(searchLabel); + + if (isEmptySearchLabel && (!emptyIsAllowed)) { + throw new IllegalArgumentException(String.format("Custom channel searchLabel [%s] must have valid %s", + customChannelInfo.getLabel(), informationTypeString)); + } + + if ((!isEmptySearchLabel) && (!accumulationSet.contains(searchLabel))) { + if (null == lookupLabelMethod.apply(searchLabel)) { + throw new IllegalArgumentException(String.format("No %s named [%s] for custom channel [%s]", + informationTypeString, searchLabel, customChannelInfo.getLabel())); + } + accumulationSet.add(searchLabel); + } + + //when looking to a parent or original channel, this channel can be a parent/original of the following ones + if (null != accumulateFollowingMethod) { + accumulationSet.add(accumulateFollowingMethod.apply(customChannelInfo)); + } + } + } + + private static void ensureValidOrgIds(List customChannelInfoJsonList) { + Set orgSet = new HashSet<>(); + + for (T customChannelInfo : customChannelInfoJsonList) { + Long orgId = customChannelInfo.getPeripheralOrgId(); + + if (null == orgId) { + throw new IllegalArgumentException("Custom channel info [" + + customChannelInfo.getLabel() + "] must have a defined OrgId"); + } + + if (!orgSet.contains(orgId)) { + Org org = OrgFactory.lookupById(orgId); + if (null == org) { + throw new IllegalArgumentException("No org id found [" + orgId + + "] for custom channel [" + customChannelInfo.getLabel() + "]"); + } + orgSet.add(orgId); + } + } + } + + /** + * Ensures that the custom channels json modify info structure is valid, as well as all consequent data + * + * @param modifyCustomChannelList list of custom channel info structures to be checked + * @throws IllegalArgumentException if something is wrong + */ + public static void ensureValidModifyCustomChannels(List modifyCustomChannelList) { + for (ModifyCustomChannelInfoJson modifyCustomChannelInfo : modifyCustomChannelList) { + + if (!ChannelFactory.doesChannelLabelExist(modifyCustomChannelInfo.getLabel())) { + throw new IllegalArgumentException("Channel to modify not found for custom channel [" + + modifyCustomChannelInfo.getLabel() + "]"); + } + + ensureValidOrgIds(modifyCustomChannelList); + + ensureExistingOrAboutToCreate(modifyCustomChannelList, true, "original channel", + ModifyCustomChannelInfoJson::getOriginalChannelLabel, ChannelFactory::lookupByLabel, + ModifyCustomChannelInfoJson::getLabel); + } + } + + private static void setValueIfNotNull(Channel channelIn, T valueIn, + BiConsumer channelSetValueMethod) { + if (null != valueIn) { + channelSetValueMethod.accept(channelIn, valueIn); + } + } + + /** + * Modifies a custom channel according to the info structure + * + * @param modifyCustomChannelInfo the custom channel info with the info on how to modify the custom channel + * @return customChannel the modified custom channel + */ + public static Channel modifyCustomChannel(ModifyCustomChannelInfoJson modifyCustomChannelInfo) { + + Channel customChannel = ChannelFactory.lookupByLabel(modifyCustomChannelInfo.getLabel()); + if (null == customChannel) { + throw new IllegalArgumentException("No existing custom channel to modify with label [" + + modifyCustomChannelInfo.getLabel() + "]"); + } + + Org org = null; + if (null != modifyCustomChannelInfo.getPeripheralOrgId()) { + org = OrgFactory.lookupById(modifyCustomChannelInfo.getPeripheralOrgId()); + if ((null != modifyCustomChannelInfo.getPeripheralOrgId()) && (null == org)) { + throw new IllegalArgumentException("No org id to modify [" + + modifyCustomChannelInfo.getPeripheralOrgId() + + "] for custom channel [" + modifyCustomChannelInfo.getLabel() + "]"); + } + } + + if ((null != modifyCustomChannelInfo.getOriginalChannelLabel()) && + (StringUtils.isNotEmpty(modifyCustomChannelInfo.getOriginalChannelLabel()))) { + + Channel originalChannel = + ChannelFactory.lookupByLabel(modifyCustomChannelInfo.getOriginalChannelLabel()); + + if (null == originalChannel) { + throw new IllegalArgumentException("No original channel to modify as original [" + + modifyCustomChannelInfo.getOriginalChannelLabel() + + "] for custom channel [" + modifyCustomChannelInfo.getLabel() + "]"); + } + + if (customChannel.asCloned().isEmpty()) { + throw new IllegalArgumentException("Cannot set original channel " + + "for not cloned custom channel [" + modifyCustomChannelInfo.getLabel() + "]"); + } + + customChannel.asCloned().get().setOriginal(originalChannel); + } + + //null field = do not modify + if (null != modifyCustomChannelInfo.getPeripheralOrgId()) { + customChannel.setOrg(org); + } + + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getBaseDir(), Channel::setBaseDir); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getName(), Channel::setName); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getSummary(), Channel::setSummary); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getDescription(), Channel::setDescription); + + if (null != modifyCustomChannelInfo.getProductNameLabel()) { + customChannel.setProductName( + MgrSyncUtils.findOrCreateProductName(modifyCustomChannelInfo.getProductNameLabel())); + } + + setValueIfNotNull(customChannel, modifyCustomChannelInfo.isGpgCheck(), Channel::setGPGCheck); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getGpgKeyUrl(), Channel::setGPGKeyUrl); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getGpgKeyId(), Channel::setGPGKeyId); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getGpgKeyFp(), Channel::setGPGKeyFp); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getEndOfLifeDate(), Channel::setEndOfLife); + + if ((null != modifyCustomChannelInfo.getChannelProductProduct()) && + (null != modifyCustomChannelInfo.getChannelProductVersion())) { + customChannel.setProduct(MgrSyncUtils.findOrCreateChannelProduct( + modifyCustomChannelInfo.getChannelProductProduct(), + modifyCustomChannelInfo.getChannelProductVersion())); + } + + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getChannelAccess(), Channel::setAccess); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getMaintainerName(), Channel::setMaintainerName); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getMaintainerEmail(), Channel::setMaintainerEmail); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getMaintainerPhone(), Channel::setMaintainerPhone); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getSupportPolicy(), Channel::setSupportPolicy); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.getUpdateTag(), Channel::setUpdateTag); + setValueIfNotNull(customChannel, modifyCustomChannelInfo.isInstallerUpdates(), Channel::setInstallerUpdates); + + return customChannel; + } } diff --git a/java/code/src/com/redhat/rhn/domain/channel/test/ChannelFactoryTest.java b/java/code/src/com/redhat/rhn/domain/channel/test/ChannelFactoryTest.java index 320121ce89d5..2dec461bb682 100644 --- a/java/code/src/com/redhat/rhn/domain/channel/test/ChannelFactoryTest.java +++ b/java/code/src/com/redhat/rhn/domain/channel/test/ChannelFactoryTest.java @@ -112,12 +112,12 @@ public static Channel createBaseChannel(User user, String channelArchLabel) thro } public static Channel createBaseChannel(User user, - ChannelFamily fam) throws Exception { + ChannelFamily fam) throws Exception { Channel c = createTestChannel(null, fam); ProductName pn = lookupOrCreateProductName(ChannelManager.RHEL_PRODUCT_NAME); c.setProductName(pn); ChannelFactory.save(c); - return (Channel)TestUtils.saveAndReload(c); + return (Channel) TestUtils.saveAndReload(c); } public static Channel createTestChannel(User user) throws Exception { @@ -137,15 +137,15 @@ public static Channel createTestChannel(User user, List contentSourceUrl ContentSourceType type = ChannelManager.findCompatibleContentSourceType(c.getChannelArch()); contentSourceUrls.stream() - .map(url -> { - ContentSource cs = new ContentSource(); - cs.setLabel(c.getLabel() + "-CS-" + RandomStringUtils.randomAlphabetic(8)); - cs.setOrg(user.getOrg()); - cs.setType(type); - cs.setSourceUrl(url); - return TestUtils.saveAndReload(cs); - }) - .forEach(c.getSources()::add); + .map(url -> { + ContentSource cs = new ContentSource(); + cs.setLabel(c.getLabel() + "-CS-" + RandomStringUtils.randomAlphabetic(8)); + cs.setOrg(user.getOrg()); + cs.setType(type); + cs.setSourceUrl(url); + return TestUtils.saveAndReload(cs); + }) + .forEach(c.getSources()::add); ChannelFactory.save(c); return c; @@ -163,7 +163,7 @@ public static Channel createTestChannel(User user, String channelArch) throws Ex public static Channel createTestChannel(Org org) throws Exception { ChannelFamily cfam = org.getPrivateChannelFamily(); - Channel c = ChannelFactoryTest.createTestChannel(org, cfam); + Channel c = ChannelFactoryTest.createTestChannel(org, cfam); ChannelFactory.save(c); return c; } @@ -177,7 +177,7 @@ public static Channel createTestChannel(Org org, ChannelFamily cfam) throws Exce /** * Create a test channel setting the GPGCheck flag via a parameter. * - * @param user the user + * @param user the user * @param gpgCheckIn the GPGCheck flag to set * @return the test channel * @throws Exception @@ -236,6 +236,7 @@ public static Channel createTestChannel(String name, String label, Org org, Chan /** * TODO: need to fix this test when we put errata management back in. + * * @throws Exception something bad happened */ @Test @@ -243,7 +244,7 @@ public void testChannelsWithClonableErrata() throws Exception { User user = UserTestUtils.findNewUser("testUser", "testOrg" + this.getClass().getSimpleName()); ChannelManager. - getChannelsWithClonableErrata(user.getOrg()); + getChannelsWithClonableErrata(user.getOrg()); Channel original = ChannelFactoryTest.createTestChannel(user); Channel clone = ChannelFactoryTest.createTestClonedChannel(original, user); @@ -252,7 +253,7 @@ public void testChannelsWithClonableErrata() throws Exception { List channels = ChannelFactory.getChannelsWithClonableErrata( - user.getOrg()); + user.getOrg()); assertFalse(channels.isEmpty()); } @@ -378,7 +379,7 @@ public void testPackageCount() throws Exception { ChannelFactory.save(original); TestUtils.flushAndEvict(original); - original = (Channel)reload(original); + original = (Channel) reload(original); assertEquals(1, ChannelFactory.getPackageCount(original)); } @@ -386,8 +387,9 @@ public void testPackageCount() throws Exception { * Create a test cloned channel. NOTE: This function does not copy its * original's package list like a real clone would. It is only useful for * testing purposes. + * * @param original Channel to be cloned - * @param user the user + * @param user the user * @return a test cloned channel */ public static Channel createTestClonedChannel(Channel original, User user) { @@ -661,6 +663,7 @@ public void testTrustedOrgAccessibility() throws Exception { /** * Test "ChannelFactory.findAllByUserOrderByChild" + * * @throws Exception */ @Test @@ -702,6 +705,7 @@ public void testFindAllByUserOrderByChild() throws Exception { assertEquals("a_child1", channels.get(2).getLabel()); assertEquals("b_parent3", channels.get(3).getLabel()); } + @Test public void testChannelSyncFlag() throws Exception { diff --git a/java/code/src/com/redhat/rhn/domain/credentials/CredentialsFactory.java b/java/code/src/com/redhat/rhn/domain/credentials/CredentialsFactory.java index 9cf5fafc6f6f..23c454458db4 100644 --- a/java/code/src/com/redhat/rhn/domain/credentials/CredentialsFactory.java +++ b/java/code/src/com/redhat/rhn/domain/credentials/CredentialsFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012 SUSE LLC + * Copyright (c) 2012--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.domain.credentials; @@ -92,6 +88,17 @@ public static SCCCredentials createSCCCredentials(String username, String passwo return new SCCCredentials(username, password); } + /** + * Helper method for creating new Hub SCC {@link Credentials} + * @param username the username + * @param password the password that will be BASE64 encoded + * @param fqdn the FQDN of the peripheral server that will use this credentials + * @return new credential with type SCC + */ + public static HubSCCCredentials createHubSCCCredentials(String username, String password, String fqdn) { + return new HubSCCCredentials(username, password, fqdn); + } + /** * Helper method for creating new Virtual Host Manager {@link Credentials} * @param username the username diff --git a/java/code/src/com/redhat/rhn/domain/credentials/CredentialsType.java b/java/code/src/com/redhat/rhn/domain/credentials/CredentialsType.java index 767aebbd421f..733193eefcd0 100644 --- a/java/code/src/com/redhat/rhn/domain/credentials/CredentialsType.java +++ b/java/code/src/com/redhat/rhn/domain/credentials/CredentialsType.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012 SUSE LLC + * Copyright (c) 2012--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.domain.credentials; @@ -25,7 +21,8 @@ public enum CredentialsType { REGISTRY(Label.REGISTRY), CLOUD_RMT(Label.CLOUD_RMT), REPORT_DATABASE(Label.REPORT_DATABASE), - RHUI(Label.RHUI); + RHUI(Label.RHUI), + HUB_SCC(Label.HUB_SCC); private final String label; @@ -58,6 +55,7 @@ public static class Label { public static final String CLOUD_RMT = "cloudrmt"; public static final String REPORT_DATABASE = "reportcreds"; public static final String RHUI = "rhui"; + public static final String HUB_SCC = "hub_scc"; private Label() { } diff --git a/java/code/src/com/redhat/rhn/domain/credentials/HubSCCCredentials.java b/java/code/src/com/redhat/rhn/domain/credentials/HubSCCCredentials.java new file mode 100644 index 000000000000..5fa44d1a35dd --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/credentials/HubSCCCredentials.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.redhat.rhn.domain.credentials; + +import com.suse.manager.model.hub.IssPeripheral; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.apache.commons.lang3.builder.ToStringBuilder; + +import javax.persistence.Column; +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.OneToOne; +import javax.persistence.Transient; + +@Entity +@DiscriminatorValue(CredentialsType.Label.HUB_SCC) +public class HubSCCCredentials extends PasswordBasedCredentials { + + + private IssPeripheral issPeripheral; + private String peripheralUrl; + + // No args constructor for hibernate + protected HubSCCCredentials() { + } + + // Default constructor filling the mandatory fields to be used in the CredentialFactory + protected HubSCCCredentials(String usernameIn, String passwordIn, String peripheralUrlIn) { + setUsername(usernameIn); + setPassword(passwordIn); + this.peripheralUrl = peripheralUrlIn; + } + + @OneToOne(mappedBy = "mirrorCredentials", fetch = FetchType.LAZY) + public IssPeripheral getIssPeripheral() { + return issPeripheral; + } + + public void setIssPeripheral(IssPeripheral issPeripheralIn) { + this.issPeripheral = issPeripheralIn; + } + + @Override + @Transient + public CredentialsType getType() { + return CredentialsType.HUB_SCC; + } + + @Column(name = "url") + public String getPeripheralUrl() { + return peripheralUrl; + } + + public void setPeripheralUrl(String peripheralUrlIn) { + this.peripheralUrl = peripheralUrlIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof HubSCCCredentials that)) { + return false; + } + + return new EqualsBuilder() + .appendSuper(super.equals(o)) + .append(getPeripheralUrl(), that.getPeripheralUrl()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .appendSuper(super.hashCode()) + .append(getPeripheralUrl()) + .toHashCode(); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("id", getId()) + .append("type", CredentialsType.HUB_SCC) + .append("user", getUser()) + .append("username", getUsername()) + .append("url", getPeripheralUrl()) + .toString(); + } +} diff --git a/java/code/src/com/redhat/rhn/domain/credentials/SCCCredentials.java b/java/code/src/com/redhat/rhn/domain/credentials/SCCCredentials.java index fddf8e800c65..bb2949e0a685 100644 --- a/java/code/src/com/redhat/rhn/domain/credentials/SCCCredentials.java +++ b/java/code/src/com/redhat/rhn/domain/credentials/SCCCredentials.java @@ -15,16 +15,22 @@ package com.redhat.rhn.domain.credentials; +import com.suse.manager.model.hub.IssHub; + import org.apache.commons.lang3.builder.ToStringBuilder; import javax.persistence.DiscriminatorValue; import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.OneToOne; import javax.persistence.Transient; @Entity @DiscriminatorValue(CredentialsType.Label.SCC) public class SCCCredentials extends RemoteCredentials { + private IssHub issHub; + // No args constructor for hibernate protected SCCCredentials() { } @@ -41,6 +47,15 @@ public CredentialsType getType() { return CredentialsType.SCC; } + @OneToOne(mappedBy = "mirrorCredentials", fetch = FetchType.LAZY) + public IssHub getIssHub() { + return issHub; + } + + public void setIssHub(IssHub issHubIn) { + this.issHub = issHubIn; + } + /** * @return if this credential is the current primary scc credential which * is at the moment denoted by having the url field set. @@ -50,6 +65,23 @@ public boolean isPrimary() { return getUrl() != null; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof SCCCredentials)) { + return false; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + @Override public String toString() { return new ToStringBuilder(this) diff --git a/java/code/src/com/redhat/rhn/domain/errata/AdvisoryStatusEnumType.java b/java/code/src/com/redhat/rhn/domain/errata/AdvisoryStatusEnumType.java index b09e4106829b..89c731fc829c 100644 --- a/java/code/src/com/redhat/rhn/domain/errata/AdvisoryStatusEnumType.java +++ b/java/code/src/com/redhat/rhn/domain/errata/AdvisoryStatusEnumType.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 SUSE LLC + * Copyright (c) 2021--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,13 +7,11 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.redhat.rhn.domain.errata; +import com.redhat.rhn.domain.CustomEnumType; + /** * AdvisoryStatusEnumType */ diff --git a/java/code/src/com/redhat/rhn/domain/org/OrgFactory.java b/java/code/src/com/redhat/rhn/domain/org/OrgFactory.java index 4877972cdbfe..e9cca5ed5715 100644 --- a/java/code/src/com/redhat/rhn/domain/org/OrgFactory.java +++ b/java/code/src/com/redhat/rhn/domain/org/OrgFactory.java @@ -324,13 +324,29 @@ public static TemplateString lookupTemplateByLabel(String label) { /** * Get the default organization. - * - * Currently looks up the org with ID 1. + * Currently, it searches for the org with the lowest org id which has a sat_admin * * @return Default organization */ public static Org getSatelliteOrg() { - return lookupById(1L); + return findOrgsWithSatAdmin().stream().findFirst().orElse(lookupById(1L)); + } + + /** + * Find all Organizations which has a sat_admin (SUSE Manager Administrator) ordered by its ID. + * @return return an ordered list of {@link Org} which have a sat_admin + */ + public static List findOrgsWithSatAdmin() { + return getSession().createNativeQuery(""" + SELECT distinct org.*, null as reg_token_id + FROM web_contact wc + JOIN web_customer org ON wc.org_id = org.id + JOIN rhnUserGroupMembers ugm ON wc.id = ugm.user_id + WHERE ugm.user_group_id in (SELECT id + FROM rhnUserGroup + WHERE group_type = 1) + ORDER BY org.id; + """, Org.class).getResultList(); } /** diff --git a/java/code/src/com/redhat/rhn/domain/server/MgrServerInfo.java b/java/code/src/com/redhat/rhn/domain/server/MgrServerInfo.java index 3690948e62f0..30e1ed09c7c0 100644 --- a/java/code/src/com/redhat/rhn/domain/server/MgrServerInfo.java +++ b/java/code/src/com/redhat/rhn/domain/server/MgrServerInfo.java @@ -56,6 +56,7 @@ public void setId(Long sid) { */ public MgrServerInfo() { super(); + reportDbPort = 5432; } /** diff --git a/java/code/src/com/redhat/rhn/domain/server/ServerFQDN.java b/java/code/src/com/redhat/rhn/domain/server/ServerFQDN.java index 409218be690e..d9ec34685667 100644 --- a/java/code/src/com/redhat/rhn/domain/server/ServerFQDN.java +++ b/java/code/src/com/redhat/rhn/domain/server/ServerFQDN.java @@ -106,6 +106,9 @@ public void setServer(Server serverIn) { */ @Override public boolean equals(Object o) { + if (this == o) { + return true; + } if (o instanceof ServerFQDN toCompare) { return new EqualsBuilder() .append(name, toCompare.name) diff --git a/java/code/src/com/redhat/rhn/domain/server/ServerFactory.java b/java/code/src/com/redhat/rhn/domain/server/ServerFactory.java index c86fc84cc227..feec7ab10454 100644 --- a/java/code/src/com/redhat/rhn/domain/server/ServerFactory.java +++ b/java/code/src/com/redhat/rhn/domain/server/ServerFactory.java @@ -1035,6 +1035,21 @@ public static List listFqdns(Long sid) { return SINGLETON.listObjectsByNamedQuery("Server.listFqdns", Map.of("sid", sid)); } + /** + * Find Server by a set of possible FQDNs + * @param fqdns the set of FQDNs + * @return return the first Server found if any + */ + public static Optional findByAnyFqdn(Set fqdns) { + for (String fqdn : fqdns) { + Optional server = findByFqdn(fqdn); + if (server.isPresent()) { + return server; + } + } + return Optional.empty(); + } + /** * Lookup a Server by their FQDN * @param name of the FQDN to search for @@ -1572,16 +1587,16 @@ public static int setMaintenanceScheduleToSystems(MaintenanceSchedule schedule, /** * Remove MgrServerInfo from minion * - * @param minion the minion + * @param server the minion */ - public static void dropMgrServerInfo(MinionServer minion) { - MgrServerInfo serverInfo = minion.getMgrServerInfo(); + public static void dropMgrServerInfo(Server server) { + MgrServerInfo serverInfo = server.getMgrServerInfo(); if (serverInfo == null) { return; } ReportDBCredentials credentials = serverInfo.getReportDbCredentials(); CredentialsFactory.removeCredentials(credentials); SINGLETON.removeObject(serverInfo); - minion.setMgrServerInfo(null); + server.setMgrServerInfo(null); } } diff --git a/java/code/src/com/redhat/rhn/frontend/action/satellite/SetupWizardAction.java b/java/code/src/com/redhat/rhn/frontend/action/satellite/SetupWizardAction.java index a41d88eb6706..4114d46b30a1 100644 --- a/java/code/src/com/redhat/rhn/frontend/action/satellite/SetupWizardAction.java +++ b/java/code/src/com/redhat/rhn/frontend/action/satellite/SetupWizardAction.java @@ -21,6 +21,8 @@ import com.redhat.rhn.taskomatic.TaskoFactory; import com.redhat.rhn.taskomatic.domain.TaskoRun; +import com.suse.manager.model.hub.HubFactory; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.struts.action.ActionForm; @@ -61,11 +63,10 @@ public ActionForward execute(ActionMapping mapping, ActionForm formIn, /** * @param mapping the Action mapping object * @param request current request object - * @throws Exception if parsing of navigation XML fails */ - private void setAttributes(ActionMapping mapping, HttpServletRequest request) - throws Exception { - request.setAttribute(ISS_MASTER, IssFactory.getCurrentMaster() == null); + private void setAttributes(ActionMapping mapping, HttpServletRequest request) { + HubFactory hubFactory = new HubFactory(); + request.setAttribute(ISS_MASTER, IssFactory.getCurrentMaster() == null && !hubFactory.isISSPeripheral()); ContentSyncManager csm = new ContentSyncManager(); request.setAttribute(REFRESH_NEEDED, csm.isRefreshNeeded(null)); diff --git a/java/code/src/com/redhat/rhn/frontend/security/PxtAuthenticationService.java b/java/code/src/com/redhat/rhn/frontend/security/PxtAuthenticationService.java index 4e3e241bba0e..53056741a80e 100644 --- a/java/code/src/com/redhat/rhn/frontend/security/PxtAuthenticationService.java +++ b/java/code/src/com/redhat/rhn/frontend/security/PxtAuthenticationService.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2015 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -21,11 +22,11 @@ import com.suse.manager.api.HttpApiRegistry; import com.suse.manager.webui.utils.LoginHelper; -import org.apache.commons.collections.set.UnmodifiableSet; import org.apache.commons.lang3.StringUtils; import org.apache.http.client.utils.URIBuilder; import java.io.IOException; +import java.util.Collections; import java.util.Set; import java.util.TreeSet; @@ -40,17 +41,17 @@ public class PxtAuthenticationService extends BaseAuthenticationService { public static final long MAX_URL_LENGTH = 2048; - private static final Set UNPROTECTED_URIS; - private static final Set POST_UNPROTECTED_URIS; - private static final Set LOGIN_URIS; + private static final Set UNPROTECTED_URIS; + private static final Set POST_UNPROTECTED_URIS; + private static final Set LOGIN_URIS; static { // Login routes - TreeSet set = new TreeSet<>(); + TreeSet set = new TreeSet<>(); set.add("/rhn/newlogin/"); set.add("/rhn/manager/login"); - LOGIN_URIS = UnmodifiableSet.decorate(set); + LOGIN_URIS = Collections.unmodifiableSet(set); // Unauthenticated routes set = new TreeSet<>(set); @@ -69,11 +70,12 @@ public class PxtAuthenticationService extends BaseAuthenticationService { set.add("/rhn/ResetLink"); set.add("/rhn/ResetPasswordSubmit"); set.add("/rhn/saltboot"); + set.add("/rhn/hub"); // HTTP API public endpoints set.addAll(HttpApiRegistry.getUnautenticatedRoutes()); - UNPROTECTED_URIS = UnmodifiableSet.decorate(set); + UNPROTECTED_URIS = Collections.unmodifiableSet(set); // CSRF whitelist set = new TreeSet<>(set); @@ -83,7 +85,7 @@ public class PxtAuthenticationService extends BaseAuthenticationService { set.add("/rhn/manager/api/"); set.add("/rhn/manager/upload/image"); - POST_UNPROTECTED_URIS = UnmodifiableSet.decorate(set); + POST_UNPROTECTED_URIS = Collections.unmodifiableSet(set); } private PxtSessionDelegate pxtDelegate; @@ -92,17 +94,17 @@ protected PxtAuthenticationService() { } @Override - protected Set getLoginURIs() { + protected Set getLoginURIs() { return LOGIN_URIS; } @Override - protected Set getUnprotectedURIs() { + protected Set getUnprotectedURIs() { return UNPROTECTED_URIS; } @Override - protected Set getPostUnprotectedURIs() { + protected Set getPostUnprotectedURIs() { return POST_UNPROTECTED_URIS; } @@ -125,9 +127,6 @@ public boolean skipCsfr(HttpServletRequest request) { return requestURIdoesLogin(request) || requestPostCsfrWhitelist(request); } - /** - * {@inheritDoc} - */ @Override public boolean validate(HttpServletRequest request, HttpServletResponse response) { if (requestURIRequiresAuthentication(request)) { @@ -140,9 +139,6 @@ public boolean validate(HttpServletRequest request, HttpServletResponse response } - /** - * {@inheritDoc} - */ @Override public void refresh(HttpServletRequest request, HttpServletResponse response) { // If URL requires auth and we are authenticated refresh the session. @@ -159,9 +155,6 @@ private boolean isAuthenticationRequired(HttpServletRequest request) { pxtDelegate.getWebUserId(request) == null); } - /** - * {@inheritDoc} - */ @Override public void redirectToLogin(HttpServletRequest request, HttpServletResponse response) throws ServletException { @@ -198,19 +191,12 @@ public void redirectToLogin(HttpServletRequest request, HttpServletResponse resp } } - /** - * {@inheritDoc} - */ @Override - public void redirectTo(HttpServletRequest request, HttpServletResponse response, - String path) { + public void redirectTo(HttpServletRequest request, HttpServletResponse response, String path) { response.setHeader("Location", path); - response.setStatus(response.SC_SEE_OTHER); + response.setStatus(HttpServletResponse.SC_SEE_OTHER); } - /** - * {@inheritDoc} - */ @Override public void invalidate(HttpServletRequest request, HttpServletResponse response) { pxtDelegate.invalidatePxtSession(request, response); diff --git a/java/code/src/com/redhat/rhn/frontend/servlets/EnvironmentFilter.java b/java/code/src/com/redhat/rhn/frontend/servlets/EnvironmentFilter.java index fe1bfd524459..1c9755cd24f3 100644 --- a/java/code/src/com/redhat/rhn/frontend/servlets/EnvironmentFilter.java +++ b/java/code/src/com/redhat/rhn/frontend/servlets/EnvironmentFilter.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2013 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -26,6 +27,7 @@ import org.apache.struts.action.ActionMessages; import java.io.IOException; +import java.util.List; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -41,27 +43,23 @@ */ public class EnvironmentFilter implements Filter { - private static Logger log = LogManager.getLogger(EnvironmentFilter.class); + private static final Logger LOGGER = LogManager.getLogger(EnvironmentFilter.class); - private static String[] nosslurls = {"/rhn/kickstart/DownloadFile", - "/rhn/common/DownloadFile", - "/rhn/rpc/api", - "/rhn/errors", - "/rhn/ty/TinyUrl", - "/rhn/websocket", - "/rhn/metrics"}; + private static final List NO_SSL_URL = List.of( + "/rhn/kickstart/DownloadFile", + "/rhn/common/DownloadFile", + "/rhn/rpc/api", + "/rhn/errors", + "/rhn/ty/TinyUrl", + "/rhn/websocket", + "/rhn/metrics" + ); - /** - * {@inheritDoc} - */ @Override public void init(FilterConfig arg0) { // Not needed in this filter } - /** - * {@inheritDoc} - */ @Override public void doFilter(ServletRequest request, ServletResponse response, @@ -80,8 +78,8 @@ public void doFilter(ServletRequest request, // Have to make this decision here, because once we pass the request // off to the next filter, that filter can do work that sends data to // the client, meaning that we can't redirect. - if (ConfigDefaults.get().isSsl() && RhnHelper.pathNeedsSecurity(nosslurls, path) && !hreq.isSecure()) { - log.debug("redirecting to secure: {}", path); + if (ConfigDefaults.get().isSsl() && RhnHelper.pathNeedsSecurity(NO_SSL_URL, path) && !hreq.isSecure()) { + LOGGER.debug("redirecting to secure: {}", path); redirectToSecure(hreq, hres); return; } @@ -90,7 +88,7 @@ public void doFilter(ServletRequest request, HttpServletRequest req = (HttpServletRequest) request; request.setAttribute(RequestContext.REQUESTED_URI, req.getRequestURI()); - log.debug("set REQUESTED_URI: {}", req.getRequestURI()); + LOGGER.debug("set REQUESTED_URI: {}", req.getRequestURI()); // add messages that were put on the request path. addParameterizedMessages(req); @@ -117,9 +115,6 @@ private void addParameterizedMessages(HttpServletRequest req) { } } - /** - * {@inheritDoc} - */ @Override public void destroy() { // Nothing to do here diff --git a/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml b/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml index ba8c07395c2c..f0e8db02f4af 100644 --- a/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml +++ b/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml @@ -9106,6 +9106,60 @@ Alternatively, you will want to download <strong>Incremental Channel Conte PAYG {0} deleted successfully + + Issued + + + Consumed + + + Server {0} has been successfully registered as peripheral + + + The request to perform the operation was rejected. Please check the server logs. + + + The provided certificate is not in valid PEM format.<br/>Please ensure the certificate is correctly encoded as a Base64-encoded ASCII file and enclosed within <code>-----BEGIN CERTIFICATE-----</code> and <code>-----END CERTIFICATE-----</code> tags. + + + Unexpected error while processing the root certificate. Please check the server logs. + + + Unable to issue a token for the remote server. Please check the server logs. + + + The token received from the remote server is not valid. Please check the server logs. + + + Unable to establish a secure connection with the remote server. Please ensure you are providing the correct root certificate. + + + Invalid response received from the remote server. Please check the server logs. + + + An error has occurred while attempting the registration with the remote server. Please check the server logs. + + + Registration of the remote server failed due to an unexpected error. Please check the server logs. + + + The specified access token does not exist. + + + The specified access token is in an invalid state. + + + Unable to delete a token with the specified id + + + Unable to issue a new token for the specified server. Please check the server logs. + + + Unable to parse the provided token. Please, ensure the token is correct and was generated for this server. + + + Unable to store the token. Please check the server logs. + CVE Audit diff --git a/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml b/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml index 6ccc23095e16..e1a2addaa5ab 100644 --- a/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml +++ b/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml @@ -25061,7 +25061,7 @@ given channel. Subscriptions related to these credentials - This server is configured as an Inter-Server Synchronisation (ISS) slave. Credentials can only be managed on the ISS master. + This server is configured as a Peripheral server in a Hub configuration. Credentials can only be managed on the Hub server. Cancel diff --git a/java/code/src/com/redhat/rhn/frontend/strings/nav/StringResource_en_US.xml b/java/code/src/com/redhat/rhn/frontend/strings/nav/StringResource_en_US.xml index 0722a1926ddc..a529fe9709fe 100644 --- a/java/code/src/com/redhat/rhn/frontend/strings/nav/StringResource_en_US.xml +++ b/java/code/src/com/redhat/rhn/frontend/strings/nav/StringResource_en_US.xml @@ -653,6 +653,24 @@ Navigation Menu + + Hub Configuration + + Navigation Menu + + + + Hub Details + + Navigation Menu + + + + Peripherals Configuration + + Navigation Menu + + Remote Command diff --git a/java/code/src/com/redhat/rhn/frontend/struts/RhnHelper.java b/java/code/src/com/redhat/rhn/frontend/struts/RhnHelper.java index c62d42d0cf8b..d56c731954ce 100644 --- a/java/code/src/com/redhat/rhn/frontend/struts/RhnHelper.java +++ b/java/code/src/com/redhat/rhn/frontend/struts/RhnHelper.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2014 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -20,6 +21,8 @@ import org.apache.struts.action.ActionMessages; import org.apache.struts.action.DynaActionForm; +import java.util.List; + import javax.servlet.http.HttpServletRequest; @@ -57,15 +60,14 @@ private RhnHelper() { /** * If the path doesn't require authentication, return false. Otherwise * return true. Checks that the passed in path doesn't startwith the params - * found in nosecurityPaths - * @param nosecurityPaths array of String paths, "/foo", + * found in noSecurityPaths + * @param noSecurityPaths list of String paths, "/foo", * "/bar/baz/test.jsp", "/somepath/foo.do" * @param path to check * @return boolean if it needs to be authorized or not */ - public static boolean pathNeedsSecurity(String[] nosecurityPaths, - String path) { - for (String curr : nosecurityPaths) { + public static boolean pathNeedsSecurity(List noSecurityPaths, String path) { + for (String curr : noSecurityPaths) { if (path.startsWith(curr)) { return false; } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/HandlerFactory.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/HandlerFactory.java index 75602344cb32..427baaaba386 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/HandlerFactory.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/HandlerFactory.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024--2025 SUSE LLC * Copyright (c) 2009--2010 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -95,6 +96,7 @@ import com.suse.manager.webui.controllers.bootstrap.SSHMinionBootstrapper; import com.suse.manager.webui.services.iface.SaltApi; import com.suse.manager.xmlrpc.admin.AdminPaygHandler; +import com.suse.manager.xmlrpc.iss.HubHandler; import com.suse.manager.xmlrpc.maintenance.MaintenanceHandler; import java.util.HashMap; @@ -209,6 +211,7 @@ public static HandlerFactory getDefaultHandlerFactory() { factory.addHandler("saltkey", new SaltKeyHandler(saltKeyUtils)); factory.addHandler("schedule", new ScheduleHandler()); factory.addHandler("subscriptionmatching.pinnedsubscription", new PinnedSubscriptionHandler()); + factory.addHandler("sync.hub", new HubHandler()); factory.addHandler("sync.master", new MasterHandler()); factory.addHandler("sync.slave", new SlaveHandler()); factory.addHandler("sync.content", new ContentSyncHandler()); diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/InvalidTokenException.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/InvalidTokenException.java new file mode 100644 index 000000000000..1b603b28efb7 --- /dev/null +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/InvalidTokenException.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.frontend.xmlrpc; + +import com.redhat.rhn.FaultException; + +/** + * Token creation failed. + * + */ +public class InvalidTokenException extends FaultException { + + /** + * Constructor + */ + public InvalidTokenException() { + super(11000 , "invalidToken" , "Invalid token"); + } + + /** + * Constructor + * + * @param message exception message + */ + public InvalidTokenException(String message) { + super(11000 , "invalidToken" , message); + } + + /** + * Constructor + * @param cause the cause (which is saved for later retrieval + * by the Throwable.getCause() method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public InvalidTokenException(Throwable cause) { + super(11000 , "invalidToken" , "Invalid token", cause); + } +} diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenAlreadyExistsException.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenAlreadyExistsException.java new file mode 100644 index 000000000000..abc9e3bca3ad --- /dev/null +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenAlreadyExistsException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.frontend.xmlrpc; + +import com.redhat.rhn.FaultException; + +/** + * Token creation failed. + */ +public class TokenAlreadyExistsException extends FaultException { + + /** + * Constructor + */ + public TokenAlreadyExistsException() { + super(11001, "tokenAlreadyExists", "Token already exists for given FQDN"); + } + + /** + * Constructor + * @param message exception message + */ + public TokenAlreadyExistsException(String message) { + super(11001, "tokenAlreadyExists", message); + } + + /** + * Constructor + * @param cause the cause (which is saved for later retrieval + * by the Throwable.getCause() method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public TokenAlreadyExistsException(Throwable cause) { + super(11001, "tokenAlreadyExists", "Token already exists for given FQDN", cause); + } +} diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/TokenCreationException.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenCreationException.java similarity index 82% rename from java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/TokenCreationException.java rename to java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenCreationException.java index 86f46c9d2a6f..14de218bd5fa 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/TokenCreationException.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenCreationException.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016 SUSE LLC + * Copyright (c) 2016--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,12 +7,8 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ -package com.redhat.rhn.frontend.xmlrpc.activationkey; +package com.redhat.rhn.frontend.xmlrpc; import com.redhat.rhn.FaultException; diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenExchangeFailedException.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenExchangeFailedException.java new file mode 100644 index 000000000000..e327cff07431 --- /dev/null +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/TokenExchangeFailedException.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.frontend.xmlrpc; + +import com.redhat.rhn.FaultException; + +/** + * Token exchange failed. + * + */ +public class TokenExchangeFailedException extends FaultException { + + /** + * Constructor + */ + public TokenExchangeFailedException() { + super(11002, "tokenExchangeFailed", "Token exchange failed"); + } + + /** + * Constructor + * + * @param message exception message + */ + public TokenExchangeFailedException(String message) { + super(11002, "tokenExchangeFailed", message); + } + + /** + * Constructor + * @param cause the cause (which is saved for later retrieval + * by the Throwable.getCause() method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public TokenExchangeFailedException(Throwable cause) { + super(11002, "tokenExchangeFailed", "Token exchange failed", cause); + } +} diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java index 78f39d1b8297..2955f35ca09c 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2024 SUSE LLC * Copyright (c) 2009--2014 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -43,6 +44,7 @@ import com.redhat.rhn.frontend.xmlrpc.InvalidChannelException; import com.redhat.rhn.frontend.xmlrpc.InvalidServerGroupException; import com.redhat.rhn.frontend.xmlrpc.NoSuchSystemException; +import com.redhat.rhn.frontend.xmlrpc.TokenCreationException; import com.redhat.rhn.frontend.xmlrpc.ValidationException; import com.redhat.rhn.frontend.xmlrpc.configchannel.XmlRpcConfigChannelHelper; import com.redhat.rhn.manager.channel.ChannelManager; @@ -52,13 +54,14 @@ import com.suse.manager.api.ReadOnly; import com.suse.manager.utils.MachinePasswordUtils; -import com.suse.manager.webui.utils.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenBuildingException; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.hibernate.NonUniqueObjectException; -import org.jose4j.lang.JoseException; import java.util.ArrayList; import java.util.Collections; @@ -271,25 +274,19 @@ public List listChannels(String minionId, throw new AuthenticationException("wrong machine password."); } - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(minion.getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.setExpirationTimeMinutesInTheFuture( - Config.get().getInt( - ConfigDefaults.TEMP_TOKEN_LIFETIME - ) - ); - tokenBuilder.onlyChannels(key.getChannels() - .stream().map(Channel::getLabel) - .collect(Collectors.toSet())); - try { + Token token = new DownloadTokenBuilder(minion.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(Config.get().getInt(ConfigDefaults.TEMP_TOKEN_LIFETIME)) + .allowingOnlyChannels(key.getChannels().stream().map(Channel::getLabel).collect(Collectors.toSet())) + .build(); + String url = "https://" + minion.getChannelHost() + "/rhn/manager/download/"; - String token = tokenBuilder.getToken(); - return key.getChannels().stream().map( - c -> new ChannelInfo(c.getLabel(), c.getName(), url + c.getLabel(), token) - ).toList(); + return key.getChannels().stream() + .map(c -> new ChannelInfo(c.getLabel(), c.getName(), url + c.getLabel(), token.getSerializedForm())) + .toList(); } - catch (JoseException e) { + catch (TokenBuildingException e) { throw new TokenCreationException(e); } } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/NetworkDtoSerializer.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/NetworkDtoSerializer.java index 47842c074955..96cd0d991ed5 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/NetworkDtoSerializer.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/NetworkDtoSerializer.java @@ -20,7 +20,7 @@ import com.suse.manager.api.SerializationBuilder; import com.suse.manager.api.SerializedApiResponse; -import org.apache.commons.lang3.StringUtils; +import java.util.Objects; /** @@ -45,7 +45,7 @@ public Class getSupportedClass() { public SerializedApiResponse serialize(NetworkDto src) { return new SerializationBuilder() .add("systemId", src.getId()) - .add("systemName", StringUtils.defaultString(src.getName(), "unknown")) + .add("systemName", Objects.toString(src.getName(), "unknown")) .add("last_checkin", src.getLastCheckin()) .build(); } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncHandler.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncHandler.java index 0ce30ba84567..b84aba966aaa 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncHandler.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncHandler.java @@ -30,6 +30,7 @@ import com.redhat.rhn.manager.setup.MirrorCredentialsManager; import com.suse.manager.api.ReadOnly; +import com.suse.manager.model.hub.HubFactory; import java.util.ArrayList; import java.util.Collection; @@ -85,27 +86,6 @@ public List listChannels(User loggedInUser) { return csm.listChannels(); } - /** - * @Deprecated - * Synchronize channels between the Customer Center and the #product() database. - * This method is one step of the whole refresh cycle. - * - * @param loggedInUser the currently logged in user - * @param mirrorUrl optional mirror URL - * @return Integer - * @throws ContentSyncException in case of an error - * - * @apidoc.doc (Deprecated) Synchronize channels between the Customer Center - * and the #product() database. - * @apidoc.param #session_key() - * @apidoc.param #param_desc("string", "mirrorUrl", "Sync from mirror temporarily") - * @apidoc.returntype #return_int_success() - */ - public Integer synchronizeChannels(User loggedInUser, String mirrorUrl) - throws ContentSyncException { - return 1; - } - /** * Synchronize channel families between the Customer Center * and the #product() database. @@ -218,6 +198,11 @@ public Integer synchronizeRepositories(User loggedInUser, String mirrorUrl) thro public Integer addChannel(User loggedInUser, String channelLabel, String mirrorUrl) throws ContentSyncException { ensureSatAdmin(loggedInUser); + HubFactory hubFactory = new HubFactory(); + if (hubFactory.isISSPeripheral()) { + throw new ContentSyncException("This is an ISS Peripheral Server. " + + "Managing channels is disabled and can only be done from the Hub Server."); + } ContentSyncManager csm = new ContentSyncManager(); if (csm.isRefreshNeeded(mirrorUrl)) { throw new ContentSyncException("Product Data refresh needed. Please call mgr-sync refresh."); @@ -244,6 +229,11 @@ public Integer addChannel(User loggedInUser, String channelLabel, String mirrorU public Object[] addChannels(User loggedInUser, String channelLabel, String mirrorUrl) throws ContentSyncException { ensureSatAdmin(loggedInUser); + HubFactory hubFactory = new HubFactory(); + if (hubFactory.isISSPeripheral()) { + throw new ContentSyncException("This is an ISS Peripheral Server. " + + "Managing channels is disabled and can only be done from the Hub Server."); + } ContentSyncManager csm = new ContentSyncManager(); if (csm.isRefreshNeeded(mirrorUrl)) { throw new ContentSyncException("Product Data refresh needed. Please call mgr-sync refresh."); @@ -289,6 +279,10 @@ public Object[] addChannels(User loggedInUser, String channelLabel, String mirro public Integer addCredentials(User loggedInUser, String username, String password, boolean primary) throws ContentSyncException { ensureSatAdmin(loggedInUser); + HubFactory hubFactory = new HubFactory(); + if (hubFactory.isISSPeripheral()) { + throw new ContentSyncException("This is an ISS Peripheral Server. Managing credentials is disabled."); + } MirrorCredentialsDto creds = new MirrorCredentialsDto(username, password); MirrorCredentialsManager credsManager = new MirrorCredentialsManager(); long id = credsManager.storeMirrorCredentials(creds, null); @@ -314,6 +308,10 @@ public Integer addCredentials(User loggedInUser, String username, String passwor public Integer deleteCredentials(User loggedInUser, String username) throws ContentSyncException { ensureSatAdmin(loggedInUser); + HubFactory hubFactory = new HubFactory(); + if (hubFactory.isISSPeripheral()) { + throw new ContentSyncException("This is an ISS Peripheral Server. Managing credentials is disabled."); + } for (SCCCredentials c : CredentialsFactory.listSCCCredentials()) { if (c.getUsername().equals(username)) { new MirrorCredentialsManager().deleteMirrorCredentials(c.getId(), null); diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncSource.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncSource.java index d092434a9ac8..682d374dfae4 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncSource.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/ContentSyncSource.java @@ -51,12 +51,12 @@ default Optional castAs(Class contentSyncSou /** * Gets the instance of {@link SCCWebClient} that allows to access this content source. * @param uuid the unique identifier for this client for debugging purpose - * @param loggingDir the optional logging directory + * @param loggingDir the logging directory * @return the client that can be used to connect this content source. * @throws ContentSyncSourceException when it's not possible to build a client * @throws SCCClientException when an client error happens during the initialization */ - SCCClient getClient(String uuid, Optional loggingDir) throws ContentSyncSourceException, SCCClientException; + SCCClient getClient(String uuid, Path loggingDir) throws ContentSyncSourceException, SCCClientException; /** diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/LocalDirContentSyncSource.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/LocalDirContentSyncSource.java index c2ef8419e0c0..c60d8a02e648 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/LocalDirContentSyncSource.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/LocalDirContentSyncSource.java @@ -19,7 +19,6 @@ import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCClientException; -import com.suse.scc.client.SCCConfig; import com.suse.scc.client.SCCFileClient; import java.io.File; @@ -54,7 +53,7 @@ public Optional getCredentials() { } @Override - public SCCClient getClient(String uuid, Optional loggingDir) throws SCCClientException { + public SCCClient getClient(String uuid, Path loggingDir) throws SCCClientException { File localFile = path.toFile(); String localAbsolutePath = localFile.getAbsolutePath(); @@ -67,6 +66,6 @@ else if (!localFile.isDirectory()) { throw new SCCClientException(String.format("Path \"%s\" must be a directory.", localAbsolutePath)); } - return new SCCFileClient(new SCCConfig(localAbsolutePath)); + return new SCCFileClient(path); } } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/RMTContentSyncSource.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/RMTContentSyncSource.java index 103ec201e631..1a817a6f08c0 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/RMTContentSyncSource.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/RMTContentSyncSource.java @@ -20,6 +20,7 @@ import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.google.gson.Gson; @@ -57,7 +58,7 @@ public Optional getCredentials() { } @Override - public SCCClient getClient(String uuid, Optional loggingDir) throws ContentSyncSourceException { + public SCCClient getClient(String uuid, Path loggingDir) throws ContentSyncSourceException { try { URI uri = new URI(credentials.getUrl()); URI url = new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null); @@ -67,10 +68,14 @@ public SCCClient getClient(String uuid, Optional loggingDir) throws Conten @SuppressWarnings("unchecked") Map headers = gson.fromJson(new String(credentials.getExtraAuthData()), Map.class); - SCCConfig config = loggingDir - .map(path -> path.toAbsolutePath().toString()) - .map(path -> new SCCConfig(url, null, null, uuid, null, path, false, headers)) - .orElseGet(() -> new SCCConfig(url, null, null, uuid, headers)); + SCCConfig config = new SCCConfigBuilder() + .setUrl(url) + .setUsername(credentials.getUsername()) + .setUuid(uuid) + .setLoggingDir(loggingDir.toAbsolutePath().toString()) + .setSkipOwner(false) + .setAdditionalHeaders(headers) + .createSCCConfig(); return new SCCWebClient(config); } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/SCCContentSyncSource.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/SCCContentSyncSource.java index 7d7e0d014f38..46178b36e738 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/SCCContentSyncSource.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/SCCContentSyncSource.java @@ -20,13 +20,16 @@ import com.redhat.rhn.domain.credentials.RemoteCredentials; import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.suse.manager.model.hub.IssHub; import com.suse.scc.client.SCCClient; -import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; +import com.suse.utils.CertificateUtils; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; +import java.security.cert.CertificateException; import java.util.Optional; import java.util.function.Function; @@ -54,21 +57,41 @@ public Optional getCredentials() { } @Override - public SCCClient getClient(String uuid, Optional loggingDir) throws ContentSyncSourceException { + public SCCClient getClient(String uuid, Path loggingDir) throws ContentSyncSourceException { try { URI url = new URI(Config.get().getString(ConfigDefaults.SCC_URL)); String username = credentials.getUsername(); String password = credentials.getPassword(); - SCCConfig config = loggingDir - .map(path -> path.toAbsolutePath().toString()) - .map(path -> new SCCConfig(url, username, password, uuid, null, path, false)) - .orElseGet(() -> new SCCConfig(url, username, password, uuid)); + IssHub issHub = credentials.getIssHub(); + if (issHub != null) { + String rootCa = issHub.getRootCa(); + URI uri = new URI("https://%1$s/rhn/hub/scc/".formatted(issHub.getFqdn())); + var cfg = new SCCConfigBuilder() + .setUrl(uri) + .setCertificates(CertificateUtils.parse(rootCa).stream().toList()) + .setUsername(username) + .setPassword(password) + .setUuid(uuid) + .setLoggingDir(loggingDir.toAbsolutePath().toString()) + .setSkipOwner(false) + .createSCCConfig(); + return new SCCWebClient(cfg); + } + + var config = new SCCConfigBuilder() + .setUrl(url) + .setUsername(username) + .setPassword(password) + .setUuid(uuid) + .setLoggingDir(loggingDir.toAbsolutePath().toString()) + .setSkipOwner(false) + .createSCCConfig(); return new SCCWebClient(config); } - catch (URISyntaxException e) { + catch (URISyntaxException | CertificateException e) { throw new ContentSyncSourceException(e); } } diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/test/ContentSyncHandlerTest.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/test/ContentSyncHandlerTest.java new file mode 100644 index 000000000000..8468d1f1e98c --- /dev/null +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/sync/content/test/ContentSyncHandlerTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.frontend.xmlrpc.sync.content.test; + +import com.redhat.rhn.common.hibernate.HibernateFactory; +import com.redhat.rhn.domain.role.RoleFactory; +import com.redhat.rhn.frontend.xmlrpc.sync.content.ContentSyncHandler; +import com.redhat.rhn.frontend.xmlrpc.test.BaseHandlerTestCase; +import com.redhat.rhn.manager.content.ContentSyncException; + +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssHub; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class ContentSyncHandlerTest extends BaseHandlerTestCase { + + private final ContentSyncHandler handler = new ContentSyncHandler(); + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + admin.addPermanentRole(RoleFactory.SAT_ADMIN); + } + + @Test + void denyApiOnPeripheral() { + HubFactory hubFactory = new HubFactory(); + IssHub hub = new IssHub("hub.domain.top", null); + hubFactory.save(hub); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + Assertions.assertThrows(ContentSyncException.class, () -> handler.addChannel(admin, "dummy", null)); + Assertions.assertThrows(ContentSyncException.class, () -> handler.addChannels(admin, "dummy", null)); + Assertions.assertThrows(ContentSyncException.class, () -> + handler.addCredentials(admin, "dummy-user", "dummy-passwd", false)); + Assertions.assertThrows(ContentSyncException.class, () -> handler.deleteCredentials(admin, "dummy-user")); + } +} diff --git a/java/code/src/com/redhat/rhn/manager/content/ContentSyncManager.java b/java/code/src/com/redhat/rhn/manager/content/ContentSyncManager.java index 1897004972a1..87a5c0ce771a 100644 --- a/java/code/src/com/redhat/rhn/manager/content/ContentSyncManager.java +++ b/java/code/src/com/redhat/rhn/manager/content/ContentSyncManager.java @@ -64,12 +64,16 @@ import com.redhat.rhn.taskomatic.task.payg.beans.PaygProductInfo; import com.suse.cloud.CloudPaygManager; +import com.suse.manager.hub.HubManager; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssHub; import com.suse.manager.webui.services.pillar.MinionGeneralPillarGenerator; import com.suse.mgrsync.MgrSyncStatus; import com.suse.salt.netapi.parser.JsonParser; import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCClientException; import com.suse.scc.client.SCCClientUtils; +import com.suse.scc.client.SCCConfig; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.ChannelFamilyJson; import com.suse.scc.model.SCCOrderItemJson; @@ -156,24 +160,31 @@ public class ContentSyncManager { private CloudPaygManager cloudPaygManager; - private final Optional tmpLoggingDir; + private final HubFactory hubFactory; + + private final boolean isPeripheral; + private final boolean hubHasSignedMetadata; + + private final Path tmpLoggingDir; /** * Default constructor. */ public ContentSyncManager() { - cloudPaygManager = GlobalInstanceHolder.PAYG_MANAGER; - tmpLoggingDir = Optional.empty(); + this(Paths.get(SCCConfig.DEFAULT_LOGGING_DIR), GlobalInstanceHolder.PAYG_MANAGER); } /** - * Constructor for testing - * @param tmpLogDir overwrite logdir for credential output + * @param tmpLogDir logdir for credential output * @param paygMgrIn {@link CloudPaygManager} to use */ public ContentSyncManager(Path tmpLogDir, CloudPaygManager paygMgrIn) { cloudPaygManager = paygMgrIn; - tmpLoggingDir = Optional.ofNullable(tmpLogDir); + tmpLoggingDir = tmpLogDir; + hubFactory = new HubFactory(); + Optional issHub = hubFactory.lookupIssHub(); + isPeripheral = issHub.isPresent(); + hubHasSignedMetadata = StringUtils.isNotBlank(issHub.map(IssHub::getGpgKey).orElse("")); } /** @@ -868,18 +879,19 @@ public void refreshRepositoriesAuthentication( List availableRepoIds = SCCCachingFactory.lookupRepositories().stream() .map(SCCRepository::getSccId) .toList(); - List ptfRepos = repositories.stream() + Map> ptfOrCustomRepos = repositories.stream() .filter(r -> !availableRepoIds.contains(r.getSCCId())) - .filter(SCCRepositoryJson::isPtfRepository) - .toList(); - generatePtfChannels(ptfRepos); + .collect(Collectors.groupingBy(SCCRepositoryJson::isPtfRepository, + Collectors.toList())); + + generatePtfChannels(ptfOrCustomRepos.getOrDefault(Boolean.TRUE, Collections.emptyList())); Map availableReposById = SCCCachingFactory.lookupRepositories().stream() .collect(Collectors.toMap(SCCRepository::getSccId, r -> r)); List allExistingRepoAuths = SCCCachingFactory.lookupRepositoryAuth(); // cloudrmt and mirror work together - // mirror and scc doesn't work togehter + // mirror and scc doesn't work together //CLEANUP if (source instanceof LocalDirContentSyncSource) { // cleanup if we come from scc @@ -979,6 +991,11 @@ public void refreshRepositoriesAuthentication( source.getCredentials(SCCCredentials.class) .ifPresent(scc -> repoIdsFromCredential.addAll(refreshOESRepositoryAuth(scc, mirrorUrl, oesRepos))); + // Custom Channels + source.getCredentials(SCCCredentials.class) + .ifPresent(scc -> refreshCustomRepoAuthentication( + ptfOrCustomRepos.getOrDefault(Boolean.FALSE, Collections.emptyList()), scc)); + //DELETE OLD // check if we have to remove auths which exists before List authList = SCCCachingFactory.lookupRepositoryAuthByCredential(source); @@ -991,6 +1008,63 @@ public void refreshRepositoriesAuthentication( }); } + private void refreshCustomRepoAuthentication(List customReposIn, SCCCredentials creds) { + refreshCustomRepo(customReposIn, creds.getIssHub()); + } + + /** + * refreshes custom repositories + * + * @param customReposIn list of SCCRepositoryJson objects {@link SCCRepositoryJson} + * @param issHub {@link IssHub} to use + */ + public void refreshCustomRepo(List customReposIn, IssHub issHub) { + if (issHub == null) { + LOG.debug("Only Peripheral server manage custom channels via SCC API"); + return; + } + boolean metadataSigned = StringUtils.isNotBlank(issHub.getGpgKey()); + for (SCCRepositoryJson repo : customReposIn) { + Channel customChannel = ChannelFactory.lookupByLabel(repo.getName()); + if (!isValidCustomChannel(repo, customChannel)) { + LOG.error("Invalid custom repo/channel {} - {}", repo, customChannel); + continue; + } + Set css = customChannel.getSources(); + if (css.isEmpty()) { + // new channel; need to add the source + ContentSource source = new ContentSource(); + source.setLabel(customChannel.getLabel()); + source.setOrg(customChannel.getOrg()); + source.setSourceUrl(repo.getUrl()); + source.setType(ChannelManager.findCompatibleContentSourceType(customChannel.getChannelArch())); + source.setMetadataSigned(metadataSigned); + ChannelFactory.save(source); + + css.add(source); + customChannel.setSources(css); + ChannelFactory.save(customChannel); + } + else if (css.size() == 1) { + // found the repo; update the URL + ContentSource source = css.iterator().next(); + source.setSourceUrl(repo.getUrl()); + source.setMetadataSigned(metadataSigned); + ChannelFactory.save(source); + } + else { + LOG.error("Multiple repositories not allowed for this custom channel {}. Skipping", + customChannel.getName()); + } + } + } + + private boolean isValidCustomChannel(SCCRepositoryJson repoIn, Channel customChannelIn) { + return customChannelIn != null && repoIn != null && + Objects.equals(repoIn.getSCCId(), HubManager.CUSTOM_REPO_FAKE_SCC_ID) && + Objects.equals(customChannelIn.getLabel(), repoIn.getName()); + } + private void generatePtfChannels(List repositories) { List reposToSave = new ArrayList<>(); List templatesToSave = new ArrayList<>(); @@ -1018,7 +1092,7 @@ private void generatePtfChannels(List repositories) { templatesToSave.forEach(SUSEProductFactory::save); } - private static PtfProductRepositoryInfo parsePtfInfoFromUrl(SCCRepositoryJson jrepo) { + private PtfProductRepositoryInfo parsePtfInfoFromUrl(SCCRepositoryJson jrepo) { URI uri; try { @@ -1038,7 +1112,7 @@ private static PtfProductRepositoryInfo parsePtfInfoFromUrl(SCCRepositoryJson jr String archStr = prdArch.equals("amd64") ? prdArch + "-deb" : prdArch; SCCRepository repo = new SCCRepository(); - repo.setSigned(true); + repo.setSigned(isRepoSigned(true)); repo.update(jrepo); SUSEProduct product = SUSEProductFactory.findSUSEProduct(parts[4], parts[5], null, archStr, false); @@ -1273,7 +1347,7 @@ public List buildRepoFileUrls(String url, SCCRepository repo) throws URI relFiles.add("/repodata/repomd.xml"); } for (String relFile : relFiles) { - Path urlPath = new File(StringUtils.defaultString(uri.getRawPath(), "/"), relFile).toPath(); + Path urlPath = new File(Objects.toString(uri.getRawPath(), "/"), relFile).toPath(); urls.add(new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), urlPath.toString(), uri.getQuery(), null).toString()); } @@ -1362,22 +1436,6 @@ public Collection updateSubscriptions() throws ContentSyncE }); } - /** - * Check if the provided Credentials are usable for SCC. OES credentials will return false. - * @param c the credentials - * @return true if they can be used for SCC, otherwise false - */ - public boolean isSCCCredentials(SCCCredentials c) { - try { - SCCClient scc = this.getSCCClient(new SCCContentSyncSource(c)); - scc.listOrders(); - } - catch (SCCClientException | ContentSyncSourceException e) { - return false; - } - return true; - } - //Some old scc credentials are in reality OES credentials and don't work for SCC but only on the OES //endpoint so whenever there is an error using SCC credentials we have to check if those work for OES //and handle it accordingly @@ -1632,8 +1690,7 @@ private List loadStaticTree(String tag) throws ContentSyncExce }).findFirst().orElseGet(Collections::emptyList); }); - return tree.stream() .filter(e -> e.getTags().isEmpty() || e.getTags().contains(tag)) - .toList(); + return tree.stream().filter(e -> e.getTags().isEmpty() || e.getTags().contains(tag)).toList(); } /** @@ -1680,13 +1737,17 @@ private static Map productAttributeOverride(List })).entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - private static List overrideProductAttributes( + private List overrideProductAttributes( List jsonProducts, List tree) { Map> productTypeById = productAttributeOverride( tree, ProductTreeEntry::getProductType); Map releaseStageById = productAttributeOverride( tree, ProductTreeEntry::getReleaseStage); + + Optional optIssHub = hubFactory.lookupIssHub(); + String hubFqdn = optIssHub.map(IssHub::getFqdn).orElse("invalid.hub.internal"); + return jsonProducts.stream().map(product -> { ProductType productType = Optional.ofNullable(productTypeById.get(product.getId())) .flatMap(Function.identity()) @@ -1694,7 +1755,10 @@ private static List overrideProductAttributes( ReleaseStage releaseStage = Optional.ofNullable(releaseStageById.get(product.getId())) .orElseGet(product::getReleaseStage); - + if (optIssHub.isPresent()) { + product.getRepositories().forEach(r -> + r.setUrl("https://%1$s/rhn/manager/download/hubsync/%2$d/".formatted(hubFqdn, r.getSCCId()))); + } return product.copy() .setProductType(productType) .setReleaseStage(releaseStage) @@ -1704,12 +1768,12 @@ private static List overrideProductAttributes( /** - * Update Products, Repositories and relation ship table in DB. + * Update Products, Repositories and relationship table in DB. * @param productsById map of scc products by id * @param reposById map of scc repositories by id * @param tree the static suse product tree */ - public static void updateProducts(Map productsById, Map reposById, + public void updateProducts(Map productsById, Map reposById, List tree) { Map packageArchMap = PackageFactory.lookupPackageArch() .stream().collect(Collectors.toMap(PackageArch::getLabel, a -> a)); @@ -1821,7 +1885,7 @@ public static void updateProducts(Map productsById, Map { SCCRepository repo = repoMap.get(repoJson.getSCCId()); - repo.setSigned(entry.isSigned()); + repo.setSigned(isRepoSigned(entry.isSigned())); ChannelTemplate channelTemplate = new ChannelTemplate(); channelTemplate.setUpdateTag(entry.getUpdateTag().orElse(null)); @@ -1875,7 +1939,7 @@ public static void updateProducts(Map productsById, Map products, List templates = product.getChannelTemplates(); if (templates == null) { return false; @@ -2019,13 +2083,13 @@ public static boolean isProductAvailable(SUSEProduct product, SUSEProduct root) return !templates.isEmpty() && templates.stream() .filter(e -> e.getRootProduct().equals(root)) .filter(ChannelTemplate::isMandatory) - .allMatch(ContentSyncManager::isChannelAccessible); + .allMatch(this::isChannelAccessible); } - private static boolean isChannelAccessible(ChannelTemplate template) { + private boolean isChannelAccessible(ChannelTemplate template) { boolean isPublic = template.getProduct().getChannelFamily().isPublic(); boolean isAvailable = ChannelFactory.lookupByLabel(template.getChannelLabel()) != null; - boolean isISSSlave = IssFactory.getCurrentMaster() != null; + boolean isISSSlave = IssFactory.getCurrentMaster() != null || isPeripheral; boolean isMirrorable = false; if (!isISSSlave) { isMirrorable = template.getRepository().isAccessible(); @@ -2548,7 +2612,7 @@ protected boolean accessibleUrl(String url, String user, String password) { URI uri = new URI(url); // SMT doesn't do dir listings, so we try to get the metadata - Path testUrlPath = new File(StringUtils.defaultString(uri.getRawPath(), "/")).toPath(); + Path testUrlPath = new File(Objects.toString(uri.getRawPath(), "/")).toPath(); // Build full URL to test if (uri.getScheme().equals("file")) { @@ -2608,6 +2672,13 @@ public static boolean isChannelNameReserved(String name) { return !SUSEProductFactory.lookupByChannelName(name).isEmpty(); } + private boolean isRepoSigned(boolean signedDefault) { + if (isPeripheral) { + return hubHasSignedMetadata; + } + return signedDefault; + } + /** * Returns true when a valid Subscription for the SUSE Manager Tools Channel * is available diff --git a/java/code/src/com/redhat/rhn/manager/content/MgrSyncUtils.java b/java/code/src/com/redhat/rhn/manager/content/MgrSyncUtils.java index 279687b1ea79..720c125c5fe9 100644 --- a/java/code/src/com/redhat/rhn/manager/content/MgrSyncUtils.java +++ b/java/code/src/com/redhat/rhn/manager/content/MgrSyncUtils.java @@ -166,16 +166,28 @@ public static Channel getChannel(String label) throws ContentSyncException { /** * Find a {@link ChannelProduct} or create it if necessary and return it. + * * @param product product to find or create * @return channel product */ public static ChannelProduct findOrCreateChannelProduct(SUSEProduct product) { + return findOrCreateChannelProduct(product.getName(), product.getVersion()); + } + + /** + * Find a {@link ChannelProduct} or create it if necessary and return it. + * + * @param productName name of the product to find or create + * @param productVersion version of the product to find or create + * @return channel product + */ + public static ChannelProduct findOrCreateChannelProduct(String productName, String productVersion) { ChannelProduct p = ChannelFactory.findChannelProduct( - product.getName(), product.getVersion()); + productName, productVersion); if (p == null) { p = new ChannelProduct(); - p.setProduct(product.getName()); - p.setVersion(product.getVersion()); + p.setProduct(productName); + p.setVersion(productVersion); p.setBeta(false); ChannelFactory.save(p); } diff --git a/java/code/src/com/redhat/rhn/manager/distupgrade/DistUpgradeManager.java b/java/code/src/com/redhat/rhn/manager/distupgrade/DistUpgradeManager.java index c64e6d11edc2..10583a16a3a9 100644 --- a/java/code/src/com/redhat/rhn/manager/distupgrade/DistUpgradeManager.java +++ b/java/code/src/com/redhat/rhn/manager/distupgrade/DistUpgradeManager.java @@ -368,8 +368,9 @@ private static List getMigrationTargetProductSets(List processCombination(List combination) { final List result = new LinkedList<>(); + ContentSyncManager mgr = new ContentSyncManager(); SUSEProduct base = combination.get(0); - if (!ContentSyncManager.isProductAvailable(base, base)) { + if (!mgr.isProductAvailable(base, base)) { LOG.warn("No SUSE Product Channels for {}. Skipping", base.getFriendlyName()); return result; } @@ -382,9 +383,8 @@ private static List processCombination(List combina List addonProducts = combination.subList(1, combination.size()); addLibertyLinuxAddonIfMissing(base, addonProducts); // No Product Channels means, no subscription to access the channels - if (addonProducts.stream() - .anyMatch(ap -> !ContentSyncManager.isProductAvailable(ap, base))) { - logUnavailableAddons(addonProducts, base); + if (addonProducts.stream().anyMatch(ap -> !mgr.isProductAvailable(ap, base))) { + logUnavailableAddons(addonProducts, base, mgr); return result; } LOG.debug("Found Target: {}", base.getFriendlyName()); @@ -394,9 +394,10 @@ private static List processCombination(List combina return result; } - private static void logUnavailableAddons(List addonProducts, SUSEProduct base) { + private static void logUnavailableAddons(List addonProducts, SUSEProduct base, + ContentSyncManager mgr) { addonProducts.stream() - .filter(ap -> !ContentSyncManager.isProductAvailable(ap, base)) + .filter(ap -> !mgr.isProductAvailable(ap, base)) .forEach(ap -> LOG.warn("No SUSE Product Channels for {}. Skipping {}", ap.getFriendlyName(), base.getFriendlyName())); } diff --git a/java/code/src/com/redhat/rhn/manager/setup/MirrorCredentialsManager.java b/java/code/src/com/redhat/rhn/manager/setup/MirrorCredentialsManager.java index 187ccd4af1ea..f99b8d53883e 100644 --- a/java/code/src/com/redhat/rhn/manager/setup/MirrorCredentialsManager.java +++ b/java/code/src/com/redhat/rhn/manager/setup/MirrorCredentialsManager.java @@ -32,6 +32,7 @@ import com.suse.scc.SCCSystemRegistrationManager; import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.SCCSubscriptionJson; @@ -209,7 +210,12 @@ public void deleteMirrorCredentials(Long id, HttpServletRequest request) try { URI url = new URI(Config.get().getString(ConfigDefaults.SCC_URL)); String uuid = ContentSyncManager.getUUID(); - SCCConfig sccConfig = new SCCConfig(url, "", "", uuid); + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(url) + .setUsername("") + .setPassword("") + .setUuid(uuid) + .createSCCConfig(); SCCClient sccClient = new SCCWebClient(sccConfig); SCCSystemRegistrationManager sccRegManager = new SCCSystemRegistrationManager(sccClient); sccRegManager.deregister(itemList, true); diff --git a/java/code/src/com/redhat/rhn/manager/system/SystemManager.java b/java/code/src/com/redhat/rhn/manager/system/SystemManager.java index 8b2535c01f8e..928e738b446d 100644 --- a/java/code/src/com/redhat/rhn/manager/system/SystemManager.java +++ b/java/code/src/com/redhat/rhn/manager/system/SystemManager.java @@ -34,7 +34,6 @@ import com.redhat.rhn.common.hibernate.HibernateFactory; import com.redhat.rhn.common.hibernate.LookupException; import com.redhat.rhn.common.localization.LocalizationService; -import com.redhat.rhn.common.messaging.MessageQueue; import com.redhat.rhn.common.security.PermissionException; import com.redhat.rhn.common.validator.ValidatorError; import com.redhat.rhn.common.validator.ValidatorResult; @@ -121,11 +120,10 @@ import com.redhat.rhn.taskomatic.task.systems.SystemsOverviewUpdateDriver; import com.redhat.rhn.taskomatic.task.systems.SystemsOverviewUpdateWorker; +import com.suse.manager.model.hub.ManagerInfoJson; import com.suse.manager.model.maintenance.MaintenanceSchedule; -import com.suse.manager.reactor.messaging.ApplyStatesEventMessage; import com.suse.manager.reactor.messaging.ChannelsChangedEventMessage; import com.suse.manager.reactor.messaging.ChannelsChangedEventMessageAction; -import com.suse.manager.reactor.utils.ValueMap; import com.suse.manager.ssl.SSLCertData; import com.suse.manager.ssl.SSLCertGenerationException; import com.suse.manager.ssl.SSLCertManager; @@ -3806,92 +3804,35 @@ public static int updatePeripheralServerInfo(Server server, String reportDbName, /** * Update MgrServerInfo with current grains data * - * @param minion the minion which is a Mgr Server - * @param grains grains from the minion + * @param server the server which is a Mgr Server + * @param info info from peripheral server + * @return return true when the report db infos where changed */ - public static void updateMgrServerInfo(MinionServer minion, ValueMap grains) { + public static boolean updateMgrServerInfo(Server server, ManagerInfoJson info) { // Check for Uyuni Server and create basic info - if (grains.getOptionalAsBoolean("is_mgr_server").orElse(false)) { - MgrServerInfo serverInfo = Optional.ofNullable(minion.getMgrServerInfo()).orElse(new MgrServerInfo()); + if (info != null) { + MgrServerInfo serverInfo = Optional.ofNullable(server.getMgrServerInfo()).orElse(new MgrServerInfo()); String oldHost = serverInfo.getReportDbHost(); String oldName = serverInfo.getReportDbName(); serverInfo.setVersion(PackageEvrFactory.lookupOrCreatePackageEvr(null, - grains.getOptionalAsString("version").orElse("0"), - "1", minion.getPackageType())); - serverInfo.setReportDbName(grains.getValueAsString("report_db_name")); - serverInfo.setReportDbHost(grains.getValueAsString("report_db_host")); - serverInfo.setReportDbPort((grains.getValueAsLong("report_db_port").orElse(5432L)).intValue()); - serverInfo.setServer(minion); - minion.setMgrServerInfo(serverInfo); - - if (!StringUtils.isAnyBlank(oldHost, oldName) && + info.getVersion(), "1", server.getPackageType())); + serverInfo.setReportDbName(info.getReportDbName()); + serverInfo.setReportDbHost(info.getReportDbHost()); + serverInfo.setReportDbPort(info.getReportDbPort()); + serverInfo.setServer(server); + server.setMgrServerInfo(serverInfo); + + // something changed, we better reset the user + return StringUtils.isAnyBlank(oldHost, oldName) || !(oldHost.equals(serverInfo.getReportDbHost()) && - oldName.equals(serverInfo.getReportDbName()))) { - // something changed, we better reset the user - setReportDbUser(minion, false); - } + oldName.equals(serverInfo.getReportDbName())); } else { - ServerFactory.dropMgrServerInfo(minion); + ServerFactory.dropMgrServerInfo(server); // Should we try to drop the credentials on the reportdb? } - } - - /** - * Set the User and Password for the report database in MgrServerInfo. - * It trigger also a state apply to set this user in the report database. - * - * @param minion the Mgr Server - * @param forcePwChange force a password change - */ - public static void setReportDbUser(MinionServer minion, boolean forcePwChange) { - // Create a report db user when system is a mgr server - if (!minion.isMgrServer()) { - return; - } - // create default user with random password - MgrServerInfo mgrServerInfo = minion.getMgrServerInfo(); - if (StringUtils.isAnyBlank(mgrServerInfo.getReportDbName(), mgrServerInfo.getReportDbHost())) { - // no reportdb configured - return; - } - - String password = RandomStringUtils.random(24, 0, 0, true, true, null, new SecureRandom()); - ReportDBCredentials credentials = Optional.ofNullable(mgrServerInfo.getReportDbCredentials()) - .map(existingCredentials -> { - if (forcePwChange) { - existingCredentials.setPassword(password); - CredentialsFactory.storeCredentials(existingCredentials); - } - - return existingCredentials; - }) - .orElseGet(() -> { - String randomSuffix = RandomStringUtils.random(8, 0, 0, true, false, null, new SecureRandom()); - // Ensure the username is stored lowercase in the database, since the script uyuni-setup-reportdb-user - // will convert it to lowercase anyway - String username = "hermes_" + randomSuffix.toLowerCase(); - - ReportDBCredentials reportCredentials = CredentialsFactory.createReportCredentials(username, password); - CredentialsFactory.storeCredentials(reportCredentials); - - return reportCredentials; - }); - - mgrServerInfo.setReportDbCredentials(credentials); - - Map pillar = new HashMap<>(); - pillar.put("report_db_user", credentials.getUsername()); - pillar.put("report_db_password", credentials.getPassword()); - - MessageQueue.publish(new ApplyStatesEventMessage( - minion.getId(), - minion.getCreator() != null ? minion.getCreator().getId() : null, - false, - pillar, - ApplyStatesEventMessage.REPORTDB_USER - )); + return false; } /** diff --git a/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreateAcquisitor.java b/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreateAcquisitor.java index eef88b0a321d..ed1997bfbce7 100644 --- a/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreateAcquisitor.java +++ b/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreateAcquisitor.java @@ -134,7 +134,7 @@ private Server getOrCreateProxySystem( SystemEntitlementManager systemEntitlementManager, User creator, String proxyName, Set fqdns, Integer port, String sshPublicKey ) { - Optional existing = findByAnyFqdn(fqdns); + Optional existing = ServerFactory.findByAnyFqdn(fqdns); if (existing.isPresent()) { Server server = existing.get(); if (!(server.hasEntitlement(EntitlementManager.FOREIGN) || @@ -230,14 +230,4 @@ private String getServerSshPublicKey(User user, String serverFqdn) { return new String(serverServer.getProxyInfo().getSshPublicKey()); } - - private Optional findByAnyFqdn(Set fqdns) { - for (String fqdn : fqdns) { - Optional server = ServerFactory.findByFqdn(fqdn); - if (server.isPresent()) { - return server; - } - } - return Optional.empty(); - } } diff --git a/java/code/src/com/redhat/rhn/taskomatic/TaskomaticApi.java b/java/code/src/com/redhat/rhn/taskomatic/TaskomaticApi.java index 325a2361aac2..f2298a7d5bea 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/TaskomaticApi.java +++ b/java/code/src/com/redhat/rhn/taskomatic/TaskomaticApi.java @@ -15,6 +15,7 @@ package com.redhat.rhn.taskomatic; import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; import com.redhat.rhn.common.conf.ConfigDefaults; import com.redhat.rhn.common.hibernate.HibernateFactory; @@ -76,7 +77,7 @@ public class TaskomaticApi { private XmlRpcClient getClient() throws TaskomaticApiException { try { - return new XmlRpcClient( + return new XmlRpcClient( ConfigDefaults.get().getTaskoServerUrl(), false); } catch (MalformedURLException e) { @@ -84,7 +85,7 @@ private XmlRpcClient getClient() throws TaskomaticApiException { } } - protected Object invoke(String name, Object...args) throws TaskomaticApiException { + protected Object invoke(String name, Object... args) throws TaskomaticApiException { try { return getClient().invoke(name, args); } @@ -95,6 +96,7 @@ protected Object invoke(String name, Object...args) throws TaskomaticApiExceptio /** * Returns whether taskomatic is running + * * @return True if taskomatic is running */ public boolean isRunning() { @@ -109,7 +111,8 @@ public boolean isRunning() { /** * Schedule a single ssh minion action. - * @param actionIn the action + * + * @param actionIn the action * @param sshMinion the Salt ssh minion * @throws TaskomaticApiException if there was an error */ @@ -120,8 +123,9 @@ public void scheduleSSHActionExecution(Action actionIn, MinionServer sshMinion) /** * Schedule a single ssh minion action. - * @param actionIn the action - * @param sshMinion the Salt ssh minion + * + * @param actionIn the action + * @param sshMinion the Salt ssh minion * @param forcePackageListRefresh force package list refresh when set to true * @throws TaskomaticApiException if there was an error */ @@ -142,12 +146,13 @@ public void scheduleSSHActionExecution(Action actionIn, MinionServer sshMinion, /** * Schedule a single reposync + * * @param chan the channel * @param user the user * @throws TaskomaticApiException if there was an error */ public void scheduleSingleRepoSync(Channel chan, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { Map scheduleParams = new HashMap<>(); scheduleParams.put("channel_id", chan.getId().toString()); invoke("tasko.scheduleSingleBunchRun", user.getOrg().getId(), @@ -158,6 +163,7 @@ public void scheduleSingleRepoSync(Channel chan, User user) * Schedule a single reposync for a given list of channels. This is scheduled from * within another taskomatic job, so we don't have a user here. We pass in the * satellite org to create the job label internally. + * * @param channels list of channels * @throws TaskomaticApiException if there was an error */ @@ -175,13 +181,14 @@ public void scheduleSingleRepoSync(List channels) /** * Schedule a single reposync - * @param chan the channel - * @param user the user + * + * @param chan the channel + * @param user the user * @param params parameters * @throws TaskomaticApiException if there was an error */ public void scheduleSingleRepoSync(Channel chan, User user, Map params) - throws TaskomaticApiException { + throws TaskomaticApiException { Map scheduleParams = new HashMap<>(); scheduleParams.put("channel_id", chan.getId().toString()); @@ -197,6 +204,7 @@ private String createRepoSyncScheduleName(Channel chan, User user) { /** * Schedule a recurring reposync + * * @param chan the channel * @param user the user * @param cron the cron format @@ -204,7 +212,7 @@ private String createRepoSyncScheduleName(Channel chan, User user) { * @throws TaskomaticApiException if there was an error */ public Date scheduleRepoSync(Channel chan, User user, String cron) - throws TaskomaticApiException { + throws TaskomaticApiException { String jobLabel = createRepoSyncScheduleName(chan, user); Map task = findScheduleByBunchAndLabel("repo-sync-bunch", jobLabel, user); @@ -219,15 +227,16 @@ public Date scheduleRepoSync(Channel chan, User user, String cron) /** * Schedule a recurring reposync - * @param chan the channel - * @param user the user - * @param cron the cron format + * + * @param chan the channel + * @param user the user + * @param cron the cron format * @param params parameters * @return the Date? * @throws TaskomaticApiException if there was an error */ public Date scheduleRepoSync(Channel chan, User user, String cron, - Map params) throws TaskomaticApiException { + Map params) throws TaskomaticApiException { String jobLabel = createRepoSyncScheduleName(chan, user); Map task = findScheduleByBunchAndLabel("repo-sync-bunch", jobLabel, user); @@ -244,21 +253,23 @@ public Date scheduleRepoSync(Channel chan, User user, String cron, /** * Creates a new single satellite schedule - * @param user shall be sat admin + * + * @param user shall be sat admin * @param bunchName bunch name - * @param params parameters for the bunch + * @param params parameters for the bunch * @return date of the first schedule * @throws TaskomaticApiException if there was an error */ public Date scheduleSingleSatBunch(User user, String bunchName, - Map params) throws TaskomaticApiException { + Map params) throws TaskomaticApiException { ensureSatAdminRole(user); return (Date) invoke("tasko.scheduleSingleSatBunchRun", bunchName, params); } - /** + /** * Creates a new single gatherer schedule - * @param user shall be org admin + * + * @param user shall be org admin * @param params parameters for the bunch * @return date of the first schedule * @throws TaskomaticApiException if there was an error @@ -270,18 +281,20 @@ public Date scheduleGathererRefresh(User user, Map params) throw /** * Validates user has sat admin role + * * @param user shall be sat admin * @throws PermissionException if there was an error */ private void ensureSatAdminRole(User user) { if (!user.hasRole(RoleFactory.SAT_ADMIN)) { ValidatorException.raiseException("satadmin.jsp.error.notsatadmin", - user.getLogin()); + user.getLogin()); } } /** * Validates user has org admin role + * * @param user shall be org admin * @throws PermissionException if there was an error */ @@ -293,6 +306,7 @@ private void ensureOrgAdminRole(User user) { /** * Validates user has channel admin role + * * @param user shall be channel admin * @throws PermissionException if there was an error */ @@ -304,15 +318,16 @@ private void ensureChannelAdminRole(User user) { /** * Creates a new schedule, unschedules, if en existing is defined - * @param user shall be sat admin - * @param jobLabel name of the schedule + * + * @param user shall be sat admin + * @param jobLabel name of the schedule * @param bunchName bunch name - * @param cron cron expression + * @param cron cron expression * @return date of the first schedule * @throws TaskomaticApiException if there was an error */ public Date scheduleSatBunch(User user, String jobLabel, String bunchName, String cron) - throws TaskomaticApiException { + throws TaskomaticApiException { ensureSatAdminRole(user); return doScheduleSatBunch(user, jobLabel, bunchName, cron); } @@ -321,8 +336,8 @@ public Date scheduleSatBunch(User user, String jobLabel, String bunchName, Strin * Schedule a recurring action * * @param action the {@link RecurringAction} - * @param user the scheduler user - * @throws PermissionException when given user does not have permissions for scheduling given action + * @param user the scheduler user + * @throws PermissionException when given user does not have permissions for scheduling given action * @throws TaskomaticApiException on Taskomatic error */ public void scheduleRecurringAction(RecurringAction action, User user) throws TaskomaticApiException { @@ -341,15 +356,15 @@ private Date doScheduleSatBunch(User user, String jobLabel, String bunchName, St if (task != null) { doUnscheduleSatTask(jobLabel); } - return (Date) invoke("tasko.scheduleSatBunch", bunchName, jobLabel , cron, new HashMap<>()); + return (Date) invoke("tasko.scheduleSatBunch", bunchName, jobLabel, cron, new HashMap<>()); } /** * Unschedule a recurring action * * @param action the {@link RecurringAction} - * @param user the unscheduler user - * @throws PermissionException when given user does not have permissions for unscheduling given action + * @param user the unscheduler user + * @throws PermissionException when given user does not have permissions for unscheduling given action * @throws TaskomaticApiException on Taskomatic error */ public void unscheduleRecurringAction(RecurringAction action, User user) throws TaskomaticApiException { @@ -375,6 +390,7 @@ private void doUnscheduleSatTask(String jobLabel) throws TaskomaticApiException /** * Unchedule a reposync task + * * @param chan the channel * @param user the user * @throws TaskomaticApiException if there was an error @@ -388,25 +404,27 @@ public void unscheduleRepoSync(Channel chan, User user) throws TaskomaticApiExce } private void unscheduleRepoTask(String jobLabel, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { ensureChannelAdminRole(user); invoke("tasko.unscheduleBunch", user.getOrg().getId(), jobLabel); } /** * unschedule satellite task + * * @param jobLabel schedule name - * @param user shall be satellite admin + * @param user shall be satellite admin * @throws TaskomaticApiException if there was an error */ public void unscheduleSatTask(String jobLabel, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { ensureSatAdminRole(user); invoke("tasko.unscheduleSatBunches", singletonList(jobLabel)); } /** * Return list of active schedules + * * @param user shall be sat admin * @return list of schedules * @throws TaskomaticApiException if there was an error @@ -418,7 +436,8 @@ public List> findActiveSchedules(User user) throws Taskomati /** * Return list of bunch runs - * @param user shall be sat admin + * + * @param user shall be sat admin * @param bunchName name of the bunch * @return list of schedules * @throws TaskomaticApiException if there was an error @@ -430,56 +449,58 @@ public List> findRunsByBunch(User user, String bunchName) th @SuppressWarnings("unchecked") private Map findScheduleByBunchAndLabel(String bunchName, String jobLabel, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { List> schedules = (List>) invoke("tasko.listActiveSchedulesByBunch", user.getOrg().getId(), bunchName); for (Map schedule : schedules) { if (schedule.get("job_label").equals(jobLabel)) { return schedule; } - } + } return null; } private Map findSatScheduleByBunchAndLabel(String bunchName, String jobLabel, - User user) throws TaskomaticApiException { + User user) throws TaskomaticApiException { List> schedules = (List>) invoke("tasko.listActiveSatSchedulesByBunch", bunchName); for (Map schedule : schedules) { if (schedule.get("job_label").equals(jobLabel)) { return schedule; } - } + } return null; } /** * Check whether there's an active schedule of given job label + * * @param jobLabel job label - * @param user the user + * @param user the user * @return true, if schedule exists * @throws TaskomaticApiException if there was an error */ public boolean satScheduleActive(String jobLabel, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { List> schedules = (List>) invoke("tasko.listActiveSatSchedules"); for (Map schedule : schedules) { if (schedule.get("job_label").equals(jobLabel)) { return Boolean.TRUE; } - } + } return Boolean.FALSE; } /** * Get the cron format for a single channel + * * @param chan the channel * @param user the user * @return the Cron format * @throws TaskomaticApiException if there was an error */ public String getRepoSyncSchedule(Channel chan, User user) - throws TaskomaticApiException { + throws TaskomaticApiException { String jobLabel = createRepoSyncScheduleName(chan, user); Map task = findScheduleByBunchAndLabel("repo-sync-bunch", jobLabel, user); if (task == null) { @@ -490,6 +511,7 @@ public String getRepoSyncSchedule(Channel chan, User user) /** * Return list of available bunches + * * @param user shall be sat admin * @return list of bunches * @throws TaskomaticApiException if there was an error @@ -501,43 +523,47 @@ public List> listSatBunchSchedules(User user) throws Taskoma /** * looks up schedule according to id - * @param user shall be sat admin + * + * @param user shall be sat admin * @param scheduleId schedule id * @return schedule * @throws TaskomaticApiException if there was an error */ public Map lookupScheduleById(User user, Long scheduleId) - throws TaskomaticApiException { + throws TaskomaticApiException { return (Map) invoke("tasko.lookupScheduleById", scheduleId); } /** * looks up schedule according to label - * @param user shall be sat admin - * @param bunchName bunch name + * + * @param user shall be sat admin + * @param bunchName bunch name * @param scheduleLabel schedule label * @return schedule * @throws TaskomaticApiException if there was an error */ public Map lookupScheduleByBunchAndLabel(User user, String bunchName, - String scheduleLabel) throws TaskomaticApiException { + String scheduleLabel) throws TaskomaticApiException { return findSatScheduleByBunchAndLabel(bunchName, scheduleLabel, user); } /** * looks up bunch according to name - * @param user shall be sat admin + * + * @param user shall be sat admin * @param bunchName bunch name * @return bunch * @throws TaskomaticApiException if there was an error */ public Map lookupBunchByName(User user, String bunchName) - throws TaskomaticApiException { + throws TaskomaticApiException { return (Map) invoke("tasko.lookupBunchByName", bunchName); } /** * List all reposync schedules within an organization + * * @param org organization * @return list of schedules */ @@ -554,6 +580,7 @@ private List listActiveRepoSyncSchedules(Org org) { /** * unschedule all outdated repo-sync schedules within an org + * * @param orgIn organization * @return number of removed schedules * @throws TaskomaticApiException if there was an error @@ -578,9 +605,9 @@ public int unscheduleInvalidRepoSyncSchedules(Org orgIn) throws TaskomaticApiExc /** * Schedule an Action execution for Salt minions. * - * @param action the action to be executed + * @param action the action to be executed * @param forcePackageListRefresh is a package list is requested - * @param checkIfMinionInvolved check if action involves minions + * @param checkIfMinionInvolved check if action involves minions * @throws TaskomaticApiException if there was an error */ public void scheduleActionExecution(Action action, boolean forcePackageListRefresh, boolean checkIfMinionInvolved) @@ -599,10 +626,11 @@ public void scheduleActionExecution(Action action, boolean forcePackageListRefre } scheduleMinionActionExecutions(singletonList(action), forcePackageListRefresh); } + /** * Schedule Actions execution for Salt minions. * - * @param actions the list of actions to be executed + * @param actions the list of actions to be executed * @param forcePackageListRefresh is a package list is requested * @throws TaskomaticApiException if there was an error */ @@ -610,7 +638,7 @@ public void scheduleMinionActionExecutions(List actions, boolean forcePa throws TaskomaticApiException { List> paramsList = new ArrayList<>(); List ids = new ArrayList<>(); - for (Action action: actions) { + for (Action action : actions) { Map params = new HashMap<>(); String id = Long.toString(action.getId()); params.put("action_id", id); @@ -631,7 +659,7 @@ public void scheduleMinionActionExecutions(List actions, boolean forcePa * @throws TaskomaticApiException if there was an error */ public void scheduleActionChainExecution(ActionChain actionchain) - throws TaskomaticApiException { + throws TaskomaticApiException { if (!ActionChainFactory.isActionChainTargettingMinions(actionchain)) { return; } @@ -649,13 +677,13 @@ public void scheduleActionChainExecution(ActionChain actionchain) /** * Schedule a staging job for Salt minions. * - * @param actionId ID of the action to be executed - * @param minionId ID of the minion involved + * @param actionId ID of the action to be executed + * @param minionId ID of the minion involved * @param stagingDateTime scheduling time of staging * @throws TaskomaticApiException if there was an error */ public void scheduleStagingJob(Long actionId, Long minionId, Date stagingDateTime) - throws TaskomaticApiException { + throws TaskomaticApiException { Map params = new HashMap<>(); params.put("action_id", Long.toString(actionId)); params.put("staging_job", "true"); @@ -668,6 +696,7 @@ public void scheduleStagingJob(Long actionId, Long minionId, Date stagingDateTim /** * Schedule a staging job for Salt minions. + * * @param actionData Map containing mapping between action and minions data * @throws TaskomaticApiException if there was an error */ @@ -694,14 +723,14 @@ public void scheduleStagingJobs(Map> actionData) * @throws TaskomaticApiException if there was an error */ public void scheduleActionExecution(Action action) - throws TaskomaticApiException { + throws TaskomaticApiException { scheduleActionExecution(action, false); } /** * Schedule an Action execution for Salt minions. * - * @param action the action to be executed + * @param action the action to be executed * @param forcePackageListRefresh is a package list is requested * @throws TaskomaticApiException if there was an error */ @@ -713,7 +742,7 @@ public void scheduleActionExecution(Action action, boolean forcePackageListRefre /** * Schedule a channel subscription action. * - * @param user the user that schedules the action + * @param user the user that schedules the action * @param action the action to schedule * @throws TaskomaticApiException if there was an error */ @@ -734,7 +763,7 @@ public void scheduleSubscribeChannels(User user, SubscribeChannelsAction action) * @throws TaskomaticApiException if there was an error */ public void deleteScheduledActions(Map> actionMap) - throws TaskomaticApiException { + throws TaskomaticApiException { List actionsToBeUnscheduled = actionMap.entrySet().stream() // select Actions that have no minions besides those in the specified set @@ -772,6 +801,7 @@ public void deleteScheduledActions(Map> actionMap) /** * Schedule a single reposync + * * @param sshdata the payg ssh connection data * @throws TaskomaticApiException if there was an error */ @@ -784,10 +814,60 @@ public void scheduleSinglePaygUpdate(PaygSshData sshdata) /** * Check if the Taskomatic java process has JMX enabled. + * * @return true is JMX enabled * @throws TaskomaticApiException if there was an error */ public boolean isJmxEnabled() throws TaskomaticApiException { - return (Boolean)invoke("tasko.isJmxEnabled"); + return (Boolean) invoke("tasko.isJmxEnabled"); + } + + /** + * Schedule one root ca certificate update + * + * @param fileName filename of the ca certificate + * @param rootCaCertContent root ca certificate actual content + * @throws TaskomaticApiException if there was an error + */ + public void scheduleSingleRootCaCertUpdate(String fileName, String rootCaCertContent) + throws TaskomaticApiException { + scheduleSingleRootCaCertUpdate(singletonMap(fileName, rootCaCertContent)); + } + + /** + * Schedule multiple root ca certificates update. + * + * @param filenameToRootCaCertMap maps filename to root ca certificate actual content + * @throws TaskomaticApiException if there was an error + */ + public void scheduleSingleRootCaCertUpdate(Map filenameToRootCaCertMap) + throws TaskomaticApiException { + + if ((null == filenameToRootCaCertMap) || filenameToRootCaCertMap.isEmpty()) { + return; // nothing to do: avoid invoke call, to spare a potential exception + } + + //sanitise map keys and values: XmlRpc actual call does not like null strings + //(exception: Cannot invoke "Object.toString()" because "key" is null) + Map sanitisedFilenameToRootCaCertMap = filenameToRootCaCertMap.entrySet() + .stream() + .collect(Collectors.toMap(p -> Objects.toString(p.getKey(), ""), + p -> Objects.toString(p.getValue(), ""))); + + Map paramList = new HashMap<>(); + paramList.put("filename_to_root_ca_cert_map", sanitisedFilenameToRootCaCertMap); + invoke("tasko.scheduleSingleSatBunchRun", "root-ca-cert-update-bunch", paramList); + } + + /** + * Schedule an import of a GPG key. + * @param gpgKey the GPG key (armored text) + * @throws TaskomaticApiException if there was an error + */ + public void scheduleSingleGpgKeyImport(String gpgKey) throws TaskomaticApiException { + if (StringUtils.isBlank(gpgKey)) { + return; + } + invoke("tasko.scheduleSingleSatBunchRun", "custom-gpg-key-import-bunch", Map.of("gpg-key", gpgKey)); } } diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/ForwardRegistrationTask.java b/java/code/src/com/redhat/rhn/taskomatic/task/ForwardRegistrationTask.java index 34a647c9b7fb..1b8276b37669 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/task/ForwardRegistrationTask.java +++ b/java/code/src/com/redhat/rhn/taskomatic/task/ForwardRegistrationTask.java @@ -31,6 +31,7 @@ import com.suse.scc.SCCSystemRegistrationManager; import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.SCCVirtualizationHostJson; @@ -116,7 +117,12 @@ private void executeSCCTasks(SCCCredentials primaryCredentials) { URI url = new URI(Config.get().getString(ConfigDefaults.SCC_URL)); String uuid = ContentSyncManager.getUUID(); SCCCachingFactory.initNewSystemsToForward(); - SCCConfig sccConfig = new SCCConfig(url, "", "", uuid); + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(url) + .setUsername("") + .setPassword("") + .setUuid(uuid) + .createSCCConfig(); SCCClient sccClient = new SCCWebClient(sccConfig); SCCSystemRegistrationManager sccRegManager = new SCCSystemRegistrationManager(sccClient); diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/GpgImportTask.java b/java/code/src/com/redhat/rhn/taskomatic/task/GpgImportTask.java new file mode 100644 index 000000000000..702cfd84d8e7 --- /dev/null +++ b/java/code/src/com/redhat/rhn/taskomatic/task/GpgImportTask.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.taskomatic.task; + +import com.suse.utils.CertificateUtils; + +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; + +/** + * Taskomatic task that import a GPG keys to the customer keyring + * After saving the GPG key, the system configuration is refreshed. + */ +public class GpgImportTask extends RhnJavaJob { + + @Override + public String getConfigNamespace() { + return "gpg-update"; + } + + /** + * {@inheritDoc} + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + final JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + if (!jobDataMap.containsKey("gpg-key")) { + log.error("No GPG key provided"); + return; + } + try { + CertificateUtils.importGpgKey((String)jobDataMap.get("gpg-key")); + } + catch (Exception e) { + log.error("Importing the GPG key failed", e); + } + } +} diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/MgrSyncRefresh.java b/java/code/src/com/redhat/rhn/taskomatic/task/MgrSyncRefresh.java index 68a7508e8277..ae24a8d234b8 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/task/MgrSyncRefresh.java +++ b/java/code/src/com/redhat/rhn/taskomatic/task/MgrSyncRefresh.java @@ -75,6 +75,7 @@ public void execute(JobExecutionContext context) throws JobExecutionException { } // Use mgr-inter-sync if this server is an ISS slave + // This is exclusively for ISSv1. Hub online Sync is using standard SCC like sync. if (IssFactory.getCurrentMaster() != null) { log.info("This server is an ISS slave, refresh using mgr-inter-sync"); List cmd = new ArrayList<>(); diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/ReportDBHelper.java b/java/code/src/com/redhat/rhn/taskomatic/task/ReportDBHelper.java index aad675675f38..685313ab734e 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/task/ReportDBHelper.java +++ b/java/code/src/com/redhat/rhn/taskomatic/task/ReportDBHelper.java @@ -14,6 +14,8 @@ */ package com.redhat.rhn.taskomatic.task; +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; import com.redhat.rhn.common.db.datasource.DataResult; import com.redhat.rhn.common.db.datasource.GeneratedSelectMode; import com.redhat.rhn.common.db.datasource.GeneratedWriteMode; @@ -196,4 +198,70 @@ public void analyzeReportDb(Session session) { var m = ModeFactory.getCallableMode(session, "GeneralReport_queries", "analyze_reportdb"); m.execute(new HashMap<>(), new HashMap<>()); } + + /** + * Check if a specific user is configured in the database + * @param session the session + * @param username the username to search for + * @return return true when the user exists, otherwise return false + */ + public boolean hasDBUser(Session session, String username) { + final String sqlStatement = "SELECT usename FROM pg_catalog.pg_user"; + var m = new GeneratedSelectMode("select.pg_catalog.user", session, sqlStatement, Collections.emptyList()); + DataResult> result = m.execute(); + return result.stream().map(e -> e.get("usename")).anyMatch(n -> n.equalsIgnoreCase(username)); + } + + /** + * Create a new user in the given database and grant permissions + * @param session the session + * @param dbName the db name + * @param username the new username + * @param password the new password + */ + public void createDBUser(Session session, String dbName, String username, String password) { + final String sql = """ + CREATE ROLE %1$s WITH LOGIN PASSWORD '%2$s' NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + GRANT CONNECT ON DATABASE %3$s TO %1$s; + GRANT USAGE ON SCHEMA public TO %1$s; + GRANT SELECT ON ALL TABLES IN SCHEMA public TO %1$s; + GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO %1$s; + """.formatted(username, password, dbName); + var i = new GeneratedWriteMode("grant.permissions", session, sql, Collections.emptyList()); + i.executeUpdate(Collections.emptyMap()); + } + + /** + * Change the password for the given username + * @param session the session + * @param username the username + * @param password the new password to set + */ + public void changeDBPassword(Session session, String username, String password) { + final String sql = "ALTER USER %1$s PASSWORD '%2$s'".formatted(username, password); + var i = new GeneratedWriteMode("alter.user", session, sql, Collections.emptyList()); + i.executeUpdate(Collections.emptyMap()); + } + + /** + * Drop a given user + * @param session the session + * @param username the username to drop + */ + public void dropDBUser(Session session, String username) { + List restricted = List.of("postgres", + Config.get().getString(ConfigDefaults.REPORT_DB_USER), + Config.get().getString(ConfigDefaults.DB_USER)); + if (restricted.contains(username)) { + throw new IllegalArgumentException("Forbidden to drop restricted user: " + username); + } + + final String sql = """ + DROP OWNED BY %1$s; + DROP ROLE %1$s; + """.formatted(username); + + var i = new GeneratedWriteMode("drop.user", session, sql, Collections.emptyList()); + i.executeUpdate(Collections.emptyMap()); + } } diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/RootCaCertUpdateTask.java b/java/code/src/com/redhat/rhn/taskomatic/task/RootCaCertUpdateTask.java new file mode 100644 index 000000000000..b99c71728485 --- /dev/null +++ b/java/code/src/com/redhat/rhn/taskomatic/task/RootCaCertUpdateTask.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.redhat.rhn.taskomatic.task; + +import com.suse.utils.CertificateUtils; + +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Taskomatic task that checks if new certificates have been added or updated + * and saves them accordingly in the trusted certificate path. + * After saving the certificates, the system configuration is refreshed. + */ +public class RootCaCertUpdateTask extends RhnJavaJob { + + private static final String MAP_KEY = "filename_to_root_ca_cert_map"; + + @Override + public String getConfigNamespace() { + return "root-ca-cert-update"; + } + + private Map getFilenameToRootCaCertMap(final JobDataMap jobDataMap) { + Map filenameToRootCaCertMap = new HashMap<>(); + + if (jobDataMap.containsKey(MAP_KEY)) { + try { + filenameToRootCaCertMap = (Map) jobDataMap.get(MAP_KEY); + } + catch (ClassCastException e) { + //filenameToRootCaCertMap is already empty + log.debug("error while extracting filename to root certificate map"); + } + } + return filenameToRootCaCertMap; + } + + /** + * {@inheritDoc} + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + final JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + Map filenameToRootCaCertMap = getFilenameToRootCaCertMap(jobDataMap); + + CertificateUtils.saveAndUpdateCertificates(filenameToRootCaCertMap); + } +} diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/payg/PaygUpdateHostsTask.java b/java/code/src/com/redhat/rhn/taskomatic/task/payg/PaygUpdateHostsTask.java index 08d4ec6d0486..fa54b9e225e1 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/task/payg/PaygUpdateHostsTask.java +++ b/java/code/src/com/redhat/rhn/taskomatic/task/payg/PaygUpdateHostsTask.java @@ -19,6 +19,8 @@ import com.redhat.rhn.domain.cloudpayg.CloudRmtHostFactory; import com.redhat.rhn.taskomatic.task.RhnJavaJob; +import com.suse.utils.CertificateUtils; + import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; @@ -28,7 +30,9 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class PaygUpdateHostsTask extends RhnJavaJob { private static final String HOSTS = "/etc/hosts"; @@ -36,7 +40,7 @@ public class PaygUpdateHostsTask extends RhnJavaJob { private static final String HOST_COMMENT_START = "# Added by Suma - Start"; private static final String HOST_COMMENT_END = "# Added by Suma - End"; - private static final String CA_LOCATION_TEMPLATE = "/etc/pki/trust/anchors/registration_server_%s.pem"; + private static final String CA_FILENAME_TEMPLATE_WITH_IP = "registration_server_%s.pem"; @Override public String getConfigNamespace() { @@ -53,31 +57,14 @@ public void execute(JobExecutionContext jobExecutionContext) throws JobExecution } } - private void loadHttpsCertificates(List hostToUpdate) throws JobExecutionException { - try { - for (CloudRmtHost host : hostToUpdate) { - String caFileName = String.format(CA_LOCATION_TEMPLATE, host.getIp()); - try (FileWriter fw = new FileWriter(caFileName, false)) { - fw.write(host.getSslCert()); - } - } - } - catch (IOException e) { - log.error("error when writing the hosts file", e); - } - finally { - if (!hostToUpdate.isEmpty()) { - try { - String[] cmd = {"systemctl", "is-active", "--quiet", "ca-certificates.path"}; - executeExtCmd(cmd); - } - catch (Exception e) { - log.debug("ca-certificates.path service is not active, we will call 'update-ca-certificates' tool"); - String[] cmd = {"/usr/share/rhn/certs/update-ca-cert-trust.sh"}; - executeExtCmd(cmd); - } - } + private void loadHttpsCertificates(List hostToUpdate) { + Map filenameToRootCaCertMap = new HashMap<>(); + for (CloudRmtHost host : hostToUpdate) { + String caFileName = String.format(CA_FILENAME_TEMPLATE_WITH_IP, host.getIp()); + filenameToRootCaCertMap.put(caFileName, host.getSslCert()); } + + CertificateUtils.saveAndUpdateCertificates(filenameToRootCaCertMap); } private void updateHost(List hostToUpdate) { diff --git a/java/code/src/com/redhat/rhn/taskomatic/task/repomd/DebPackageWriter.java b/java/code/src/com/redhat/rhn/taskomatic/task/repomd/DebPackageWriter.java index 327b8e73a87f..c83113800aa4 100644 --- a/java/code/src/com/redhat/rhn/taskomatic/task/repomd/DebPackageWriter.java +++ b/java/code/src/com/redhat/rhn/taskomatic/task/repomd/DebPackageWriter.java @@ -35,6 +35,7 @@ import java.io.IOException; import java.io.StringWriter; import java.util.Collection; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -127,7 +128,7 @@ public void addPackage(PackageDto pkgDto) throws IOException { buf.write(pkgDto.getArchLabel().replace("-deb", "")); buf.newLine(); - String vendor = StringUtils.defaultString(pkgDto.getVendor(), "Debian"); + String vendor = Objects.toString(pkgDto.getVendor(), "Debian"); buf.write("Maintainer: "); buf.write(vendor); buf.newLine(); diff --git a/java/code/src/com/suse/cloud/CloudPaygManager.java b/java/code/src/com/suse/cloud/CloudPaygManager.java index 5b840dc64511..a256f329ce6f 100644 --- a/java/code/src/com/suse/cloud/CloudPaygManager.java +++ b/java/code/src/com/suse/cloud/CloudPaygManager.java @@ -16,10 +16,17 @@ import com.redhat.rhn.common.util.http.HttpClientAdapter; import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.redhat.rhn.frontend.xmlrpc.sync.content.ContentSyncSourceException; +import com.redhat.rhn.frontend.xmlrpc.sync.content.SCCContentSyncSource; import com.redhat.rhn.manager.content.ContentSyncManager; import com.redhat.rhn.taskomatic.TaskomaticApi; import com.redhat.rhn.taskomatic.TaskomaticApiException; +import com.suse.scc.client.SCCClient; +import com.suse.scc.client.SCCClientException; +import com.suse.scc.client.SCCConfig; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -34,6 +41,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; @@ -54,14 +62,12 @@ public class CloudPaygManager { private Instant cacheTime; private final TaskomaticApi tapi; - private final ContentSyncManager mgr; /** * Constructor */ public CloudPaygManager() { tapi = new TaskomaticApi(); - mgr = new ContentSyncManager(null, this); cacheTime = Instant.MIN; hasSCCCredentials = null; complainceInfo = null; @@ -71,11 +77,9 @@ public CloudPaygManager() { /** * Constructor * @param tapiIn the taskomatic api object - * @param syncManagerIn the content sync manager object */ - public CloudPaygManager(TaskomaticApi tapiIn, ContentSyncManager syncManagerIn) { + public CloudPaygManager(TaskomaticApi tapiIn) { tapi = tapiIn; - mgr = syncManagerIn; cacheTime = Instant.MIN; hasSCCCredentials = null; complainceInfo = null; @@ -143,7 +147,24 @@ public boolean checkRefreshCache(boolean force) { private boolean detectHasSCCCredentials() { return CredentialsFactory.listSCCCredentials().stream() - .anyMatch(mgr::isSCCCredentials); + .anyMatch(this::isSCCCredentials); + } + + /** + * Check if the provided Credentials are usable for SCC. OES credentials will return false. + * @param c the credentials + * @return true if they can be used for SCC, otherwise false + */ + protected boolean isSCCCredentials(SCCCredentials c) { + try { + SCCClient scc = new SCCContentSyncSource(c) + .getClient(ContentSyncManager.getUUID(), Paths.get(SCCConfig.DEFAULT_LOGGING_DIR)); + scc.listOrders(); + } + catch (SCCClientException | ContentSyncSourceException e) { + return false; + } + return true; } private boolean detectIsCompliant() { diff --git a/java/code/src/com/suse/cloud/domain/BillingDimension.java b/java/code/src/com/suse/cloud/domain/BillingDimension.java index cc1527b77c4c..2eb5f713ceca 100644 --- a/java/code/src/com/suse/cloud/domain/BillingDimension.java +++ b/java/code/src/com/suse/cloud/domain/BillingDimension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 SUSE LLC + * Copyright (c) 2023--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,51 +7,17 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.cloud.domain; -import java.util.Arrays; -import java.util.Objects; +import com.redhat.rhn.domain.Labeled; -public enum BillingDimension { +public enum BillingDimension implements Labeled { MANAGED_SYSTEMS, MONITORING; - private final String label; - - /** - * Default constructor, uses the enum name converted to lowercase as label - */ - BillingDimension() { - this(null); - } - - /** - * Constructor to explicitly specify a label - * @param labelIn the label for this enum value - */ - BillingDimension(String labelIn) { - this.label = labelIn != null ? labelIn : this.name().toLowerCase(); - } - public String getLabel() { - return label; - } - - /** - * Retrieve the {@link BillingDimension} with the given label - * @param label the label of the dimension - * @return the enum value corresponding to the specified label - */ - public static BillingDimension byLabel(String label) { - return Arrays.stream(BillingDimension.values()) - .filter(e -> Objects.equals(e.getLabel(), label)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Invalid BillingDimension value " + label)); + return this.name().toLowerCase(); } } diff --git a/java/code/src/com/suse/cloud/domain/BillingDimensionEnumType.java b/java/code/src/com/suse/cloud/domain/BillingDimensionEnumType.java index 36d135d9bb46..9a3fe61f7481 100644 --- a/java/code/src/com/suse/cloud/domain/BillingDimensionEnumType.java +++ b/java/code/src/com/suse/cloud/domain/BillingDimensionEnumType.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 SUSE LLC + * Copyright (c) 2023--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,33 +7,21 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.cloud.domain; -import com.redhat.rhn.domain.errata.CustomEnumType; - -import java.sql.Types; +import com.redhat.rhn.domain.DatabaseEnumType; /** - * Maps the {@link BillingDimension} enum to its integer id + * Maps the {@link BillingDimension} enum to its label */ -public class BillingDimensionEnumType extends CustomEnumType { +public class BillingDimensionEnumType extends DatabaseEnumType { /** * Default Constructor */ public BillingDimensionEnumType() { - super(BillingDimension.class, String.class, BillingDimension::getLabel, v -> BillingDimension.byLabel(v)); - } - - @Override - public int getSqlType() { - // Returning other, as this is mapped to a PostgreSQL enum, not to a VARCHAR - return Types.OTHER; + super(BillingDimension.class); } } diff --git a/java/code/src/com/suse/cloud/test/TestCloudPaygManagerBuilder.java b/java/code/src/com/suse/cloud/test/TestCloudPaygManagerBuilder.java index 834db5b7c278..c79ee9474115 100644 --- a/java/code/src/com/suse/cloud/test/TestCloudPaygManagerBuilder.java +++ b/java/code/src/com/suse/cloud/test/TestCloudPaygManagerBuilder.java @@ -17,7 +17,6 @@ import com.redhat.rhn.domain.credentials.SCCCredentials; import com.redhat.rhn.domain.user.User; -import com.redhat.rhn.manager.content.ContentSyncManager; import com.redhat.rhn.taskomatic.TaskomaticApi; import com.redhat.rhn.taskomatic.TaskomaticApiException; @@ -139,13 +138,6 @@ public TestCloudPaygManagerBuilder withoutSCCCredentials() { } public CloudPaygManager build() { - ContentSyncManager syncManager = new ContentSyncManager() { - @Override - public boolean isSCCCredentials(SCCCredentials c) { - return sccCredentials; - } - }; - TaskomaticApi taskoApi = new TaskomaticApi() { @Override public Map lookupScheduleByBunchAndLabel(User user, String bunchName, String scheduleLabel) @@ -161,7 +153,7 @@ else if (dimensionComputationSchedule.isLeft()) { } }; - return new CloudPaygManager(taskoApi, syncManager) { + return new CloudPaygManager(taskoApi) { @Override protected String requestUrl(String url) { return billingDataServiceStatus; @@ -173,6 +165,11 @@ protected PaygComplainceInfo getInstanceComplianceInfo() { cloudProvider, payg, packageModified, billingAdapterRunning, billingAdapterHealthy, meteringAccess ); } + + @Override + protected boolean isSCCCredentials(SCCCredentials c) { + return sccCredentials; + } }; } } diff --git a/java/code/src/com/suse/manager/api/test/TestHandler.java b/java/code/src/com/suse/manager/api/test/TestHandler.java index a51069a51da7..f0a50188d8ca 100644 --- a/java/code/src/com/suse/manager/api/test/TestHandler.java +++ b/java/code/src/com/suse/manager/api/test/TestHandler.java @@ -19,11 +19,10 @@ import com.suse.manager.api.ReadOnly; -import org.apache.commons.lang3.StringUtils; - import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -50,7 +49,7 @@ public Long withUser(User user) { public Map basicTypes(Integer myInteger, String myString, Boolean myBoolean) { return Map.of( "myInteger", myInteger, - "myString", StringUtils.defaultString(myString, "-empty-"), + "myString", Objects.toString(myString, "-empty-"), "myBoolean", myBoolean); } diff --git a/java/code/src/com/suse/manager/hub/DefaultHubInternalClient.java b/java/code/src/com/suse/manager/hub/DefaultHubInternalClient.java new file mode 100644 index 000000000000..5e52a2996f64 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/DefaultHubInternalClient.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import com.redhat.rhn.common.util.http.HttpClientAdapter; + +import com.suse.manager.model.hub.ChannelInfoJson; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.OrgInfoJson; +import com.suse.manager.model.hub.RegisterJson; +import com.suse.manager.model.hub.SCCCredentialsJson; +import com.suse.manager.webui.controllers.ECMAScriptDateAdapter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.lang.reflect.Type; +import java.security.cert.Certificate; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * HTTP Client for the Hub Inter-Server-Sync internal server-to-server APIs + */ +public class DefaultHubInternalClient implements HubInternalClient { + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(Date.class, new ECMAScriptDateAdapter()) + .serializeNulls() + .create(); + + private final String remoteHost; + + private final HttpClientAdapter httpClientAdapter; + + private final String accessToken; + + /** + * Creates a client to connect to the specified remote host + * @param remoteHostIn the remote host + * @param tokenIn the token for authenticating + * @param rootCA the root certificate, if needed to establish a secure connection + */ + public DefaultHubInternalClient(String remoteHostIn, String tokenIn, Optional rootCA) { + this.remoteHost = remoteHostIn; + this.httpClientAdapter = new HttpClientAdapter(rootCA.stream().toList()); + this.accessToken = tokenIn; + } + + @Override + public void registerHub(String token, String rootCA, String gpgKey) throws IOException { + invokePost("hub/sync", "registerHub", new RegisterJson(token, rootCA, gpgKey)); + } + + @Override + public void storeCredentials(String username, String password) throws IOException { + invokePost("hub/sync", "storeCredentials", new SCCCredentialsJson(username, password)); + } + + @Override + public ManagerInfoJson getManagerInfo() throws IOException { + return invokeGet("hub", "managerinfo", ManagerInfoJson.class); + } + + @Override + public void storeReportDbCredentials(String username, String password) throws IOException { + invokePost("hub", "storeReportDbCredentials", Map.of("username", username, "password", password)); + } + + @Override + public String replaceTokens(String newHubToken) throws IOException { + return invokePost("hub/sync", "replaceTokens", newHubToken, String.class); + } + + @Override + public void deregister() throws IOException { + invokePost("hub/sync", "deregister", null); + } + + @Override + public List getAllPeripheralOrgs() throws IOException { + // Use a TypeToken to preserve the generic type information + Type type = new TypeToken>() { }.getType(); + return invokeGet("hub", "listAllPeripheralOrgs", type); + } + + @Override + public List getAllPeripheralChannels() throws IOException { + // Use a TypeToken to preserve the generic type information + Type type = new TypeToken>() { }.getType(); + return invokeGet("hub", "listAllPeripheralChannels", type); + } + + @Override + public List syncVendorChannels(List channelsLabelIn) throws IOException { + // Use a TypeToken to preserve the generic type information + Type type = new TypeToken>() { }.getType(); + return invokePost("hub", "addVendorChannels", channelsLabelIn, type); + } + + private R invokeGet(String namespace, String apiMethod, Type responseType) + throws IOException { + return invoke(HttpGet.METHOD_NAME, namespace, apiMethod, null, responseType); + } + + private void invokePost(String namespace, String apiMethod, T requestObject) throws IOException { + invoke(HttpPost.METHOD_NAME, namespace, apiMethod, requestObject, Void.class); + } + + private R invokePost(String namespace, String apiMethod, T requestObject, Type responseType) + throws IOException { + return invoke(HttpPost.METHOD_NAME, namespace, apiMethod, requestObject, responseType); + } + + private R invoke( + String httpMethod, String namespace, String apiMethod, T requestObject, Type responseType + ) throws IOException { + RequestBuilder builder = RequestBuilder.create(httpMethod) + .setUri("https://%s/rhn/%s/%s".formatted(remoteHost, namespace, apiMethod)) + .setHeader("Authorization", "Bearer " + accessToken); + + // Add the request object, if specified + if (requestObject != null) { + String body = GSON.toJson(requestObject, new TypeToken<>() { }.getType()); + builder.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); + } + + HttpRequestBase request = (HttpRequestBase) builder.build(); + HttpResponse response = httpClientAdapter.executeRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + // Ensure we get a valid response + if (statusCode != HttpStatus.SC_OK) { + throw new InvalidResponseException("Unexpected response code %d".formatted(statusCode)); + } + + // Parse the response object, if specified + if (!Void.class.equals(responseType)) { + try (Reader responseReader = new InputStreamReader(response.getEntity().getContent())) { + return Objects.requireNonNull(GSON.fromJson(responseReader, responseType)); + } + catch (Exception ex) { + throw new InvalidResponseException("Unable to parse the JSON response", ex); + } + } + return null; + } + +} diff --git a/java/code/src/com/suse/manager/hub/HubClientFactory.java b/java/code/src/com/suse/manager/hub/HubClientFactory.java new file mode 100644 index 000000000000..22826f2534e7 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubClientFactory.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import com.suse.manager.xmlrpc.iss.RestHubExternalClient; +import com.suse.utils.CertificateUtils; + +import java.io.IOException; +import java.security.cert.CertificateException; + +public class HubClientFactory { + + /** + * Creates a new ISS client for the internal API + * @param fqdn the FQDN of the remote server + * @param token the token to access the server + * @param rootCA the root certificate, if needed + * @return a client to invoke the internal APIs + * @throws CertificateException when the certificate is not parseable + */ + public HubInternalClient newInternalClient(String fqdn, String token, String rootCA) throws CertificateException { + return new DefaultHubInternalClient(fqdn, token, CertificateUtils.parse(rootCA)); + } + + /** + * Creates a new ISS client for the internal API + * @param fqdn the FQDN of the remote server + * @param username the username + * @param password the password + * @param rootCA the root certificate, if needed + * @return a client to invoke the internal APIs + * @throws IOException when the client cannot be created + * @throws CertificateException when the certificate is not parseable + */ + public HubExternalClient newExternalClient(String fqdn, String username, String password, String rootCA) + throws CertificateException, IOException { + return new RestHubExternalClient(fqdn, username, password, CertificateUtils.parse(rootCA)); + } + +} diff --git a/java/code/src/com/suse/manager/hub/HubController.java b/java/code/src/com/suse/manager/hub/HubController.java new file mode 100644 index 000000000000..3ae58a3744f8 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubController.java @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import static com.suse.manager.hub.HubSparkHelper.onlyFromHub; +import static com.suse.manager.hub.HubSparkHelper.onlyFromRegistered; +import static com.suse.manager.hub.HubSparkHelper.onlyFromUnregistered; +import static com.suse.manager.hub.HubSparkHelper.usingTokenAuthentication; +import static com.suse.manager.webui.utils.SparkApplicationHelper.asJson; +import static com.suse.manager.webui.utils.SparkApplicationHelper.badRequest; +import static com.suse.manager.webui.utils.SparkApplicationHelper.internalServerError; +import static com.suse.manager.webui.utils.SparkApplicationHelper.json; +import static com.suse.manager.webui.utils.SparkApplicationHelper.message; +import static com.suse.manager.webui.utils.SparkApplicationHelper.success; +import static spark.Spark.get; +import static spark.Spark.post; + +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.common.hibernate.ConnectionManager; +import com.redhat.rhn.common.hibernate.ConnectionManagerFactory; +import com.redhat.rhn.common.hibernate.ReportDbHibernateFactory; +import com.redhat.rhn.taskomatic.TaskomaticApiException; +import com.redhat.rhn.taskomatic.task.ReportDBHelper; + +import com.suse.manager.model.hub.ChannelInfoJson; +import com.suse.manager.model.hub.CustomChannelInfoJson; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.ModifyCustomChannelInfoJson; +import com.suse.manager.model.hub.OrgInfoJson; +import com.suse.manager.model.hub.RegisterJson; +import com.suse.manager.model.hub.SCCCredentialsJson; +import com.suse.manager.webui.controllers.ECMAScriptDateAdapter; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.lang.reflect.Type; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import spark.Request; +import spark.Response; + +public class HubController { + + private static final Logger LOGGER = LogManager.getLogger(HubController.class); + + private final HubManager hubManager; + + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(Date.class, new ECMAScriptDateAdapter()) + .serializeNulls() + .create(); + + /** + * Default constructor + */ + public HubController() { + this(new HubManager()); + } + + /** + * Builds an instance with the specified hub manager + * @param hubManagerIn the hub manager + */ + public HubController(HubManager hubManagerIn) { + this.hubManager = hubManagerIn; + } + + /** + * Initialize the API routes + */ + public void initRoutes() { + post("/hub/ping", asJson(usingTokenAuthentication(this::ping))); + post("/hub/sync/deregister", asJson(usingTokenAuthentication(onlyFromRegistered(this::deregister)))); + post("/hub/sync/registerHub", asJson(usingTokenAuthentication(onlyFromUnregistered(this::registerHub)))); + post("/hub/sync/replaceTokens", asJson(usingTokenAuthentication(onlyFromHub(this::replaceTokens)))); + post("/hub/sync/storeCredentials", asJson(usingTokenAuthentication(onlyFromHub(this::storeCredentials)))); + post("/hub/sync/setHubDetails", asJson(usingTokenAuthentication(onlyFromHub(this::setHubDetails)))); + get("/hub/managerinfo", asJson(usingTokenAuthentication(onlyFromHub(this::getManagerInfo)))); + post("/hub/storeReportDbCredentials", + asJson(usingTokenAuthentication(onlyFromHub(this::setReportDbCredentials)))); + post("/hub/removeReportDbCredentials", + asJson(usingTokenAuthentication(onlyFromHub(this::removeReportDbCredentials)))); + get("/hub/listAllPeripheralOrgs", + asJson(usingTokenAuthentication(onlyFromHub(this::listAllPeripheralOrgs)))); + get("/hub/listAllPeripheralChannels", + asJson(usingTokenAuthentication(onlyFromHub(this::listAllPeripheralChannels)))); + post("/hub/addVendorChannels", + asJson(usingTokenAuthentication(onlyFromHub(this::addVendorChannels)))); + post("/hub/addCustomChannels", + asJson(usingTokenAuthentication(onlyFromHub(this::addCustomChannels)))); + post("/hub/modifyCustomChannels", + asJson(usingTokenAuthentication(onlyFromHub(this::modifyCustomChannels)))); + post("/hub/sync/channelfamilies", + asJson(usingTokenAuthentication(onlyFromHub(this::synchronizeChannelFamilies)))); + post("/hub/sync/products", + asJson(usingTokenAuthentication(onlyFromHub(this::synchronizeProducts)))); + post("/hub/sync/repositories", + asJson(usingTokenAuthentication(onlyFromHub(this::synchronizeRepositories)))); + post("/hub/sync/subscriptions", + asJson(usingTokenAuthentication(onlyFromHub(this::synchronizeSubscriptions)))); + } + + private String setHubDetails(Request request, Response response, IssAccessToken accessToken) { + Map data = GSON.fromJson(request.body(), Map.class); + + try { + hubManager.updateServerData(accessToken, accessToken.getServerFqdn(), IssRole.HUB, data); + } + catch (IllegalArgumentException ex) { + LOGGER.error("Invalid data provided: ", ex); + return badRequest(response, "Invalid data"); + } + return success(response); + } + + private String deregister(Request request, Response response, IssAccessToken accessToken) { + // request to delete the local access for the requesting server. + hubManager.deleteIssServerLocal(accessToken, accessToken.getServerFqdn()); + return success(response); + } + + private String replaceTokens(Request request, Response response, IssAccessToken currentAccessToken) { + String newRemoteToken = GSON.fromJson(request.body(), String.class); + if (newRemoteToken.isBlank()) { + LOGGER.error("Bad Request: invalid data"); + return badRequest(response, "Invalid data"); + } + try { + String newLocalToken = hubManager.replaceTokens(currentAccessToken, newRemoteToken); + return success(response, newLocalToken); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the received token for server {}", currentAccessToken.getServerFqdn()); + return badRequest(response, "The specified token is not parseable"); + } + catch (TokenBuildingException ex) { + LOGGER.error("Unable to build token"); + return badRequest(response, "The token could not be build"); + } + } + + private String removeReportDbCredentials(Request request, Response response, IssAccessToken token) { + Map creds = GSON.fromJson(request.body(), Map.class); + String dbname = Config.get().getString(ConfigDefaults.REPORT_DB_NAME, ""); + + if (dbname.isBlank() || !creds.containsKey("username")) { + LOGGER.error("Bad Request: Invalid Data"); + return badRequest(response, "Invalid data"); + } + ConnectionManager localRcm = ConnectionManagerFactory.localReportingConnectionManager(); + try { + ReportDbHibernateFactory localRh = new ReportDbHibernateFactory(localRcm); + ReportDBHelper dbHelper = ReportDBHelper.INSTANCE; + String username = creds.get("username"); + if (dbHelper.hasDBUser(localRh.getSession(), username)) { + dbHelper.dropDBUser(localRh.getSession(), username); + localRcm.commitTransaction(); + return success(response); + } + LOGGER.error("Bad Request: DB User '{}' does not exist", username); + } + catch (Exception e) { + LOGGER.error("Bad Request: removing user failed", e); + } + localRcm.rollbackTransaction(); + return badRequest(response, "Request failed"); + } + + private String setReportDbCredentials(Request request, Response response, IssAccessToken token) { + Map creds = GSON.fromJson(request.body(), Map.class); + String dbname = Config.get().getString(ConfigDefaults.REPORT_DB_NAME, ""); + + if (dbname.isBlank() || !(creds.containsKey("username") && creds.containsKey("password"))) { + LOGGER.error("Bad Request: Invalid Data"); + return badRequest(response, "Invalid data"); + } + ConnectionManager localRcm = ConnectionManagerFactory.localReportingConnectionManager(); + try { + ReportDbHibernateFactory localRh = new ReportDbHibernateFactory(localRcm); + ReportDBHelper dbHelper = ReportDBHelper.INSTANCE; + if (dbHelper.hasDBUser(localRh.getSession(), creds.get("username"))) { + dbHelper.changeDBPassword(localRh.getSession(), creds.get("username"), creds.get("password")); + } + else { + dbHelper.createDBUser(localRh.getSession(), dbname, + creds.get("username"), creds.get("password")); + } + localRcm.commitTransaction(); + return success(response); + } + catch (Exception e) { + LOGGER.error("Bad Request: setting user/password failed", e); + } + localRcm.rollbackTransaction(); + return badRequest(response, "Request failed"); + } + + private String getManagerInfo(Request request, Response response, IssAccessToken token) { + ManagerInfoJson managerInfo = hubManager.collectManagerInfo(token); + return success(response, managerInfo); + } + + // Basic ping to check if the system is up + private String ping(Request request, Response response, IssAccessToken token) { + return message(response, "Pinged from %s".formatted(token.getServerFqdn())); + } + + private String registerHub(Request request, Response response, IssAccessToken token) { + RegisterJson registerRequest = GSON.fromJson(request.body(), RegisterJson.class); + + String tokenToStore = registerRequest.getToken(); + if (StringUtils.isEmpty(tokenToStore)) { + LOGGER.error("No token received in the request for server {}", token.getServerFqdn()); + return badRequest(response, "Required token is missing"); + } + + try { + hubManager.storeAccessToken(token, tokenToStore); + hubManager.saveNewServer(token, IssRole.HUB, registerRequest.getRootCA(), registerRequest.getGpgKey()); + + return success(response); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the received token for server {}", token.getServerFqdn()); + return badRequest(response, "The specified token is not parseable"); + } + catch (TaskomaticApiException ex) { + LOGGER.error("Unable to schedule root CA certificate update {}", token.getServerFqdn()); + return internalServerError(response, "Unable to schedule root CA certificate update"); + } + } + + private String storeCredentials(Request request, Response response, IssAccessToken token) { + SCCCredentialsJson storeRequest = GSON.fromJson(request.body(), SCCCredentialsJson.class); + + try { + hubManager.storeSCCCredentials(token, storeRequest.getUsername(), storeRequest.getPassword()); + return success(response); + } + catch (IllegalArgumentException ex) { + // This should never happen, fqdn guaranteed be a hub after calling allowingOnlyHub() on route init. + return badRequest(response, "Specified FQDN is not a known hub"); + } + } + + private String listAllPeripheralOrgs(Request request, Response response, IssAccessToken token) { + List allOrgsInfo = hubManager.collectAllOrgs(token) + .stream() + .map(org -> new OrgInfoJson(org.getId(), org.getName())) + .toList(); + + return success(response, allOrgsInfo); + } + + private String listAllPeripheralChannels(Request request, Response response, IssAccessToken token) { + List allChannelsInfo = hubManager.collectAllChannels(token) + .stream() + .map(ch -> new ChannelInfoJson(ch.getId(), ch.getName(), ch.getLabel(), ch.getSummary(), + ((null == ch.getOrg()) ? null : ch.getOrg().getId()), + (null == ch.getParentChannel()) ? null : ch.getParentChannel().getId())) + .toList(); + + return success(response, allChannelsInfo); + } + + private String addVendorChannels(Request request, Response response, IssAccessToken token) { + Type listType = new TypeToken>() { }.getType(); + List channelsLabels = GSON.fromJson(request.body(), listType); + if (channelsLabels == null || channelsLabels.isEmpty()) { + LOGGER.error("Bad Request: invalid invalid vendor channel label list"); + return badRequest(response, "Invalid data: invalid vendor channel label list"); + } + List createdVendorChannelInfoList = + hubManager.addVendorChannels(token, channelsLabels) + .stream() + .map(ch -> new ChannelInfoJson(ch.getId(), ch.getName(), ch.getLabel(), ch.getSummary(), + ((null == ch.getOrg()) ? null : ch.getOrg().getId()), + (null == ch.getParentChannel()) ? null : ch.getParentChannel().getId())) + .toList(); + return success(response, createdVendorChannelInfoList); + } + + private String addCustomChannels(Request request, Response response, IssAccessToken token) { + Type listType = new TypeToken>() { }.getType(); + List channels = GSON.fromJson(request.body(), listType); + if (channels == null || channels.isEmpty()) { + LOGGER.error("Bad Request: invalid invalid vendor channel label list"); + return badRequest(response, "Invalid data: invalid vendor channel label list"); + } + List createdCustomChannelsInfoList = + hubManager.addCustomChannels(token, channels) + .stream() + .map(ch -> new ChannelInfoJson(ch.getId(), ch.getName(), ch.getLabel(), ch.getSummary(), + ((null == ch.getOrg()) ? null : ch.getOrg().getId()), + (null == ch.getParentChannel()) ? null : ch.getParentChannel().getId())) + .toList(); + return success(response, createdCustomChannelsInfoList); + } + + private String modifyCustomChannels(Request request, Response response, IssAccessToken token) { + Type listType = new TypeToken>() { }.getType(); + List channels = GSON.fromJson(request.body(), listType); + if (channels == null || channels.isEmpty()) { + LOGGER.error("Bad Request: invalid invalid vendor channel label list"); + return badRequest(response, "Invalid data: invalid vendor channel label list"); + } + List modifiedCustomChannelsInfoList = + hubManager.modifyCustomChannels(token, channels) + .stream() + .map(ch -> new ChannelInfoJson(ch.getId(), ch.getName(), ch.getLabel(), ch.getSummary(), + ((null == ch.getOrg()) ? null : ch.getOrg().getId()), + (null == ch.getParentChannel()) ? null : ch.getParentChannel().getId())) + .toList(); + return success(response, modifiedCustomChannelsInfoList); + } + + private String synchronizeChannelFamilies(Request request, Response response, IssAccessToken token) { + return json(response, hubManager.synchronizeChannelFamilies(token)); + } + + private String synchronizeProducts(Request request, Response response, IssAccessToken token) { + return json(response, hubManager.synchronizeProducts(token)); + } + + private String synchronizeRepositories(Request request, Response response, IssAccessToken token) { + return json(response, hubManager.synchronizeRepositories(token)); + } + + private String synchronizeSubscriptions(Request request, Response response, IssAccessToken token) { + return json(response, hubManager.synchronizeSubscriptions(token)); + } +} diff --git a/java/code/src/com/suse/manager/hub/HubExternalClient.java b/java/code/src/com/suse/manager/hub/HubExternalClient.java new file mode 100644 index 000000000000..a96ff2f35b7e --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubExternalClient.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import java.io.IOException; + +/** + * Hub Inter-Server-Sync Client to connect to a remote server and invoke the public XMLRPC/Rest-like APIs. + */ +public interface HubExternalClient extends AutoCloseable { + + /** + * Issue a token for the given remote server + * @param fqdn the FQDN of the remote server + * @return the generated token + * @throws IOException when the remote communication fails + */ + String generateAccessToken(String fqdn) throws IOException; + + /** + * Stores the given token associating it to the specified server + * @param fqdn the FQDN of the remote server + * @param token the access token + * @throws IOException when the remote communication fails + */ + void storeAccessToken(String fqdn, String token) throws IOException; + + /** + * {@inheritDoc} + * @throws IOException when the remote communication fails + */ + void close() throws IOException; + +} diff --git a/java/code/src/com/suse/manager/hub/HubInternalClient.java b/java/code/src/com/suse/manager/hub/HubInternalClient.java new file mode 100644 index 000000000000..7d53758dc71e --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubInternalClient.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import com.suse.manager.model.hub.ChannelInfoJson; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.OrgInfoJson; + +import java.io.IOException; +import java.util.List; + +/** + * Hub Inter-Server-Sync Client to connect a remote server and invoke the private server-to-server Rest-like API + */ +public interface HubInternalClient { + + /** + * Register a remote server as a hub + * @param token the token issued by the remote server to grant access + * @param rootCA the root certificate, if needed + * @param gpgKey the gpg key, if needed + * @throws IOException when the communication fails + */ + void registerHub(String token, String rootCA, String gpgKey) throws IOException; + + /** + * Store the SCC credentials on the remote peripheral server + * @param username the username + * @param password the password + * @throws IOException when the communication fails + */ + void storeCredentials(String username, String password) throws IOException; + + /** + * Query Manager information from the peripheral server + * @return return {@link ManagerInfoJson} from peripheral server + * @throws IOException when communication fails + */ + ManagerInfoJson getManagerInfo() throws IOException; + + /** + * Store Report DB credentials on the remote peripheral server + * @param username the username + * @param password the password + * @throws IOException when communication fails + */ + void storeReportDbCredentials(String username, String password) throws IOException; + + /** + * De-register the calling server from the remote side + * + * @throws IOException when the communication fails + */ + void deregister() throws IOException; + + /** + * Return all the peripheral organizations + * @return the organizations + * @throws IOException + */ + List getAllPeripheralOrgs() throws IOException; + + /** + * Return all the peripheral channels + * @return the channels + * @throws IOException + */ + List getAllPeripheralChannels() throws IOException; + + /** + * Replace the hub token on the remote peripheral server and get a new peripheral token back + * @param newHubToken the new hub token + * @return return the new peripheral token + * @throws IOException when the communication fails + */ + String replaceTokens(String newHubToken) throws IOException; + + /** + * Sync a list of vendor channels by label + * @param channelsLabelIn the list of vendor channels label, order is not important as it's assured by the peripheral + * @return a list with minimal info about the synced channels + * @throws IOException when the communication fails + */ + List syncVendorChannels(List channelsLabelIn) throws IOException; + +} diff --git a/java/code/src/com/suse/manager/hub/HubManager.java b/java/code/src/com/suse/manager/hub/HubManager.java new file mode 100644 index 000000000000..020e75f66ca0 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubManager.java @@ -0,0 +1,1315 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import com.redhat.rhn.GlobalInstanceHolder; +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.common.security.PermissionException; +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.channel.ChannelFactory; +import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; +import com.redhat.rhn.domain.credentials.ReportDBCredentials; +import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.redhat.rhn.domain.org.Org; +import com.redhat.rhn.domain.org.OrgFactory; +import com.redhat.rhn.domain.product.ChannelTemplate; +import com.redhat.rhn.domain.product.SUSEProductFactory; +import com.redhat.rhn.domain.role.RoleFactory; +import com.redhat.rhn.domain.server.MgrServerInfo; +import com.redhat.rhn.domain.server.Server; +import com.redhat.rhn.domain.server.ServerFQDN; +import com.redhat.rhn.domain.server.ServerFactory; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.listview.PageControl; +import com.redhat.rhn.frontend.xmlrpc.InvalidChannelLabelException; +import com.redhat.rhn.manager.content.ContentSyncException; +import com.redhat.rhn.manager.content.ContentSyncManager; +import com.redhat.rhn.manager.entitlement.EntitlementManager; +import com.redhat.rhn.manager.setup.MirrorCredentialsManager; +import com.redhat.rhn.manager.system.SystemManager; +import com.redhat.rhn.manager.system.SystemManagerUtils; +import com.redhat.rhn.manager.system.SystemsExistException; +import com.redhat.rhn.manager.system.entitling.SystemEntitlementManager; +import com.redhat.rhn.taskomatic.TaskomaticApi; +import com.redhat.rhn.taskomatic.TaskomaticApiException; + +import com.suse.manager.model.hub.AccessTokenDTO; +import com.suse.manager.model.hub.CustomChannelInfoJson; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssPeripheralChannels; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.IssServer; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.ModifyCustomChannelInfoJson; +import com.suse.manager.model.hub.OrgInfoJson; +import com.suse.manager.model.hub.TokenType; +import com.suse.manager.webui.controllers.admin.beans.ChannelSyncModel; +import com.suse.manager.webui.controllers.admin.beans.IssV3ChannelResponse; +import com.suse.manager.webui.controllers.ProductsController; +import com.suse.manager.webui.utils.token.IssTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenParser; +import com.suse.manager.webui.utils.token.TokenParsingException; +import com.suse.utils.CertificateUtils; + +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Business logic to manage ISSv3 Sync + */ +public class HubManager { + + private final MirrorCredentialsManager mirrorCredentialsManager; + + private final HubFactory hubFactory; + + private final HubClientFactory clientFactory; + + private final SystemEntitlementManager systemEntitlementManager; + + private TaskomaticApi taskomaticApi; + + private static final Logger LOG = LogManager.getLogger(HubManager.class); + + private static final String ROOT_CA_FILENAME_TEMPLATE = "%s_%s_root_ca.pem"; + + /** + * A Hub deliver custom repositories via organization/repositories SCC endpoint. + * We need a fake repo ID for it. + */ + public static final Long CUSTOM_REPO_FAKE_SCC_ID = Long.MIN_VALUE; + + /** + * Default constructor + */ + public HubManager() { + this(new HubFactory(), new HubClientFactory(), new MirrorCredentialsManager(), new TaskomaticApi(), + GlobalInstanceHolder.SYSTEM_ENTITLEMENT_MANAGER); + } + + /** + * Builds an instance with the given dependencies + * @param hubFactoryIn the hub factory + * @param clientFactoryIn the ISS client factory + * @param mirrorCredentialsManagerIn the mirror credentials manager + * @param taskomaticApiIn the TaskomaticApi object + * @param systemEntitlementManagerIn the system entitlement manager + */ + public HubManager(HubFactory hubFactoryIn, HubClientFactory clientFactoryIn, + MirrorCredentialsManager mirrorCredentialsManagerIn, TaskomaticApi taskomaticApiIn, + SystemEntitlementManager systemEntitlementManagerIn) { + this.hubFactory = hubFactoryIn; + this.clientFactory = clientFactoryIn; + this.mirrorCredentialsManager = mirrorCredentialsManagerIn; + this.taskomaticApi = taskomaticApiIn; + this.systemEntitlementManager = systemEntitlementManagerIn; + } + + /** + * Create a new access token for the given FQDN and store it in the database + * @param user the user performing the operation + * @param fqdn the FQDN of the peripheral/hub + * @return the serialized form of the token + * @throws TokenBuildingException when an error occurs during generation + * @throws TokenParsingException when the generated token cannot be parsed + */ + public String issueAccessToken(User user, String fqdn) throws TokenBuildingException, TokenParsingException { + ensureSatAdmin(user); + + Token token = createAndSaveToken(fqdn); + return token.getSerializedForm(); + } + + /** + * Stores in the database the access token of the given FQDN + * @param user the user performing the operation + * @param fqdn the FQDN of the peripheral/hub that generated this token + * @param token the token + * @throws TokenParsingException when it's not possible to process the token + */ + public void storeAccessToken(User user, String fqdn, String token) throws TokenParsingException { + ensureSatAdmin(user); + + parseAndSaveToken(fqdn, token); + } + + /** + * Deletes the access token with the specified id + * @param user the user performing the operation + * @param tokenId the id of the access token to delete + * @return true if the token was deleted, false otherwise + */ + public boolean deleteAccessToken(User user, long tokenId) { + ensureSatAdmin(user); + + return hubFactory.removeAccessTokenById(tokenId); + } + + /** + * Stores in the database the access token of the given FQDN + * @param accessToken the access token granting access and identifying the caller + * @param tokenToStore the token + * @throws TokenParsingException when it's not possible to process the token + */ + public void storeAccessToken(IssAccessToken accessToken, String tokenToStore) throws TokenParsingException { + ensureValidToken(accessToken); + + parseAndSaveToken(accessToken.getServerFqdn(), tokenToStore); + } + + /** + * Returns the ISS of the specified role, if present + * @param accessToken the access token granting access and identifying the caller + * @param role the role of the server + * @return an {@link IssHub} or {@link IssPeripheral} depending on the specified role, null if the FQDN is unknown + */ + public IssServer findServer(IssAccessToken accessToken, IssRole role) { + ensureValidToken(accessToken); + + return lookupServerByFqdnAndRole(accessToken.getServerFqdn(), role); + } + + /** + * Returns the ISS of the specified role, if present + * @param user the user performing the operation + * @param serverFqdn the fqdn of the server + * @param role the role of the server + * @return an {@link IssHub} or {@link IssPeripheral} depending on the specified role, null if the FQDN is unknown + */ + public IssServer findServer(User user, String serverFqdn, IssRole role) { + ensureSatAdmin(user); + + return lookupServerByFqdnAndRole(serverFqdn, role); + } + + /** + * Save the given remote server as hub or peripheral depending on the specified role + * @param accessToken the access token granting access and identifying the caller + * @param role the role of the server + * @param rootCA the root certificate, if needed + * @param gpgKey the gpg key, if needed + * @return the persisted remote server + */ + public IssServer saveNewServer(IssAccessToken accessToken, IssRole role, String rootCA, String gpgKey) + throws TaskomaticApiException { + ensureValidToken(accessToken); + + return createServer(role, accessToken.getServerFqdn(), rootCA, gpgKey, null); + } + + /** + * Delete locally all ISS artifacts for the hub or peripheral server identified by the FQDN + * @param user the user + * @param fqdn the FQDN + */ + public void deleteIssServerLocal(User user, String fqdn) { + ensureSatAdmin(user); + if (hubFactory.isISSPeripheral()) { + deleteHub(fqdn); + } + else { + deletePeripheral(fqdn); + } + } + + /** + * Delete locally all ISS artifacts for the hub or peripheral server identified by the FQDN + * @param accessToken the token + * @param fqdn the FQDN + */ + public void deleteIssServerLocal(IssAccessToken accessToken, String fqdn) { + ensureValidToken(accessToken); + if (hubFactory.isISSPeripheral()) { + deleteHub(fqdn); + } + else { + deletePeripheral(fqdn); + } + } + + private void deletePeripheral(String peripheralFqdn) { + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn(peripheralFqdn); + if (issPeripheral.isEmpty()) { + LOG.info("Peripheral Server with name {} not found", peripheralFqdn); + return; // no error as the state is already as wanted. + } + IssPeripheral peripheral = issPeripheral.get(); + CredentialsFactory.removeCredentials(peripheral.getMirrorCredentials()); + hubFactory.remove(peripheral); + hubFactory.removeAccessTokensFor(peripheralFqdn); + } + + private void deleteHub(String hubFqdn) { + Optional issHub = hubFactory.lookupIssHubByFqdn(hubFqdn); + if (issHub.isEmpty()) { + LOG.info("Hub Server with name {} not found", hubFqdn); + return; // no error as the state is already as wanted. + } + IssHub hub = issHub.get(); + CredentialsFactory.removeCredentials(hub.getMirrorCredentials()); + hubFactory.remove(hub); + hubFactory.removeAccessTokensFor(hubFqdn); + } + + /** + * Replace locally the current token with a new created one and return it. + * Store the provided new token for the remote server. + * @param currentAccessToken the old/current token + * @param newRemoteToken the new token + * @return the new generated local token for the calling side. + * @throws TokenBuildingException when an error occurs during generation + * @throws TokenParsingException when the generated token cannot be parsed + */ + public String replaceTokens(IssAccessToken currentAccessToken, String newRemoteToken) + throws TokenBuildingException, TokenParsingException { + ensureValidToken(currentAccessToken); + + // store the new token to access the remote side + parseAndSaveToken(currentAccessToken.getServerFqdn(), newRemoteToken); + + // Generate a new token to access this server for the remote side + Token localToken = createAndSaveToken(currentAccessToken.getServerFqdn()); + return localToken.getSerializedForm(); + } + + /** + * Replace the local Hub and remote Peripheral tokens for an ISS connection. + * A new local hub token is issued and send to the peripheral side. The peripheral + * side store this token and issue a new one for the calling hub server which store it. + * + * @param user the user + * @param remoteServer the remote peripheral server FQDN + * @throws CertificateException if the specified certificate is not parseable + * @throws TokenParsingException if the specified token is not parseable + * @throws TokenBuildingException if an error occurs while generating the token for the server + * @throws IOException when connecting to the server fails + */ + public void replaceTokensHub(User user, String remoteServer) + throws CertificateException, IOException, TokenParsingException, TokenBuildingException { + ensureSatAdmin(user); + + IssPeripheral issPeripheral = hubFactory.lookupIssPeripheralByFqdn(remoteServer).orElseThrow(() -> + new IllegalStateException("Server " + remoteServer + " is not registered as peripheral")); + + // Generate a token for this server on the remote + String newLocalToken = issueAccessToken(user, remoteServer); + + // Create a client to connect to the internal API of the remote server + IssAccessToken currentAccessToken = hubFactory.lookupAccessTokenFor(issPeripheral.getFqdn()); + var internalApi = clientFactory.newInternalClient(issPeripheral.getFqdn(), currentAccessToken.getToken(), + issPeripheral.getRootCa()); + String newRemoteToken = internalApi.replaceTokens(newLocalToken); + parseAndSaveToken(remoteServer, newRemoteToken); + } + + /** + * Register a remote PERIPHERAL server + * + * @param user the user performing the operation + * @param remoteServer the peripheral server FQDN + * @param username the username of a {@link RoleFactory#SAT_ADMIN} of the remote server + * @param password the password of the specified user + * @param rootCA the optional root CA of the remote server. can be null + * + * @throws CertificateException if the specified certificate is not parseable + * @throws TokenParsingException if the specified token is not parseable + * @throws TokenBuildingException if an error occurs while generating the token for the server + * @throws IOException when connecting to the server fails + */ + public void register(User user, String remoteServer, String username, String password, String rootCA) + throws CertificateException, TokenBuildingException, IOException, TokenParsingException, + TaskomaticApiException { + ensureSatAdmin(user); + + // Verify this server is not already registered as hub or peripheral + ensureServerNotRegistered(remoteServer); + + // Generate a token for this server on the remote + String remoteToken; + try (var externalClient = clientFactory.newExternalClient(remoteServer, username, password, rootCA)) { + remoteToken = externalClient.generateAccessToken(ConfigDefaults.get().getHostname()); + } + + registerWithToken(user, remoteServer, rootCA, remoteToken); + } + + /** + * Register a remote PERIPHERAL server + * + * @param user the user performing the operation + * @param remoteServer the peripheral server FQDN + * @param remoteToken the token used to connect to the peripheral server + * @param rootCA the optional root CA of the peripheral server + * + * @throws CertificateException if the specified certificate is not parseable + * @throws TokenParsingException if the specified token is not parseable + * @throws TokenBuildingException if an error occurs while generating the token for the peripheral server + * @throws IOException when connecting to the peripheral server fails + */ + public void register(User user, String remoteServer, String remoteToken, String rootCA) + throws CertificateException, TokenBuildingException, IOException, TokenParsingException, + TaskomaticApiException { + ensureSatAdmin(user); + + // Verify this server is not already registered as hub or peripheral + ensureServerNotRegistered(remoteServer); + + registerWithToken(user, remoteServer, rootCA, remoteToken); + } + + /** + * Remove a registered Peripheral server + * + * @param user the user performing the operation + * @param peripheralId the peripheral entity id + * @throws CertificateException if the specified certificate is not parseable + */ + public void deregister(User user, Long peripheralId) throws CertificateException, IOException { + ensureSatAdmin(user); + IssPeripheral issPeripheral = hubFactory.findPeripheral(peripheralId); + IssAccessToken accessToken = hubFactory.lookupAccessTokenFor(issPeripheral.getFqdn()); + var internalApi = clientFactory.newInternalClient( + issPeripheral.getFqdn(), + accessToken.getToken(), + issPeripheral.getRootCa() + ); + // TODO: transaction here + try { + deletePeripheral(issPeripheral.getFqdn()); + internalApi.deregister(); + } + catch (IOException exc) { + // TODO: rollback here + throw exc; + } + } + + /** + * Generate SCC credentials for the specified peripheral + * @param accessToken the access token granting access and identifying the caller + * @return the generated {@link HubSCCCredentials} + */ + public HubSCCCredentials generateSCCCredentials(IssAccessToken accessToken) { + ensureValidToken(accessToken); + + IssPeripheral peripheral = hubFactory.lookupIssPeripheralByFqdn(accessToken.getServerFqdn()) + .orElseThrow(() -> new IllegalArgumentException("Access token does not identify a peripheral server")); + + return generateCredentials(peripheral); + } + + /** + * Store the given SCC credentials into the credentials database + * @param accessToken the access token granting access and identifying the caller + * @param username the username + * @param password the password + * @return the stored {@link SCCCredentials} + */ + public SCCCredentials storeSCCCredentials(IssAccessToken accessToken, String username, String password) { + ensureValidToken(accessToken); + + IssHub hub = hubFactory.lookupIssHubByFqdn(accessToken.getServerFqdn()) + .orElseThrow(() -> new IllegalArgumentException("Access token does not identify a hub server")); + + return saveCredentials(hub, username, password); + } + + /** + * Set the User and Password for the report database in MgrServerInfo. + * That trigger also a state apply to set this user in the report database. + * + * @param user the user + * @param server the Mgr Server + * @param forcePwChange force a password change + */ + public void setReportDbUser(User user, Server server, boolean forcePwChange) + throws CertificateException, IOException { + ensureSatAdmin(user); + // Create a report db user when system is a mgr server + if (!server.isMgrServer()) { + return; + } + // create default user with random password + MgrServerInfo mgrServerInfo = server.getMgrServerInfo(); + if (StringUtils.isAnyBlank(mgrServerInfo.getReportDbName(), mgrServerInfo.getReportDbHost())) { + // no reportdb configured + return; + } + + String password = RandomStringUtils.random(24, 0, 0, true, true, null, new SecureRandom()); + ReportDBCredentials credentials = Optional.ofNullable(mgrServerInfo.getReportDbCredentials()) + .map(existingCredentials -> { + if (forcePwChange) { + existingCredentials.setPassword(password); + CredentialsFactory.storeCredentials(existingCredentials); + } + + return existingCredentials; + }) + .orElseGet(() -> { + String randomSuffix = RandomStringUtils.random(8, 0, 0, true, false, null, new SecureRandom()); + // Ensure the username is stored lowercase in the database, since the script + // uyuni-setup-reportdb-user will convert it to lowercase anyway + String username = "hermes_" + randomSuffix.toLowerCase(); + + ReportDBCredentials reportCreds = CredentialsFactory.createReportCredentials(username, password); + CredentialsFactory.storeCredentials(reportCreds); + return reportCreds; + }); + + mgrServerInfo.setReportDbCredentials(credentials); + + Optional issPeripheral = server.getFqdns().stream() + .flatMap(fqdn -> hubFactory.lookupIssPeripheralByFqdn(fqdn.getName()).stream()) + .findFirst(); + if (issPeripheral.isPresent()) { + IssPeripheral remoteServer = issPeripheral.get(); + IssAccessToken token = hubFactory.lookupAccessTokenFor(remoteServer.getFqdn()); + + HubInternalClient internalApi = clientFactory.newInternalClient(remoteServer.getFqdn(), + token.getToken(), remoteServer.getRootCa()); + internalApi.storeReportDbCredentials(credentials.getUsername(), credentials.getPassword()); + String summary = "Report Database credentials changed by " + user.getLogin(); + String details = MessageFormat.format(""" + The Report Database credentials were changed by {0}. + Report Database User: {1} + """, user.getLogin(), credentials.getUsername()); + SystemManager.addHistoryEvent(server, summary, details); + } + } + + /** + * Collect data about a Manager Server + * @param accessToken the accesstoken + * @return return {@link ManagerInfoJson} + */ + public ManagerInfoJson collectManagerInfo(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return collectManagerInfo(); + } + + /** + * Set server details + * + * @param token the access token + * @param fqdn the FQDN identifying the Hub or Peripheral Server + * @param role the role which should be changed + * @param data the new data + */ + public void updateServerData(IssAccessToken token, String fqdn, IssRole role, Map data) { + ensureValidToken(token); + updateServerData(fqdn, role, data); + } + + /** + * Set server details + * + * @param user The current user + * @param fqdn the FQDN identifying the Hub or Peripheral Server + * @param role the role which should be changed + * @param data the new data + */ + public void updateServerData(User user, String fqdn, IssRole role, Map data) { + ensureSatAdmin(user); + updateServerData(fqdn, role, data); + } + + private void updateServerData(String fqdn, IssRole role, Map data) { + switch (role) { + case HUB -> hubFactory.lookupIssHubByFqdn(fqdn).ifPresentOrElse(issHub -> { + if (data.containsKey("root_ca")) { + issHub.setRootCa(data.get("root_ca")); + } + if (data.containsKey("gpg_key")) { + issHub.setGpgKey(data.get("gpg_key")); + } + hubFactory.save(issHub); + }, + () -> { + LOG.error("Server {} not found with role {}", fqdn, role); + throw new IllegalArgumentException("Server not found"); + }); + case PERIPHERAL -> hubFactory.lookupIssPeripheralByFqdn(fqdn).ifPresentOrElse(issPeripheral -> { + if (data.containsKey("root_ca")) { + issPeripheral.setRootCa(data.get("root_ca")); + } + hubFactory.save(issPeripheral); + }, + ()-> { + LOG.error("Server {} not found with role {}", fqdn, role); + throw new IllegalArgumentException("Server not found"); + }); + default -> { + LOG.error("Unknown role {}", role); + throw new IllegalArgumentException("Unknown role"); + } + } + } + + /** + * Count currently issued and consumed access tokens + * @param user the user performing the operation + * @return the number of token existing in the database + */ + public long countAccessToken(User user) { + ensureSatAdmin(user); + + return hubFactory.countAccessToken(); + } + + /** + * List the currently issued and consumed access tokens + * @param user the user performing the operation + * @param pc the pagination settings + * @return the existing tokens retrieved from the database using the specified pagination settings + */ + public List listAccessToken(User user, PageControl pc) { + ensureSatAdmin(user); + + return hubFactory.listAccessToken(pc.getStart() - 1, pc.getPageSize()); + } + + /** + * Retrieve the access token with the specied id + * @param user the user performing the operation + * @param tokenId the id of the token + * @return the access token, if present + */ + public Optional lookupAccessTokenById(User user, long tokenId) { + ensureSatAdmin(user); + + return hubFactory.lookupAccessTokenById(tokenId); + } + + /** + * Updates the give token in the database + * @param user the user performing the operation + * @param issAccessToken the token + */ + public void updateToken(User user, IssAccessToken issAccessToken) { + ensureSatAdmin(user); + + hubFactory.updateToken(issAccessToken); + } + + private ManagerInfoJson collectManagerInfo() { + String reportDbName = Config.get().getString(ConfigDefaults.REPORT_DB_NAME, ""); + // we need to provide the external hostname + String reportDbHost = Config.get().getString(ConfigDefaults.SERVER_HOSTNAME, ""); + int reportDbPort = Config.get().getInt(ConfigDefaults.REPORT_DB_PORT, 5432); + String version = ConfigDefaults.get().getProductVersion().split("\\s")[0]; + + return new ManagerInfoJson( + version, + StringUtils.isNoneBlank(reportDbName, reportDbHost), + reportDbName, reportDbHost, reportDbPort); + } + + private void registerWithToken(User user, String remoteServer, String rootCA, String remoteToken) + throws TokenParsingException, CertificateException, TokenBuildingException, IOException, + TaskomaticApiException { + parseAndSaveToken(remoteServer, remoteToken); + + IssServer registeredServer = createServer(IssRole.PERIPHERAL, remoteServer, rootCA, null, user); + registerToRemote(user, registeredServer, remoteToken, rootCA); + } + + private void registerToRemote(User user, IssServer remoteServer, String remoteToken, String rootCA) + throws CertificateException, TokenParsingException, TokenBuildingException, IOException { + + // Ensure the remote server is a peripheral + if (!(remoteServer instanceof IssPeripheral peripheral)) { + throw new IllegalStateException("Server " + remoteServer + "is not a peripheral server"); + } + + // Create a client to connect to the internal API of the remote server + var internalApi = clientFactory.newInternalClient(remoteServer.getFqdn(), remoteToken, rootCA); + try { + // Issue a token for granting access to the remote server + Token localAccessToken = createAndSaveToken(remoteServer.getFqdn()); + // Send the local trusted root, if we needed a different certificate to connect + String localRootCA = rootCA != null ? CertificateUtils.loadLocalTrustedRoot() : null; + // Send the local GPG key used to sign metadata, if configured. + // This force metadata checking on the peripheral server when mirroring from the Hub + String localGpgKey = + (ConfigDefaults.get().isMetadataSigningEnabled()) ? CertificateUtils.loadGpgKey() : null; + + // Register this server on the remote with the hub role + internalApi.registerHub(localAccessToken.getSerializedForm(), localRootCA, localGpgKey); + + // Generate the scc credentials and send them to the peripheral + HubSCCCredentials credentials = generateCredentials(peripheral); + internalApi.storeCredentials(credentials.getUsername(), credentials.getPassword()); + + // Query Report DB connection values and set create a User + ManagerInfoJson managerInfo = internalApi.getManagerInfo(); + Server peripheralServer = getOrCreateManagerSystem(systemEntitlementManager, user, + remoteServer.getFqdn(), Set.of(remoteServer.getFqdn())); + boolean changed = SystemManager.updateMgrServerInfo(peripheralServer, managerInfo); + if (changed) { + setReportDbUser(user, peripheralServer, false); + } + } + catch (Exception ex) { + // cleanup the remote side + internalApi.deregister(); + throw ex; + } + } + + private void ensureServerNotRegistered(String peripheralServer) { + Optional issHub = hubFactory.lookupIssHubByFqdn(peripheralServer); + if (issHub.isPresent()) { + throw new IllegalStateException("Server " + peripheralServer + " is already registered as hub"); + } + + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn(peripheralServer); + if (issPeripheral.isPresent()) { + throw new IllegalStateException("Server " + peripheralServer + " is already registered as peripheral"); + } + } + + private HubSCCCredentials generateCredentials(IssPeripheral peripheral) { + String username = "peripheral-%06d".formatted(peripheral.getId()); + String password = RandomStringUtils.random(24, 0, 0, true, true, null, new SecureRandom()); + + var hubSCCCredentials = CredentialsFactory.createHubSCCCredentials(username, password, peripheral.getFqdn()); + CredentialsFactory.storeCredentials(hubSCCCredentials); + + peripheral.setMirrorCredentials(hubSCCCredentials); + saveServer(peripheral); + + return hubSCCCredentials; + } + + private SCCCredentials saveCredentials(IssHub hub, String username, String password) { + // Delete any existing SCC Credentials + CredentialsFactory.listSCCCredentials() + .forEach(creds -> mirrorCredentialsManager.deleteMirrorCredentials(creds.getId(), null)); + + // Create the new credentials for the hub + SCCCredentials credentials = CredentialsFactory.createSCCCredentials(username, password); + + credentials.setUrl("https://" + hub.getFqdn()); + CredentialsFactory.storeCredentials(credentials); + + hub.setMirrorCredentials(credentials); + saveServer(hub); + + return credentials; + } + + private Token createAndSaveToken(String fqdn) throws TokenBuildingException, TokenParsingException { + Token token = new IssTokenBuilder(fqdn) + .usingServerSecret() + .build(); + + hubFactory.saveToken(fqdn, token.getSerializedForm(), TokenType.ISSUED, token.getExpirationTime()); + return token; + } + + private void parseAndSaveToken(String fqdn, String token) throws TokenParsingException { + // We do not need to verify the signature as this token is for accessing another system. + // That system will take care of ensuring its authenticity + Token parsedToken = new TokenParser() + .skippingSignatureVerification() + .verifyingExpiration() + .verifyingNotBefore() + .parse(token); + + // Verify if this token is for this system + String targetFqdn = parsedToken.getClaim("fqdn", String.class); + String hostname = ConfigDefaults.get().getHostname(); + + if (targetFqdn == null || !targetFqdn.equals(hostname)) { + throw new TokenParsingException("FQDN do not match. Expected %s got %s".formatted(hostname, targetFqdn)); + } + + hubFactory.saveToken(fqdn, token, TokenType.CONSUMED, parsedToken.getExpirationTime()); + } + + private static String computeRootCaFileName(IssRole role, String serverFqdn) { + return String.format(ROOT_CA_FILENAME_TEMPLATE, role.getLabel(), serverFqdn); + } + + private IssServer lookupServerByFqdnAndRole(String serverFqdn, IssRole role) { + return switch (role) { + case HUB -> hubFactory.lookupIssHubByFqdn(serverFqdn).orElse(null); + case PERIPHERAL -> hubFactory.lookupIssPeripheralByFqdn(serverFqdn).orElse(null); + }; + } + + private IssServer createServer(IssRole role, String serverFqdn, String rootCA, String gpgKey, User user) + throws TaskomaticApiException { + taskomaticApi.scheduleSingleRootCaCertUpdate(computeRootCaFileName(role, serverFqdn), rootCA); + return switch (role) { + case HUB -> { + IssHub hub = new IssHub(serverFqdn, rootCA); + hub.setGpgKey(gpgKey); + hubFactory.save(hub); + taskomaticApi.scheduleSingleGpgKeyImport(gpgKey); + yield hub; + } + case PERIPHERAL -> { + IssPeripheral peripheral = new IssPeripheral(serverFqdn, rootCA); + hubFactory.save(peripheral); + getOrCreateManagerSystem(systemEntitlementManager, user, serverFqdn, Set.of(serverFqdn)); + yield peripheral; + } + }; + } + + private void saveServer(IssServer server) { + if (server instanceof IssHub hub) { + hubFactory.save(hub); + } + else if (server instanceof IssPeripheral peripheral) { + hubFactory.save(peripheral); + } + else { + throw new IllegalArgumentException("Unknown server class " + server.getClass().getName()); + } + } + + private static void ensureSatAdmin(User user) { + if (!user.hasRole(RoleFactory.SAT_ADMIN)) { + throw new PermissionException(RoleFactory.SAT_ADMIN); + } + } + + private static void ensureValidToken(IssAccessToken accessToken) { + // Must be a valid not expired ISSUED token + // Actual verification of the JWT signature is not done: since the token was stored in the database we can + // consider it already verified + if (accessToken == null || accessToken.getType() != TokenType.ISSUED || + !accessToken.isValid() || accessToken.isExpired()) { + throw new PermissionException("You do not have permissions to perform this action. Invalid token provided"); + } + } + + /** + * Retrieves or create a server system + * + * @param systemEntitlementManagerIn the system entitlement manager + * @param creator the user creating the server system + * @param serverName the FQDN of the proxy system + * @return the proxy system + */ + private Server getOrCreateManagerSystem( + SystemEntitlementManager systemEntitlementManagerIn, + User creator, String serverName, Set fqdns + ) { + Optional existing = ServerFactory.findByAnyFqdn(fqdns); + if (existing.isPresent()) { + Server server = existing.get(); + if (!(server.hasEntitlement(EntitlementManager.SALT) || + server.hasEntitlement(EntitlementManager.FOREIGN))) { + throw new SystemsExistException(List.of(server.getId())); + } + // Add the FQDNs as some may not be already known + server.getFqdns().addAll(fqdns.stream() + .filter(fqdn -> !fqdn.contains("*")) + .map(fqdn -> new ServerFQDN(server, fqdn)).toList()); + + server.updateServerInfo(); + SystemManager.updateSystemOverview(server.getId()); + return server; + } + Server server = ServerFactory.createServer(); + server.setName(serverName); + server.setHostname(serverName); + server.setOrg(Optional.ofNullable(creator).map(User::getOrg).orElse(OrgFactory.getSatelliteOrg())); + server.setCreator(creator); + + String uniqueId = SystemManagerUtils.createUniqueId(List.of(serverName)); + server.setDigitalServerId(uniqueId); + server.setMachineId(uniqueId); + server.setOs("(unknown)"); + server.setRelease("(unknown)"); + server.setSecret(RandomStringUtils.random(64, 0, 0, true, true, + null, new SecureRandom())); + server.setAutoUpdate("N"); + server.setContactMethod(ServerFactory.findContactMethodByLabel("default")); + server.setLastBoot(System.currentTimeMillis() / 1000); + server.setServerArch(ServerFactory.lookupServerArchByLabel("x86_64-redhat-linux")); + ServerFactory.save(server); + + server.getFqdns().addAll(fqdns.stream() + .filter(fqdn -> !fqdn.contains("*")) + .map(fqdn -> new ServerFQDN(server, fqdn)).toList()); + + server.updateServerInfo(); + + MgrServerInfo serverInfo = new MgrServerInfo(); + serverInfo.setServer(server); + server.setMgrServerInfo(serverInfo); + + ServerFactory.save(server); + + // No need to call `updateSystemOverview` + // It will be called inside the method setBaseEntitlement. If we remove this line we need to manually call it + systemEntitlementManagerIn.setBaseEntitlement(server, EntitlementManager.FOREIGN); + return server; + } + + /** + * Collect data about all organizations + * + * @param accessToken the accesstoken + * @return return list of {@link Org} + */ + public List collectAllOrgs(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return OrgFactory.lookupAllOrgs(); + } + + /** + * Collect data about all channels + * + * @param accessToken the accesstoken + * @return return list of {@link Channel} + */ + public List collectAllChannels(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return ChannelFactory.listAllChannels(); + } + + /** + * Count the registered peripherals on "this" hub + * @param user the SatAdmin + * @return the count of registered peripherals entities + */ + public Long countRegisteredPeripherals(User user) { + ensureSatAdmin(user); + return hubFactory.countPeripherals(); + } + + /** + * List the peripherals with pagination //TODO: and search + * @param user the SatAdmin + * @param pc the PageControl + * @return a List of Peripherals entities + */ + public List listRegisteredPeripherals(User user, PageControl pc) { + ensureSatAdmin(user); + return hubFactory.listPaginatedPeripherals(pc.getStart() - 1, pc.getPageSize()); + } + + /** + * Get the Hub for "this" peripheral + * @param user the SatAdmin + * @return the IssHub entity + */ + public Optional getHub(User user) { + ensureSatAdmin(user); + return hubFactory.lookupIssHub(); + } + + /** + * Get the Peripheral Organizations + * @param user the SatAdmin + * @param peripheralId the Peripheral ID + * @return a List of Organization from the Peripheral + * @throws CertificateException Wrong CA + * @throws IOException Internal API Call has gone wrong + */ + public List getPeripheralOrgs(User user, Long peripheralId) + throws CertificateException, IOException { + ensureSatAdmin(user); + IssPeripheral issPeripheral = hubFactory.findPeripheral(peripheralId); + IssAccessToken accessToken = hubFactory.lookupAccessTokenFor(issPeripheral.getFqdn()); + var internalApi = clientFactory.newInternalClient( + issPeripheral.getFqdn(), + accessToken.getToken(), + issPeripheral.getRootCa() + ); + return internalApi.getAllPeripheralOrgs(); + } + + /** + * Get the Peripheral Channels + * @param user the SatAdmin + * @param peripheralId the Peripheral ID + * @return a Set of Channels from the Peripheral + * @throws CertificateException Wrong CA + */ + public Set getPeripheralChannels(User user, Long peripheralId) + throws CertificateException { + ensureSatAdmin(user); + IssPeripheral issPeripheral = hubFactory.findPeripheral(peripheralId); + return issPeripheral.getPeripheralChannels().stream().map( + entity -> new IssV3ChannelResponse( + entity.getChannel().getId(), + entity.getChannel().getName(), + entity.getChannel().getLabel(), + entity.getChannel().getChannelArch().getName(), + entity.getChannel().getOrg() != null ? + new IssV3ChannelResponse.ChannelOrgResponse( + entity.getChannel().getOrg().getId(), entity.getChannel().getOrg().getName()) : + null) + ).collect(Collectors.toSet()); + } + + /** + * Get the custom channels of the hub + * @param user The SatAdmin + * @return a Set of Channels + */ + public Set getHubCustomChannels(User user) { + ensureSatAdmin(user); + return ChannelFactory.listCustomChannels().stream() + .map(ch -> new IssV3ChannelResponse( + ch.getId(), ch.getName(), ch.getLabel(), ch.getChannelArch().getName(), + new IssV3ChannelResponse.ChannelOrgResponse(ch.getOrg().getId(), ch.getOrg().getName()) + )).collect(Collectors.toSet()); + } + /** + * Get the vendor channels of the hub + * @param user The SatAdmin + * @return a Set of Channels + */ + public Set getHubVendorChannels(User user) { + ensureSatAdmin(user); + return ChannelFactory.listVendorChannels().stream() + .map(ch -> new IssV3ChannelResponse( + ch.getId(), ch.getName(), ch.getLabel(), ch.getChannelArch().getName(), null + )).collect(Collectors.toSet()); + } + + /** + * Returns the available and synced channels and a list of organization from the peripheral + * @param user the SatAdmin + * @param peripheralId the Peripheral ID + * @return the Sync Channel operations model + */ + public ChannelSyncModel getChannelSyncModelForPeripheral(User user, Long peripheralId) { + List peripheralOrgs; + Set syncedCustomChannels; + Set syncedVendorChannels; + Set hubVendorChannels; + Set hubCustomChannels; + try { + // Can't page this, we need everything. + peripheralOrgs = getPeripheralOrgs(user, peripheralId); + Set syncedPeripheralChannels = getPeripheralChannels(user, peripheralId); + // Partition here so we don't go inside the list two times + Map> partitioned = syncedPeripheralChannels.stream() + .collect(Collectors.partitioningBy(ch -> ch.getChannelOrg().getOrgId() != null)); + syncedCustomChannels = new HashSet<>(partitioned.get(true)); + syncedVendorChannels = new HashSet<>(partitioned.get(true)); + hubVendorChannels = getHubVendorChannels(user); + hubCustomChannels = getHubCustomChannels(user); + } + catch (CertificateException | IOException eIn) { + throw new RuntimeException(eIn); + } + // Utility filter lambdas. + Function, Predicate> availableFilter = peripheralLabels -> + hubLabel -> !peripheralLabels.contains(hubLabel); + // Channels that are not synced + List availableCustomChannels = filterHubChannelsByPeripheral( + hubCustomChannels, + syncedCustomChannels, + IssV3ChannelResponse::getChannelLabel, + IssV3ChannelResponse::getChannelLabel, + availableFilter + ); + List availableVendorChannels = filterHubChannelsByPeripheral( + hubVendorChannels, + syncedVendorChannels, + IssV3ChannelResponse::getChannelLabel, + IssV3ChannelResponse::getChannelLabel, + availableFilter + ); + return new ChannelSyncModel( + peripheralOrgs, + syncedCustomChannels, + syncedVendorChannels, + availableCustomChannels, + availableVendorChannels + ); + } + + /** + * Generic helper function that filters hub/peripheral channels by a label + * + * @param hubChannels a set of channels from the hub + * @param peripheralChannels a set of channels from the peripheral + * @param hubLabelExtractor the label get method + * @param peripheralLabelExtractor the label get method + * @param filterFunction the filter function + * @param the hub class + * @param

the peripheral class + * @return the filtered list + */ + private static List filterHubChannelsByPeripheral( + Set hubChannels, + Set

peripheralChannels, + Function hubLabelExtractor, + Function peripheralLabelExtractor, + Function, Predicate> filterFunction + ) { + Set peripheralLabels = peripheralChannels.stream() + .map(peripheralLabelExtractor) + .collect(Collectors.toSet()); + Predicate hubFilter = filterFunction.apply(peripheralLabels); + return hubChannels.stream() + .filter(hub -> hubFilter.test(hubLabelExtractor.apply(hub))) + .collect(Collectors.toList()); + } + + /** + * Sync the channels from "this" hub to the selected peripheral + * @param user the SatAdmin + * @param peripheralId the peripheral id + * @param channelsId the list of channels id from the hub + */ + public void syncChannelsByIdForPeripheral(User user, Long peripheralId, List channelsId) + throws CertificateException, IOException { + ensureSatAdmin(user); + IssPeripheral issPeripheral = hubFactory.findPeripheral(peripheralId); + IssAccessToken accessToken = hubFactory.lookupAccessTokenFor(issPeripheral.getFqdn()); + List channels = ChannelFactory.getSession().byMultipleIds(Channel.class).multiLoad(channelsId); + var internalApi = clientFactory.newInternalClient( + issPeripheral.getFqdn(), + accessToken.getToken(), + issPeripheral.getRootCa() + ); + // TODO: transaction start + try { + List channelsLabel = new ArrayList<>(); + Set peripheralChannels = new HashSet<>(); + channels.forEach(ch -> { + peripheralChannels.add(new IssPeripheralChannels(issPeripheral, ch)); + channelsLabel.add(ch.getLabel()); + }); + issPeripheral.setPeripheralChannels(peripheralChannels); + hubFactory.save(issPeripheral); + internalApi.syncVendorChannels(channelsLabel); + } + catch (IOException eIn) { + // TODO: transaction rollback + throw eIn; + } + } + + /** + * Sync the channels from "this" hub to the selected peripheral + * @param user the SatAdmin + * @param peripheralId the peripheral id + * @param channelsId the list of channels id from the hub + */ + public void desyncChannelsByIdForPeripheral(User user, Long peripheralId, List channelsId) { + ensureSatAdmin(user); + IssPeripheral issPeripheral = hubFactory.findPeripheral(peripheralId); + List channels = ChannelFactory.getSession().byMultipleIds(Channel.class).multiLoad(channelsId); + Set peripheralChannels = new HashSet<>(); + channels.forEach(ch -> peripheralChannels.add(new IssPeripheralChannels(issPeripheral, ch))); + issPeripheral.setPeripheralChannels(peripheralChannels); + hubFactory.save(issPeripheral); + } + + /** + * add vendor channel to peripheral + * + * @param accessToken the access token + * @param vendorChannelLabelList the vendor channel label list + * @return returns a list of the vendor channel that have been added {@link Channel} + * the possible return cases are: + * 1) empty list: the vendor channel and its base channel were already present in the peripheral (nothing created) + * 2) one-channel list: if only the vendor channel was created while the base channel was already present + * 3) two-channel list: if both the vendor and the base channel were added to the peripheral + */ + public List addVendorChannels(IssAccessToken accessToken, List vendorChannelLabelList) { + ensureValidToken(accessToken); + ChannelFactory.ensureValidVendorChannels(vendorChannelLabelList); + + String mirrorUrl = null; + + ContentSyncManager csm = new ContentSyncManager(); + if (csm.isRefreshNeeded(mirrorUrl)) { + throw new ContentSyncException("Product Data refresh needed. Please call mgr-sync refresh."); + } + + List addedVendorChannelLabels = new ArrayList<>(); + for (String vendorChannelLabel : vendorChannelLabelList) { + //retrieve vendor channel template + Optional vendorChannelTemplate = SUSEProductFactory + .lookupByChannelLabelFirst(vendorChannelLabel); + + if (vendorChannelTemplate.isEmpty()) { + throw new InvalidChannelLabelException(vendorChannelLabel, + InvalidChannelLabelException.Reason.IS_MISSING, + "Invalid data: vendor channel label not found", vendorChannelLabel); + } + + // get base channel of target channel + if (!vendorChannelTemplate.get().isRoot()) { + String vendorBaseChannelLabel = vendorChannelTemplate.get().getParentChannelLabel(); + + // check if base channel is already added + if (!ChannelFactory.doesChannelLabelExist(vendorBaseChannelLabel)) { + // if not, add base channel + addedVendorChannelLabels.add(vendorBaseChannelLabel); + } + } + + // check if channel is already added + if (!ChannelFactory.doesChannelLabelExist(vendorChannelLabel)) { + //add target channel + addedVendorChannelLabels.add(vendorChannelLabel); + } + } + + //add target channels + addedVendorChannelLabels.forEach(l -> csm.addChannel(l, mirrorUrl)); + + return ChannelFactory.listAllChannels() + .stream() + .filter(e -> addedVendorChannelLabels.contains(e.getLabel())) + .toList(); + } + + /** + * add custom channels to peripheral + * + * @param accessToken the access token + * @param customChannelInfoJsonList the list of custom channel info to add + * @return returns a list of the custom channels {@link Channel} that have been added + */ + public List addCustomChannels(IssAccessToken accessToken, + List customChannelInfoJsonList) { + ensureValidToken(accessToken); + ChannelFactory.ensureValidCustomChannels(customChannelInfoJsonList); + + String mirrorUrl = null; + + ContentSyncManager csm = new ContentSyncManager(); + if (csm.isRefreshNeeded(mirrorUrl)) { + throw new ContentSyncException("Product Data refresh needed. Please call mgr-sync refresh."); + } + List addedChannelsLabelList = new ArrayList<>(); + for (CustomChannelInfoJson customChannelInfo : customChannelInfoJsonList) { + // Create the channel + Channel customChannel = ChannelFactory.toCustomChannel(customChannelInfo); + ChannelFactory.save(customChannel); + addedChannelsLabelList.add(customChannel.getLabel()); + } + + return ChannelFactory.listAllChannels() + .stream() + .filter(e -> addedChannelsLabelList.contains(e.getLabel())) + .toList(); + } + + /** + * modify a peripheral custom channel + * + * @param accessToken the access token + * @param modifyCustomChannelList the list of custom channels modifications + * @return returns a list of the custom channel that have been added {@link Channel} + */ + public List modifyCustomChannels(IssAccessToken accessToken, + List modifyCustomChannelList) { + ensureValidToken(accessToken); + ChannelFactory.ensureValidModifyCustomChannels(modifyCustomChannelList); + + String mirrorUrl = null; + + ContentSyncManager csm = new ContentSyncManager(); + if (csm.isRefreshNeeded(mirrorUrl)) { + throw new ContentSyncException("Product Data refresh needed. Please call mgr-sync refresh."); + } + + List modifiedChannelsLabelList = new ArrayList<>(); + for (ModifyCustomChannelInfoJson modifyCustomChannelInfo : modifyCustomChannelList) { + // modify the channel + Channel customChannel = ChannelFactory.modifyCustomChannel(modifyCustomChannelInfo); + ChannelFactory.save(customChannel); + + modifiedChannelsLabelList.add(customChannel.getLabel()); + } + + return ChannelFactory.listAllChannels() + .stream() + .filter(e -> modifiedChannelsLabelList.contains(e.getLabel())) + .toList(); + } + + /** + * Trigger a synchronization of Channel Families on the peripheral + * + * @param accessToken the access token + * @return a boolean flag of the success/failed result + */ + public boolean synchronizeChannelFamilies(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return ProductsController.doSynchronizeChannelFamilies(); + } + + /** + * Trigger a synchronization of Products on the peripheral + * + * @param accessToken the access token + * @return a boolean flag of the success/failed result + */ + public boolean synchronizeProducts(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return ProductsController.doSynchronizeProducts(); + } + + /** + * Trigger a synchronization of Repositories on the peripheral + * + * @param accessToken the access token + * @return a boolean flag of the success/failed result + */ + public boolean synchronizeRepositories(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return ProductsController.doSynchronizeRepositories(); + } + + /** + * Trigger a synchronization of Subscriptions on the peripheral + * + * @param accessToken the access token + * @return a boolean flag of the success/failed result + */ + public boolean synchronizeSubscriptions(IssAccessToken accessToken) { + ensureValidToken(accessToken); + return ProductsController.doSynchronizeSubscriptions(); + } + +} diff --git a/java/code/src/com/suse/manager/hub/HubSparkHelper.java b/java/code/src/com/suse/manager/hub/HubSparkHelper.java new file mode 100644 index 000000000000..e7fe437ce989 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/HubSparkHelper.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import static com.suse.manager.webui.utils.SparkApplicationHelper.internalServerError; +import static com.suse.manager.webui.utils.SparkApplicationHelper.json; + +import com.redhat.rhn.frontend.security.AuthenticationServiceFactory; + +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.webui.utils.gson.ResultJson; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import com.google.gson.reflect.TypeToken; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Optional; + +import javax.servlet.http.HttpServletResponse; + +import spark.Route; +import spark.Spark; + +public final class HubSparkHelper { + + private static final Logger LOGGER = LogManager.getLogger(HubSparkHelper.class); + + private static final HubFactory HUB_FACTORY = new HubFactory(); + + private HubSparkHelper() { + // Prevent instantiation + } + + /** + * Use in ISS routes to specify the method requires api key authentication + * + * @param route the route + * @return the route + */ + public static Route usingTokenAuthentication(RouteWithHubToken route) { + return (request, response) -> { + String authorization = request.headers("Authorization"); + if (authorization == null || !authorization.startsWith("Bearer")) { + Spark.halt(HttpServletResponse.SC_BAD_REQUEST); + } + + String serializedToken = authorization.substring(7); + IssAccessToken issuedToken = HUB_FACTORY.lookupIssuedToken(serializedToken); + + if (issuedToken == null) { + LOGGER.error("Invalid token provided"); + response.status(HttpServletResponse.SC_UNAUTHORIZED); + return json(response, ResultJson.error("Invalid token provided"), new TypeToken<>() { }); + } + else if (issuedToken.isExpired() || !issuedToken.isValid()) { + LOGGER.error("Invalid token provided for {} - expired {} valid {}", + issuedToken.getServerFqdn(), issuedToken.isExpired(), issuedToken.isValid()); + response.status(HttpServletResponse.SC_UNAUTHORIZED); + return json(response, ResultJson.error("Invalid token provided"), new TypeToken<>() { }); + } + + try { + Token token = issuedToken.getParsedToken(); + String fqdn = token.getClaim("fqdn", String.class); + if (fqdn == null) { + LOGGER.error("Invalid token provided for {} - no FQDN", issuedToken.getServerFqdn()); + response.status(HttpServletResponse.SC_UNAUTHORIZED); + return json(response, ResultJson.error("Invalid token provided"), new TypeToken<>() { }); + } + else if (!fqdn.equals(issuedToken.getServerFqdn())) { + LOGGER.error("Invalid token provided for {} - token issued for {}", + issuedToken.getServerFqdn(), fqdn); + response.status(HttpServletResponse.SC_UNAUTHORIZED); + return json(response, ResultJson.error("Invalid token provided"), new TypeToken<>() { }); + } + + return route.handle(request, response, issuedToken); + } + catch (TokenParsingException ex) { + LOGGER.error("Invalid token provided - parsing error"); + response.status(HttpServletResponse.SC_UNAUTHORIZED); + return json(response, ResultJson.error("Invalid token provided"), new TypeToken<>() { }); + } + catch (Exception e) { + LOGGER.error("Internal Server Error", e); + return internalServerError(response, "Internal Server Error", e.getMessage()); + } + finally { + var authenticationService = AuthenticationServiceFactory.getInstance().getAuthenticationService(); + authenticationService.invalidate(request.raw(), response.raw()); + } + }; + } + + /** + * Use in ISS routes only accessible from a hub + * @param route the route + * @return the route + */ + public static RouteWithHubToken onlyFromHub(RouteWithHubToken route) { + return onlyFrom(List.of(IssRole.HUB), route); + } + + /** + * Use in ISS routes only accessible from a peripheral + * @param route the route + * @return the route + */ + public static RouteWithHubToken onlyFromPeripheral(RouteWithHubToken route) { + return onlyFrom(List.of(IssRole.PERIPHERAL), route); + } + + /** + * Use in ISS routes only accessible from a registered server + * @param route the route + * @return the route + */ + public static RouteWithHubToken onlyFromRegistered(RouteWithHubToken route) { + return onlyFrom(List.of(IssRole.HUB, IssRole.PERIPHERAL), route); + } + + /** + * Use in ISS routes only accessible from an unregistered server + * @param route the route + * @return the route + */ + public static RouteWithHubToken onlyFromUnregistered(RouteWithHubToken route) { + return onlyFrom(List.of(), route); + } + + private static RouteWithHubToken onlyFrom(List allowedRoles, RouteWithHubToken route) { + return (request, response, issAccessToken) -> { + String fqdn = issAccessToken.getServerFqdn(); + Optional issHub = HUB_FACTORY.lookupIssHubByFqdn(fqdn); + Optional issPeripheral = HUB_FACTORY.lookupIssPeripheralByFqdn(fqdn); + + if (isRouteForbidden(allowedRoles, issHub.isPresent(), issPeripheral.isPresent())) { + response.status(HttpServletResponse.SC_FORBIDDEN); + LOGGER.error("Token does not allow access to this resource for {}.", fqdn); + LOGGER.error(" Allowed {} - isHub {} - isPeripheral {}", allowedRoles, issHub.isPresent(), + issPeripheral.isPresent()); + return json(response, ResultJson.error("Token does not allow access to this resource"), + new TypeToken<>() { }); + + } + + return route.handle(request, response, issAccessToken); + }; + } + + private static boolean isRouteForbidden(List allowedRoles, boolean isHub, boolean isPeripheral) { + // No role allowed, only unregistered server can use this route + if (allowedRoles.isEmpty() && (isHub || isPeripheral)) { + return true; + } + + // Only hub servers are allowed + if (isHub && allowedRoles.stream().noneMatch(r -> r == IssRole.HUB)) { + return true; + } + + // Only peripheral servers are allowed + if (isPeripheral && allowedRoles.stream().noneMatch(r -> r == IssRole.PERIPHERAL)) { + return true; + } + + return false; + } +} diff --git a/java/code/src/com/suse/manager/hub/InvalidResponseException.java b/java/code/src/com/suse/manager/hub/InvalidResponseException.java new file mode 100644 index 000000000000..09db87caf4f9 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/InvalidResponseException.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub; + +import java.io.IOException; + +public class InvalidResponseException extends IOException { + + /** + * Creates a new instance with the given error message + * @param message the error message + */ + public InvalidResponseException(String message) { + super(message); + } + + /** + * Creates a new instance with the given error message and cause + * @param message the error message + * @param cause what cause this invalid response exception + */ + public InvalidResponseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/code/src/com/suse/manager/hub/RouteWithHubToken.java b/java/code/src/com/suse/manager/hub/RouteWithHubToken.java new file mode 100644 index 000000000000..234397f7ef86 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/RouteWithHubToken.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.hub; + +import com.suse.manager.model.hub.IssAccessToken; + +import spark.Request; +import spark.Response; + +/** + * A route that gets the authentication token in addition to the request and response. + */ +@FunctionalInterface +public interface RouteWithHubToken { + + /** + * Invoked when a request is made on this route's corresponding path. + * + * @param request the request object + * @param response the response object + * @param token the access token granting access and identifying the caller + * @return the content to be set in the response + */ + Object handle(Request request, Response response, IssAccessToken token); +} diff --git a/java/code/src/com/suse/manager/hub/RouteWithSCCAuth.java b/java/code/src/com/suse/manager/hub/RouteWithSCCAuth.java new file mode 100644 index 000000000000..5f359ee3acef --- /dev/null +++ b/java/code/src/com/suse/manager/hub/RouteWithSCCAuth.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.hub; + +import com.redhat.rhn.domain.credentials.HubSCCCredentials; + +import spark.Request; +import spark.Response; + +/** + * + */ +@FunctionalInterface +public interface RouteWithSCCAuth { + + /** + * + * @param request the request object + * @param response the response object + * @param credentials hub credentials used to authenticate + * @return the content to be set in the response + */ + Object handle(Request request, Response response, HubSCCCredentials credentials); +} diff --git a/java/code/src/com/suse/manager/hub/test/ControllerTestUtils.java b/java/code/src/com/suse/manager/hub/test/ControllerTestUtils.java new file mode 100644 index 000000000000..829bcd2fee04 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/test/ControllerTestUtils.java @@ -0,0 +1,590 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub.test; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.common.hibernate.ConnectionManager; +import com.redhat.rhn.common.hibernate.ConnectionManagerFactory; +import com.redhat.rhn.common.hibernate.ReportDbHibernateFactory; +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.channel.ChannelArch; +import com.redhat.rhn.domain.channel.ChannelFactory; +import com.redhat.rhn.domain.channel.ChannelFamily; +import com.redhat.rhn.domain.channel.test.ChannelFactoryTest; +import com.redhat.rhn.domain.channel.test.ChannelFamilyFactoryTest; +import com.redhat.rhn.domain.org.Org; +import com.redhat.rhn.domain.product.test.SUSEProductTestUtils; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.taskomatic.task.ReportDBHelper; +import com.redhat.rhn.testing.RhnMockHttpServletResponse; +import com.redhat.rhn.testing.SparkTestUtils; +import com.redhat.rhn.testing.TestUtils; +import com.redhat.rhn.testing.UserTestUtils; + +import com.suse.manager.model.hub.ChannelInfoJson; +import com.suse.manager.model.hub.CustomChannelInfoJson; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.ModifyCustomChannelInfoJson; +import com.suse.manager.model.hub.TokenType; +import com.suse.manager.webui.utils.gson.ResultJson; +import com.suse.manager.webui.utils.token.IssTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenParsingException; +import com.suse.utils.Json; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import spark.Request; +import spark.RequestResponseFactory; +import spark.Response; +import spark.RouteImpl; +import spark.route.HttpMethod; +import spark.routematch.RouteMatch; + +public class ControllerTestUtils { + + private String apiEndpoint; + private HttpMethod httpMethod; + private String serverFqdn; + private String authBearerToken; + private Instant authBearerTokenExpiration; + private IssRole role; + private boolean addBearerTokenToHeaders; + private Map bodyMap; + + public ControllerTestUtils() { + apiEndpoint = null; + httpMethod = null; + serverFqdn = null; + authBearerToken = null; + authBearerTokenExpiration = null; + role = null; + addBearerTokenToHeaders = false; + bodyMap = null; + } + + public ControllerTestUtils withServerFqdn(String serverFqdnIn) + throws TokenBuildingException, TokenParsingException { + serverFqdn = serverFqdnIn; + Token dummyServerToken = new IssTokenBuilder(serverFqdn).usingServerSecret().build(); + authBearerToken = dummyServerToken.getSerializedForm(); + authBearerTokenExpiration = dummyServerToken.getExpirationTime(); + return this; + } + + public ControllerTestUtils withApiEndpoint(String apiEndpointIn) { + apiEndpoint = apiEndpointIn; + return this; + } + + public ControllerTestUtils withHttpMethod(HttpMethod httpMethodIn) { + httpMethod = httpMethodIn; + return this; + } + + public ControllerTestUtils withRole(IssRole roleIn) { + role = roleIn; + return this; + } + + public ControllerTestUtils withBearerTokenInHeaders() { + addBearerTokenToHeaders = true; + return this; + } + + public ControllerTestUtils withBody(Map bodyMapIn) { + bodyMap = bodyMapIn; + return this; + } + + public Object simulateControllerApiCall() throws Exception { + HubFactory hubFactory = new HubFactory(); + hubFactory.saveToken(serverFqdn, authBearerToken, TokenType.ISSUED, authBearerTokenExpiration); + + if (null != role) { + switch (role) { + case HUB: + Optional hub = hubFactory.lookupIssHubByFqdn(serverFqdn); + if (hub.isEmpty()) { + hubFactory.save(new IssHub(serverFqdn, "")); + } + break; + case PERIPHERAL: + Optional peripheral = hubFactory.lookupIssPeripheralByFqdn(serverFqdn); + if (peripheral.isEmpty()) { + hubFactory.save(new IssPeripheral(serverFqdn, "")); + } + break; + default: + throw new IllegalArgumentException("unsupported role " + role.getLabel()); + } + } + + String bodyString = (null == bodyMap) ? null : Json.GSON.toJson(bodyMap, Map.class); + + return simulateApiEndpointCall(apiEndpoint, httpMethod, + addBearerTokenToHeaders ? authBearerToken : null, bodyString); + } + + public static Object simulateApiEndpointCall(String apiEndpoint, HttpMethod httpMethod, + String authBearerToken, String body) throws Exception { + Optional routeMatch = spark.Spark.routes() + .stream() + .filter(e -> apiEndpoint.equals(e.getMatchUri())) + .filter(e -> httpMethod.equals(e.getHttpMethod())) + .findAny(); + + if (routeMatch.isEmpty()) { + throw new IllegalStateException("route not found for " + apiEndpoint); + } + + RouteImpl routeImpl = (RouteImpl) routeMatch.get().getTarget(); + + Map httpHeaders = (null == authBearerToken) ? + new HashMap<>() : + Map.of("Authorization", "Bearer " + authBearerToken); + + Request dummyTestRequest = (null == body) ? + SparkTestUtils.createMockRequestWithParams(apiEndpoint, new HashMap<>(), httpHeaders) : + SparkTestUtils.createMockRequestWithBody(apiEndpoint, httpHeaders, body); + + Response dummyTestResponse = RequestResponseFactory.create(new RhnMockHttpServletResponse()); + return routeImpl.handle(dummyTestRequest, dummyTestResponse); + } + + public String createTestUserName() { + return "testUser" + TestUtils.randomString(); + } + + public String createTestPassword() { + return "testPassword" + TestUtils.randomString(); + } + + public void createReportDbUser(String testReportDbUserName, String testReportDbPassword) { + String dbname = Config.get().getString(ConfigDefaults.REPORT_DB_NAME, ""); + ConnectionManager localRcm = ConnectionManagerFactory.localReportingConnectionManager(); + ReportDbHibernateFactory localRh = new ReportDbHibernateFactory(localRcm); + ReportDBHelper dbHelper = ReportDBHelper.INSTANCE; + + dbHelper.createDBUser(localRh.getSession(), dbname, testReportDbUserName, testReportDbPassword); + localRcm.commitTransaction(); + } + + public boolean existsReportDbUser(String testReportDbUserName) { + ConnectionManager localRcm = ConnectionManagerFactory.localReportingConnectionManager(); + ReportDbHibernateFactory localRh = new ReportDbHibernateFactory(localRcm); + ReportDBHelper dbHelper = ReportDBHelper.INSTANCE; + + return dbHelper.hasDBUser(localRh.getSession(), testReportDbUserName); + } + + public void cleanupReportDbUser(String testReportDbUserName) { + ConnectionManager localRcm = ConnectionManagerFactory.localReportingConnectionManager(); + ReportDbHibernateFactory localRh = new ReportDbHibernateFactory(localRcm); + ReportDBHelper dbHelper = ReportDBHelper.INSTANCE; + + dbHelper.dropDBUser(localRh.getSession(), testReportDbUserName); + localRcm.commitTransaction(); + } + + public Channel createVendorBaseChannel(String name, String label) throws Exception { + Org nullOrg = null; + ChannelFamily cfam = ChannelFamilyFactoryTest.createNullOrgTestChannelFamily(); + String query = "ChannelArch.findById"; + ChannelArch arch = (ChannelArch) TestUtils.lookupFromCacheById(500L, query); + return ChannelFactoryTest.createTestChannel(name, label, nullOrg, arch, cfam); + } + + public Channel createVendorChannel(String name, String label, Channel vendorBaseChannel) throws Exception { + Channel vendorChannel = createVendorBaseChannel(name, label); + vendorChannel.setParentChannel(vendorBaseChannel); + vendorChannel.setChecksumType(ChannelFactory.findChecksumTypeByLabel("sha512")); + ChannelFactory.save(vendorChannel); + return vendorChannel; + } + + public Date createDateUtil(int year, int month, int dayOfMonth) { + GregorianCalendar cal = new GregorianCalendar(year, month, dayOfMonth); + return cal.getTime(); + } + + public boolean isNowUtil(Date dateIn) { + GregorianCalendar cal = new GregorianCalendar(); + Date nowDate = createDateUtil(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)); + + return (dateIn.getTime() - nowDate.getTime() < 24L * 60L * 60L * 1000L); + } + + public CustomChannelInfoJson createCustomChannelInfoJson(Long orgId, + String channelLabel, + String parentChannelLabel, + String originalChannelLabel, + boolean isGpgCheck, + boolean isInstallerUpdates, + String archLabel, + String checksumLabel, + Date endOfLifeDate) { + CustomChannelInfoJson info = new CustomChannelInfoJson(channelLabel); + + info.setPeripheralOrgId(orgId); + info.setParentChannelLabel(parentChannelLabel); + info.setChannelArchLabel(archLabel); + info.setBaseDir("baseDir_" + channelLabel); + info.setName("name_" + channelLabel); + info.setSummary("summary_" + channelLabel); + info.setDescription("description_" + channelLabel); + info.setProductNameLabel("productNameLabel"); + info.setGpgCheck(isGpgCheck); + info.setGpgKeyUrl("gpgKeyUrl_" + channelLabel); + info.setGpgKeyId("gpgKeyId"); + info.setGpgKeyFp("gpgKeyFp_" + channelLabel); + info.setEndOfLifeDate(endOfLifeDate); + info.setChecksumTypeLabel(checksumLabel); + info.setChannelProductProduct("channelProductProduct"); + info.setChannelProductVersion("channelProductVersion"); + info.setChannelAccess("chAccess"); // max 10 + info.setMaintainerName("maintainerName_" + channelLabel); + info.setMaintainerEmail("maintainerEmail_" + channelLabel); + info.setMaintainerPhone("maintainerPhone_" + channelLabel); + info.setSupportPolicy("supportPolicy_" + channelLabel); + info.setUpdateTag("updateTag_" + channelLabel); + info.setInstallerUpdates(isInstallerUpdates); + + info.setOriginalChannelLabel(originalChannelLabel); + + return info; + } + + public void testCustomChannel(Channel ch, Long peripheralOrgId, + boolean isGpgCheck, + boolean isInstallerUpdates, + String archLabel, + String checksumLabel, + Date endOfLifeDate) { + ChannelArch testChannelArch = ChannelFactory.findArchByLabel(archLabel); + String channelLabel = ch.getLabel(); + + if (null != peripheralOrgId) { + assertEquals(peripheralOrgId, ch.getOrg().getId()); + } + else { + assertNull(ch.getOrg()); + } + + assertEquals(testChannelArch, ch.getChannelArch()); + assertEquals("baseDir_" + channelLabel, ch.getBaseDir()); + assertEquals("name_" + channelLabel, ch.getName()); + assertEquals("summary_" + channelLabel, ch.getSummary()); + assertEquals("description_" + channelLabel, ch.getDescription()); + assertEquals(isGpgCheck, ch.isGPGCheck()); + assertEquals("gpgKeyUrl_" + channelLabel, ch.getGPGKeyUrl()); + assertEquals("gpgKeyId", ch.getGPGKeyId()); + assertEquals("gpgKeyFp_" + channelLabel, ch.getGPGKeyFp()); + + assertEquals(endOfLifeDate, ch.getEndOfLife()); + assertEquals(checksumLabel, ch.getChecksumType().getLabel()); + assertTrue(isNowUtil(ch.getLastModified())); + + assertEquals("chAccess", ch.getAccess()); + + assertEquals("maintainerName_" + channelLabel, ch.getMaintainerName()); + assertEquals("maintainerEmail_" + channelLabel, ch.getMaintainerEmail()); + assertEquals("maintainerPhone_" + channelLabel, ch.getMaintainerPhone()); + + assertEquals("supportPolicy_" + channelLabel, ch.getSupportPolicy()); + assertEquals("updateTag_" + channelLabel, ch.getUpdateTag()); + assertEquals(isInstallerUpdates, ch.isInstallerUpdates()); + + assertEquals("productNameLabel", ch.getProductName().getLabel()); + assertEquals("channelProductProduct", ch.getProduct().getProduct()); + assertEquals("channelProductVersion", ch.getProduct().getVersion()); + } + + public String createTestVendorChannels(User userIn, String serverFqdnIn) throws Exception { + + //SUSE Linux Enterprise Server 11 SP3 x86_64 + return createTestVendorChannels(userIn, serverFqdnIn, + "SLES11-SP3-Pool for x86_64", "sles11-sp3-pool-x86_64", true, + "SLES11-SP3-Updates for x86_64", "sles11-sp3-updates-x86_64", true); + + } + + public String createTestVendorChannels(User userIn, String serverFqdnIn, + String vendorBaseChannelTemplateNameIn, + String vendorBaseChannelTemplateLabelIn, + boolean createBaseChannelIn, + String vendorChannelTemplateNameIn, + String vendorChannelTemplateLabelIn, + boolean createChildChannelIn) throws Exception { + + SUSEProductTestUtils.createVendorSUSEProductEnvironment(userIn, null, true); + + Channel vendorBaseChannel = null; + if (createBaseChannelIn) { + vendorBaseChannel = createVendorBaseChannel(vendorBaseChannelTemplateNameIn, + vendorBaseChannelTemplateLabelIn); + } + if (createChildChannelIn) { + createVendorChannel(vendorChannelTemplateNameIn, vendorChannelTemplateLabelIn, vendorBaseChannel); + } + + Map bodyMapIn = new HashMap<>(); + bodyMapIn.put("vendorchannellabellist", Json.GSON.toJson(List.of(vendorChannelTemplateLabelIn))); + + return (String) withServerFqdn(serverFqdnIn) + .withApiEndpoint("/hub/addVendorChannels") + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMapIn) + .simulateControllerApiCall(); + } + + public CustomChannelInfoJson createValidCustomChInfo() { + return createValidCustomChInfo("customCh"); + } + + public CustomChannelInfoJson createValidCustomChInfo(String channelLabel) { + User testPeripheralUser = UserTestUtils.findNewUser("peripheral_user_", "peripheral_org_", true); + return createCustomChannelInfoJson(testPeripheralUser.getOrg().getId(), + channelLabel, "", "", + true, true, "channel-s390", "sha256", + createDateUtil(2096, 10, 22)); + } + + public Object testAddCustomChannelsApiCall(String serverFqdnIn, + List customChannelInfoListIn) throws Exception { + String apiUnderTest = "/hub/addCustomChannels"; + + Map bodyMapIn = new HashMap<>(); + bodyMapIn.put("customchannellist", Json.GSON.toJson(customChannelInfoListIn)); + + return withServerFqdn(serverFqdnIn) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMapIn) + .simulateControllerApiCall(); + } + + public void checkAddCustomChannelsApiNotThrowing(String serverFqdnIn, + List customChannelInfoListIn) + throws Exception { + try { + String answer = (String) testAddCustomChannelsApiCall(serverFqdnIn, customChannelInfoListIn); + + List peripheralCreatedCustomChInfo = + Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + assertNotNull(peripheralCreatedCustomChInfo, + "addCustomChannels API failing when creating peripheral channel"); + } + catch (IllegalArgumentException e) { + fail("addCustomChannels API should not throw"); + } + } + + public void checkAddCustomChannelsApiThrows(String serverFqdnIn, + List customChannelInfoListIn, + String errorStartsWith) throws Exception { + try { + String answer = (String) testAddCustomChannelsApiCall(serverFqdnIn, customChannelInfoListIn); + + ResultJson result = Json.GSON.fromJson(answer, ResultJson.class); + assertFalse(result.isSuccess(), + "addCustomChannels API not failing when creating peripheral channel with " + + errorStartsWith); + assertEquals("Internal Server Error", result.getMessages().get(0)); + assertTrue(result.getMessages().get(1).startsWith(errorStartsWith), + "Wrong expected start of error message: [" + result.getMessages().get(1) + "]"); + } + catch (IllegalArgumentException e) { + fail("addCustomChannels API should not throw"); + } + } + + public ModifyCustomChannelInfoJson createValidModifyCustomChInfo() { + return createValidModifyCustomChInfo("customCh"); + } + + public ModifyCustomChannelInfoJson createValidModifyCustomChInfo(String channelLabel) { + User testPeripheralUser = UserTestUtils.findNewUser("peripheral_user_", "peripheral_org_", true); + boolean isGpgCheck = true; + boolean isInstallerUpdates = true; + ModifyCustomChannelInfoJson info = new ModifyCustomChannelInfoJson(channelLabel); + + info.setPeripheralOrgId(testPeripheralUser.getOrg().getId()); + info.setOriginalChannelLabel(""); + + info.setBaseDir("baseDir_" + channelLabel); + info.setName("name_" + channelLabel); + info.setSummary("summary_" + channelLabel); + info.setDescription("description_" + channelLabel); + info.setProductNameLabel("productNameLabel"); + info.setGpgCheck(isGpgCheck); + info.setGpgKeyUrl("gpgKeyUrl_" + channelLabel); + info.setGpgKeyId("gpgKeyId"); + info.setGpgKeyFp("gpgKeyFp_" + channelLabel); + info.setEndOfLifeDate(createDateUtil(2096, 10, 22)); + info.setChannelProductProduct("channelProductProduct"); + info.setChannelProductVersion("channelProductVersion"); + info.setChannelAccess("chAccess"); // max 10 + info.setMaintainerName("maintainerName_" + channelLabel); + info.setMaintainerEmail("maintainerEmail_" + channelLabel); + info.setMaintainerPhone("maintainerPhone_" + channelLabel); + info.setSupportPolicy("supportPolicy_" + channelLabel); + info.setUpdateTag("updateTag_" + channelLabel); + info.setInstallerUpdates(isInstallerUpdates); + + return info; + } + + public Object testModifyCustomChannelsApiCall(String serverFqdnIn, + List modifyCustomChannelInfoListIn) + throws Exception { + String apiUnderTest = "/hub/modifyCustomChannels"; + + Map bodyMapIn = new HashMap<>(); + bodyMapIn.put("modifycustomchannellist", Json.GSON.toJson(modifyCustomChannelInfoListIn)); + + return withServerFqdn(serverFqdnIn) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMapIn) + .simulateControllerApiCall(); + } + + public void checkModifyCustomChannelsApiNotThrowing(String serverFqdnIn, + List modifyCustomChannelInfoListIn) + throws Exception { + + try { + String answer = (String) testModifyCustomChannelsApiCall(serverFqdnIn, modifyCustomChannelInfoListIn); + + List peripheralCreatedCustomChInfo = + Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + assertNotNull(peripheralCreatedCustomChInfo, + "addCustomChannels API failing when creating peripheral channel"); + + } + catch (IllegalArgumentException e) { + fail("modifyCustomChannels API should not throw"); + } + } + + public void checkModifyCustomChannelsApiThrows(String serverFqdnIn, + List modifyCustomChannelInfoListIn, + String errorStartsWith) throws Exception { + try { + String answer = (String) testModifyCustomChannelsApiCall(serverFqdnIn, modifyCustomChannelInfoListIn); + + ResultJson result = Json.GSON.fromJson(answer, ResultJson.class); + assertFalse(result.isSuccess(), + "modifyCustomChannels API not failing when creating peripheral channel with " + + errorStartsWith); + assertEquals("Internal Server Error", result.getMessages().get(0)); + assertTrue(result.getMessages().get(1).startsWith(errorStartsWith), + "Wrong expected start of error message: [" + result.getMessages().get(1) + "]"); + } + catch (IllegalArgumentException e) { + fail("modifyCustomChannels API should not throw"); + } + } + + private static void checkEqualIfModified(Object modified, Object pristine) { + if (null != modified) { + assertEquals(modified, pristine); + } + } + + private static void checkDifferentIfModified(Object modified, Object pristine) { + if (null != modified) { + assertNotEquals(modified, pristine); + } + } + + @FunctionalInterface + private interface CheckMethod { + void checkCompatible(Object modified, Object pristine); + } + + public void checkEqualModifications(ModifyCustomChannelInfoJson modifyInfo, Channel ch) { + checkModifications(modifyInfo, ch, ControllerTestUtils::checkEqualIfModified); + } + + public void checkDifferentModifications(ModifyCustomChannelInfoJson modifyInfo, Channel ch) { + checkModifications(modifyInfo, ch, ControllerTestUtils::checkDifferentIfModified); + } + + private void checkModifications(ModifyCustomChannelInfoJson modifyInfo, Channel ch, CheckMethod checkMethod) { + assertEquals(modifyInfo.getLabel(), ch.getLabel()); + + if (null != modifyInfo.getPeripheralOrgId()) { + checkMethod.checkCompatible(modifyInfo.getPeripheralOrgId(), ch.getOrg().getId()); + } + + checkMethod.checkCompatible(modifyInfo.getOriginalChannelLabel(), ch.getOriginal().getLabel()); + + checkMethod.checkCompatible(modifyInfo.getBaseDir(), ch.getBaseDir()); + checkMethod.checkCompatible(modifyInfo.getName(), ch.getName()); + checkMethod.checkCompatible(modifyInfo.getSummary(), ch.getSummary()); + checkMethod.checkCompatible(modifyInfo.getDescription(), ch.getDescription()); + checkMethod.checkCompatible(modifyInfo.getProductNameLabel(), ch.getProductName().getLabel()); + checkMethod.checkCompatible(modifyInfo.isGpgCheck(), ch.isGPGCheck()); + checkMethod.checkCompatible(modifyInfo.getGpgKeyUrl(), ch.getGPGKeyUrl()); + checkMethod.checkCompatible(modifyInfo.getGpgKeyId(), ch.getGPGKeyId()); + checkMethod.checkCompatible(modifyInfo.getGpgKeyFp(), ch.getGPGKeyFp()); + checkMethod.checkCompatible(modifyInfo.getEndOfLifeDate().toString(), ch.getEndOfLife().toString()); + + checkMethod.checkCompatible(modifyInfo.getChannelProductProduct(), ch.getProduct().getProduct()); + checkMethod.checkCompatible(modifyInfo.getChannelProductVersion(), ch.getProduct().getVersion()); + checkMethod.checkCompatible(modifyInfo.getChannelAccess(), ch.getAccess()); + checkMethod.checkCompatible(modifyInfo.getMaintainerName(), ch.getMaintainerName()); + checkMethod.checkCompatible(modifyInfo.getMaintainerEmail(), ch.getMaintainerEmail()); + checkMethod.checkCompatible(modifyInfo.getMaintainerPhone(), ch.getMaintainerPhone()); + checkMethod.checkCompatible(modifyInfo.getSupportPolicy(), ch.getSupportPolicy()); + checkMethod.checkCompatible(modifyInfo.getUpdateTag(), ch.getUpdateTag()); + checkMethod.checkCompatible(modifyInfo.isInstallerUpdates(), ch.isInstallerUpdates()); + } + + public void createTestChannel(ModifyCustomChannelInfoJson modifyInfo, User userIn) throws Exception { + ChannelFamily cfam = ChannelFamilyFactoryTest.createTestChannelFamily(); + String query = "ChannelArch.findById"; + ChannelArch arch = (ChannelArch) TestUtils.lookupFromCacheById(500L, query); + ChannelFactoryTest.createTestChannel(modifyInfo.getName(), modifyInfo.getLabel(), userIn.getOrg(), arch, cfam); + } +} diff --git a/java/code/src/com/suse/manager/hub/test/HubControllerTest.java b/java/code/src/com/suse/manager/hub/test/HubControllerTest.java new file mode 100644 index 000000000000..8bb44ed79d4c --- /dev/null +++ b/java/code/src/com/suse/manager/hub/test/HubControllerTest.java @@ -0,0 +1,937 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub.test; + +import static com.suse.manager.hub.HubSparkHelper.usingTokenAuthentication; +import static com.suse.manager.webui.utils.SparkApplicationHelper.asJson; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static spark.Spark.post; + +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.channel.ChannelArch; +import com.redhat.rhn.domain.channel.ChannelFactory; +import com.redhat.rhn.domain.channel.ChannelFamily; +import com.redhat.rhn.domain.channel.ChannelProduct; +import com.redhat.rhn.domain.channel.ClonedChannel; +import com.redhat.rhn.domain.channel.ProductName; +import com.redhat.rhn.domain.channel.test.ChannelFactoryTest; +import com.redhat.rhn.domain.channel.test.ChannelFamilyFactoryTest; +import com.redhat.rhn.domain.org.Org; +import com.redhat.rhn.domain.role.RoleFactory; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.xmlrpc.channel.software.ChannelSoftwareHandler; +import com.redhat.rhn.manager.channel.ChannelManager; +import com.redhat.rhn.testing.ChannelTestUtils; +import com.redhat.rhn.testing.ErrataTestUtils; +import com.redhat.rhn.testing.JMockBaseTestCaseWithUser; +import com.redhat.rhn.testing.TestUtils; +import com.redhat.rhn.testing.UserTestUtils; + +import com.suse.manager.hub.HubController; +import com.suse.manager.model.hub.ChannelInfoJson; +import com.suse.manager.model.hub.CustomChannelInfoJson; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.ModifyCustomChannelInfoJson; +import com.suse.manager.model.hub.OrgInfoJson; +import com.suse.manager.webui.utils.gson.ResultJson; +import com.suse.manager.webui.utils.token.IssTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.utils.Json; + +import com.google.gson.JsonObject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import javax.servlet.http.HttpServletResponse; + +import spark.Request; +import spark.Response; +import spark.route.HttpMethod; + +public class HubControllerTest extends JMockBaseTestCaseWithUser { + + private static final String DUMMY_SERVER_FQDN = "dummy-server.unit-test.local"; + + private final ControllerTestUtils testUtils = new ControllerTestUtils(); + + private static final String TEST_ERROR_MESSAGE = "test error message"; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + HubController dummyHubController = new HubController(); + dummyHubController.initRoutes(); + //add dummy route that throws + post("/hub/testThrowsRuntimeException", asJson(usingTokenAuthentication(this::testThrowsRuntimeException))); + } + + private String testThrowsRuntimeException(Request request, Response response, IssAccessToken token) { + throw new NullPointerException(TEST_ERROR_MESSAGE); + } + + private static Stream allApiEndpoints() { + return Stream.of(Arguments.of(HttpMethod.post, "/hub/ping", null), + Arguments.of(HttpMethod.post, "/hub/sync/deregister", null), + Arguments.of(HttpMethod.post, "/hub/sync/registerHub", null), + Arguments.of(HttpMethod.post, "/hub/sync/replaceTokens", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/storeCredentials", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/setHubDetails", IssRole.HUB), + Arguments.of(HttpMethod.get, "/hub/managerinfo", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/storeReportDbCredentials", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/removeReportDbCredentials", IssRole.HUB), + Arguments.of(HttpMethod.get, "/hub/listAllPeripheralOrgs", IssRole.HUB), + Arguments.of(HttpMethod.get, "/hub/listAllPeripheralChannels", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/addVendorChannels", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/addCustomChannels", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/modifyCustomChannels", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/channelfamilies", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/products", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/repositories", IssRole.HUB), + Arguments.of(HttpMethod.post, "/hub/sync/subscriptions", IssRole.HUB) + ); + } + + private static Stream onlyGetApis() { + return allApiEndpoints().filter(e -> (HttpMethod.get == e.get()[0])); + } + + private static Stream onlyPostApis() { + return allApiEndpoints().filter(e -> (HttpMethod.post == e.get()[0])); + } + + private static Stream onlyHubApis() { + return allApiEndpoints().filter(e -> (IssRole.HUB == e.get()[2])); + } + + private static Stream onlyPeripheralApis() { + return allApiEndpoints().filter(e -> (IssRole.PERIPHERAL == e.get()[2])); + } + + @ParameterizedTest + @MethodSource("onlyGetApis") + public void ensureGetApisNotWorkingWithPost(HttpMethod apiMethod, String apiEndpoint, IssRole apiRole) { + assertThrows(IllegalStateException.class, () -> + testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiEndpoint) + .withHttpMethod(HttpMethod.post) + .withRole(apiRole) + .withBearerTokenInHeaders() + .simulateControllerApiCall(), + apiEndpoint + " get API not failing when called with post method"); + } + + @ParameterizedTest + @MethodSource("onlyPostApis") + public void ensurePostApisNotWorkingWithGet(HttpMethod apiMethod, String apiEndpoint, IssRole apiRole) { + assertThrows(IllegalStateException.class, () -> + testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiEndpoint) + .withHttpMethod(HttpMethod.get) + .withRole(apiRole) + .withBearerTokenInHeaders() + .simulateControllerApiCall(), + apiEndpoint + " post API not failing when called with get method"); + } + + @ParameterizedTest + @MethodSource("onlyHubApis") + public void ensureHubApisNotWorkingWithPeripheral(HttpMethod apiMethod, String apiEndpoint, IssRole apiRole) + throws Exception { + String answerKO = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiEndpoint) + .withHttpMethod(apiMethod) + .withRole(IssRole.PERIPHERAL) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + + ResultJson resultKO = Json.GSON.fromJson(answerKO, ResultJson.class); + assertFalse(resultKO.isSuccess(), apiEndpoint + " hub API not failing with peripheral server"); + assertEquals("Token does not allow access to this resource", resultKO.getMessages().get(0)); + } + + //@ParameterizedTest + //@MethodSource("onlyPeripheralApis") + public void ensurePeripheralApisNotWorkingWithHub(HttpMethod apiMethod, String apiEndpoint/*, IssRole apiRole*/) + throws Exception { + String answerKO = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiEndpoint) + .withHttpMethod(apiMethod) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + + ResultJson resultKO = Json.GSON.fromJson(answerKO, ResultJson.class); + assertFalse(resultKO.isSuccess(), apiEndpoint + " peripheral API not failing with hub server"); + assertEquals("Token does not allow access to this resource", resultKO.getMessages().get(0)); + } + + @ParameterizedTest + @MethodSource("allApiEndpoints") + public void ensureNotWorkingWithoutToken(HttpMethod apiMethod, String apiEndpoint, IssRole apiRole) + throws Exception { + try { + testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiEndpoint) + .withHttpMethod(apiMethod) + .withRole(apiRole) + .simulateControllerApiCall(); + + fail(apiEndpoint + " API call should have failed without token"); + } + catch (spark.HaltException ex) { + assertEquals(HttpServletResponse.SC_BAD_REQUEST, ex.statusCode()); + } + } + + @Test + public void checkPingApiEndpoint() throws Exception { + String apiUnderTest = "/hub/ping"; + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + assertTrue(jsonObj.get("message").getAsString().startsWith("Pinged from"), "Unexpected ping message"); + } + + @Test + public void checkRegisterHubApiEndpoint() throws Exception { + String apiUnderTest = "/hub/sync/registerHub"; + + Token dummyToken = new IssTokenBuilder(ConfigDefaults.get().getHostname()).usingServerSecret().build(); + Map bodyMap = new HashMap<>(); + bodyMap.put("rootCA", "----- BEGIN TEST ROOTCA ----"); + bodyMap.put("token", dummyToken.getSerializedForm()); + + String answer = (String) testUtils.withServerFqdn(ConfigDefaults.get().getHostname()) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withBearerTokenInHeaders() + .withBody(bodyMap) + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + //taskomatic is not running! + assertFalse(jsonObj.get("success").getAsBoolean()); + assertEquals("Unable to schedule root CA certificate update", + jsonObj.get("messages").getAsJsonArray().get(0).getAsString(), "Unexpected error message"); + } + + @Test + public void checkStoreCredentialsApiEndpoint() throws Exception { + String apiUnderTest = "/hub/sync/storeCredentials"; + + String testUserName = testUtils.createTestUserName(); + String testPassword = testUtils.createTestPassword(); + Map bodyMap = new HashMap<>(); + bodyMap.put("username", testUserName); + bodyMap.put("password", testPassword); + + String answer = (String) testUtils.withServerFqdn(ConfigDefaults.get().getHostname()) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMap) + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + assertTrue(jsonObj.get("success").getAsBoolean(), apiUnderTest + " API call is failing"); + } + + @Test + public void checkManagerinfoApiEndpoint() throws Exception { + String apiUnderTest = "/hub/managerinfo"; + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.get) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + ManagerInfoJson mgrInfo = Json.GSON.fromJson(answer, ManagerInfoJson.class); + + assertFalse(mgrInfo.getVersion().isBlank(), "ManagerInfo version is blank"); + assertTrue(mgrInfo.hasReportDb(), "ManagerInfo has missing report db"); + assertFalse(mgrInfo.getReportDbName().isBlank(), "ManagerInfo database name is blank"); + assertFalse(mgrInfo.getReportDbHost().isBlank(), "ManagerInfo database host is blank"); + assertEquals(5432, mgrInfo.getReportDbPort(), "ManagerInfo database port is not 5432"); + } + + @Test + public void checkStoreReportDbCredentialsApiEndpoint() throws Exception { + String apiUnderTest = "/hub/storeReportDbCredentials"; + + String testReportDbUserName = testUtils.createTestUserName(); + String testReportDbPassword = testUtils.createTestPassword(); + Map bodyMap = new HashMap<>(); + bodyMap.put("username", testReportDbUserName); + bodyMap.put("password", testReportDbPassword); + + //check there is no user with that username + assertFalse(testUtils.existsReportDbUser(testReportDbUserName)); + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMap) + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + assertTrue(jsonObj.get("success").getAsBoolean(), apiUnderTest + " API call is failing"); + + //check there is one user with that username + assertTrue(testUtils.existsReportDbUser(testReportDbUserName), + apiUnderTest + " API reports no user " + testReportDbUserName); + + //cleanup + testUtils.cleanupReportDbUser(testReportDbUserName); + assertFalse(testUtils.existsReportDbUser(testReportDbUserName), + "cleanup of user not working for user " + testReportDbUserName); + } + + @Test + public void checkRemoveReportDbCredentialsApiEndpoint() throws Exception { + String apiUnderTest = "/hub/removeReportDbCredentials"; + + String testReportDbUserName = testUtils.createTestUserName(); + String testReportDbPassword = "testPassword" + TestUtils.randomString(); + Map bodyMap = new HashMap<>(); + bodyMap.put("username", testReportDbUserName); + + //create a user + testUtils.createReportDbUser(testReportDbUserName, testReportDbPassword); + assertTrue(testUtils.existsReportDbUser(testReportDbUserName), + "failed creation of user " + testReportDbUserName); + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .withBody(bodyMap) + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + assertTrue(jsonObj.get("success").getAsBoolean(), apiUnderTest + " API call is failing"); + //check the user is gone + assertFalse(testUtils.existsReportDbUser(testReportDbUserName), + apiUnderTest + " API call fails to remove user " + testReportDbUserName); + } + + @Test + public void checkApiListAllPeripheralOrgs() throws Exception { + String apiUnderTest = "/hub/listAllPeripheralOrgs"; + + Org org1 = UserTestUtils.findNewOrg("org1"); + Org org2 = UserTestUtils.findNewOrg("org2"); + Org org3 = UserTestUtils.findNewOrg("org3"); + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.get) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + List allOrgs = Arrays.asList(Json.GSON.fromJson(answer, OrgInfoJson[].class)); + + assertTrue(allOrgs.size() >= 3, "All 3 test test orgs are not listed"); + assertTrue(allOrgs.stream() + .anyMatch(e -> (e.getOrgId() == org1.getId()) && (e.getOrgName().startsWith("org1"))), + apiUnderTest + " API call not listing test organization [org1]"); + assertTrue(allOrgs.stream() + .anyMatch(e -> (e.getOrgId() == org2.getId()) && (e.getOrgName().startsWith("org2"))), + apiUnderTest + " API call not listing test organization [org2]"); + assertTrue(allOrgs.stream() + .anyMatch(e -> (e.getOrgId() == org3.getId()) && (e.getOrgName().startsWith("org3"))), + apiUnderTest + " API call not listing test organization [org3]"); + } + + @Test + public void checkApilistAllPeripheralChannels() throws Exception { + String apiUnderTest = "/hub/listAllPeripheralChannels"; + + user.addPermanentRole(RoleFactory.CHANNEL_ADMIN); + Channel testBaseChannel = ChannelTestUtils.createBaseChannel(user); + Channel testChildChannel = ChannelTestUtils.createChildChannel(user, testBaseChannel); + + String answer = (String) testUtils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.get) + .withRole(IssRole.HUB) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + List allChannels = Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + Optional testBaseChannelInfo = allChannels.stream() + .filter(e -> e.getName().equals(testBaseChannel.getName())) + .findAny(); + assertTrue(testBaseChannelInfo.isPresent(), + apiUnderTest + " API call not listing channel " + testBaseChannel); + assertEquals(testBaseChannel.getName(), testBaseChannelInfo.get().getName()); + assertEquals(testBaseChannel.getLabel(), testBaseChannelInfo.get().getLabel()); + assertEquals(user.getOrg().getId(), testBaseChannelInfo.get().getOrgId()); + assertNull(testBaseChannelInfo.get().getParentChannelId()); + + Optional testChildChannelInfo = allChannels.stream() + .filter(e -> e.getName().equals(testChildChannel.getName())) + .findAny(); + assertTrue(testChildChannelInfo.isPresent(), + apiUnderTest + " API call not listing channel " + testChildChannel); + assertEquals(testChildChannel.getName(), testChildChannelInfo.get().getName()); + assertEquals(testChildChannel.getLabel(), testChildChannelInfo.get().getLabel()); + assertEquals(user.getOrg().getId(), testChildChannelInfo.get().getOrgId()); + assertEquals(testBaseChannel.getId(), testChildChannelInfo.get().getParentChannelId()); + } + + @Test + public void checkApiThrowingRuntimeException() throws Exception { + String apiUnderTest = "/hub/testThrowsRuntimeException"; + + ControllerTestUtils utils = new ControllerTestUtils(); + assertDoesNotThrow(() -> utils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.PERIPHERAL) + .withBearerTokenInHeaders() + .simulateControllerApiCall()); + + String answer = (String) utils.withServerFqdn(DUMMY_SERVER_FQDN) + .withApiEndpoint(apiUnderTest) + .withHttpMethod(HttpMethod.post) + .withRole(IssRole.PERIPHERAL) + .withBearerTokenInHeaders() + .simulateControllerApiCall(); + JsonObject jsonObj = Json.GSON.fromJson(answer, JsonObject.class); + + assertFalse(jsonObj.get("success").getAsBoolean(), apiUnderTest + + " API throwing a runtime exception should fail"); + assertEquals("Internal Server Error", jsonObj.get("messages").getAsJsonArray().get(0).getAsString()); + assertEquals(TEST_ERROR_MESSAGE, jsonObj.get("messages").getAsJsonArray().get(1).getAsString()); + } + + private Channel utilityCreateVendorBaseChannel(String name, String label) throws Exception { + Org nullOrg = null; + ChannelFamily cfam = ChannelFamilyFactoryTest.createNullOrgTestChannelFamily(); + String query = "ChannelArch.findById"; + ChannelArch arch = (ChannelArch) TestUtils.lookupFromCacheById(500L, query); + return ChannelFactoryTest.createTestChannel(name, label, nullOrg, arch, cfam); + } + + private Channel utilityCreateVendorChannel(String name, String label, Channel vendorBaseChannel) throws Exception { + Channel vendorChannel = utilityCreateVendorBaseChannel(name, label); + vendorChannel.setParentChannel(vendorBaseChannel); + vendorChannel.setChecksumType(ChannelFactory.findChecksumTypeByLabel("sha512")); + ChannelFactory.save(vendorChannel); + return vendorChannel; + } + + private static Stream allBaseAndVendorChannelAlreadyPresentCombinations() { + return Stream.of(Arguments.of(false, false), + Arguments.of(true, false), + Arguments.of(true, true) + ); + } + + @ParameterizedTest + @MethodSource("allBaseAndVendorChannelAlreadyPresentCombinations") + public void checkApiAddVendorChannel(boolean baseChannelAlreadyPresentInPeripheral, + boolean channelAlreadyPresentInPeripheral) throws Exception { + String apiUnderTest = "/hub/addVendorChannels"; + + //SUSE Linux Enterprise Server 11 SP3 x86_64 + String vendorBaseChannelTemplateName = "SLES11-SP3-Pool for x86_64"; + String vendorBaseChannelTemplateLabel = "sles11-sp3-pool-x86_64"; + String vendorChannelTemplateName = "SLES11-SP3-Updates for x86_64"; + String vendorChannelTemplateLabel = "sles11-sp3-updates-x86_64"; + + String answer = testUtils.createTestVendorChannels(user, DUMMY_SERVER_FQDN, + vendorBaseChannelTemplateName, vendorBaseChannelTemplateLabel, + baseChannelAlreadyPresentInPeripheral, + vendorChannelTemplateName, vendorChannelTemplateLabel, + channelAlreadyPresentInPeripheral); + + int expectedNumOfPeripheralCreatedChannels = 2; + if (baseChannelAlreadyPresentInPeripheral) { + expectedNumOfPeripheralCreatedChannels--; + } + if (channelAlreadyPresentInPeripheral) { + expectedNumOfPeripheralCreatedChannels--; + } + + List peripheralVendorCreatedChannelsInfo = + Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + assertEquals(expectedNumOfPeripheralCreatedChannels, peripheralVendorCreatedChannelsInfo.size()); + + Optional peripheralVendorBaseChannelInfo = + peripheralVendorCreatedChannelsInfo + .stream() + .filter(e -> e.getLabel().equals(vendorBaseChannelTemplateLabel)) + .findAny(); + if (baseChannelAlreadyPresentInPeripheral) { + assertTrue(peripheralVendorBaseChannelInfo.isEmpty(), + String.format("%s API call mistakenly creating base channel %s", + apiUnderTest, vendorBaseChannelTemplateLabel)); + } + else { + assertTrue(peripheralVendorBaseChannelInfo.isPresent(), + String.format("%s API call mistakenly NOT creating base channel %s", + apiUnderTest, vendorBaseChannelTemplateLabel)); + assertEquals(vendorBaseChannelTemplateName, peripheralVendorBaseChannelInfo.get().getName()); + assertEquals(vendorBaseChannelTemplateLabel, peripheralVendorBaseChannelInfo.get().getLabel()); + assertNull(peripheralVendorBaseChannelInfo.get().getOrgId()); + assertNull(peripheralVendorBaseChannelInfo.get().getParentChannelId()); + } + + Optional testChildPeriphChInfo = + peripheralVendorCreatedChannelsInfo + .stream() + .filter(e -> e.getLabel().equals(vendorChannelTemplateLabel)) + .findAny(); + if (channelAlreadyPresentInPeripheral) { + assertTrue(testChildPeriphChInfo.isEmpty(), + String.format("%s API call mistakenly creating vendor channel %s", + apiUnderTest, vendorChannelTemplateLabel)); + } + else { + assertTrue(testChildPeriphChInfo.isPresent(), + String.format("%s API call mistakenly NOT creating vendor channel %s", + apiUnderTest, vendorChannelTemplateLabel)); + + assertEquals(vendorChannelTemplateName, testChildPeriphChInfo.get().getName()); + assertEquals(vendorChannelTemplateLabel, testChildPeriphChInfo.get().getLabel()); + assertNull(testChildPeriphChInfo.get().getOrgId()); + if (baseChannelAlreadyPresentInPeripheral) { + assertTrue(testChildPeriphChInfo.get().getParentChannelId() > 0, + "child channel not having valid parent channel id"); + } + else { + assertEquals(peripheralVendorBaseChannelInfo.get().getId(), + testChildPeriphChInfo.get().getParentChannelId()); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void checkApiAddCustomChannel(boolean testIncludeTestChannelInChain) throws Exception { + boolean testIsGpgCheck = true; + boolean testIssInstallerUpdates = true; + String testArchLabel = "channel-s390"; + String testChecksumLabel = "sha256"; + + Date endOfLifeDate = testUtils.createDateUtil(2096, 10, 22); + + User testPeripheralUser = UserTestUtils.findNewUser("peripheral_user_", "peripheral_org_", true); + Org testPeripheralOrg = testPeripheralUser.getOrg(); + Long testPeripheralOrgId = testPeripheralOrg.getId(); + + // vendorBaseCh -> cloneBaseCh + // ^ ^ + // vendorCh -> cloneDevelCh -> cloneTestCh -> cloneProdCh + + // prepare inputs + String vendorBaseChannelTemplateLabel = "sles11-sp3-pool-x86_64"; //SUSE Linux Enterprise Server 11 SP3 x86_64 + String vendorChannelTemplateLabel = "sles11-sp3-updates-x86_64"; + + CustomChannelInfoJson cloneBaseChInfo = testUtils.createCustomChannelInfoJson(testPeripheralOrgId, + "cloneBaseCh", "", vendorBaseChannelTemplateLabel, + testIsGpgCheck, testIssInstallerUpdates, testArchLabel, testChecksumLabel, endOfLifeDate); + + CustomChannelInfoJson cloneDevelChInfo = testUtils.createCustomChannelInfoJson(testPeripheralOrgId, + "cloneDevelCh", "cloneBaseCh", vendorChannelTemplateLabel, + testIsGpgCheck, testIssInstallerUpdates, testArchLabel, testChecksumLabel, endOfLifeDate); + + CustomChannelInfoJson cloneTestChInfo = null; + String originalOfProdCh = "cloneDevelCh"; + if (testIncludeTestChannelInChain) { + cloneTestChInfo = testUtils.createCustomChannelInfoJson(testPeripheralOrgId, + "cloneTestCh", "", "cloneDevelCh", + testIsGpgCheck, testIssInstallerUpdates, testArchLabel, testChecksumLabel, endOfLifeDate); + originalOfProdCh = "cloneTestCh"; + } + + CustomChannelInfoJson cloneProdChInfo = testUtils.createCustomChannelInfoJson(testPeripheralOrgId, + "cloneProdCh", "", originalOfProdCh, + testIsGpgCheck, testIssInstallerUpdates, testArchLabel, testChecksumLabel, endOfLifeDate); + + testUtils.createTestVendorChannels(user, DUMMY_SERVER_FQDN); + + Channel vendorBaseCh = ChannelFactory.lookupByLabel(vendorBaseChannelTemplateLabel); + assertNotNull(vendorBaseCh); + Channel vendorCh = ChannelFactory.lookupByLabel(vendorChannelTemplateLabel); + assertNotNull(vendorCh); + + //create peripheral vendorCh custom cloned channels + List customChannelInfoListIn = new ArrayList<>(); + customChannelInfoListIn.add(cloneBaseChInfo); + customChannelInfoListIn.add(cloneDevelChInfo); + if (testIncludeTestChannelInChain) { + customChannelInfoListIn.add(cloneTestChInfo); + } + customChannelInfoListIn.add(cloneProdChInfo); + + String answer = (String) testUtils.testAddCustomChannelsApiCall(DUMMY_SERVER_FQDN, customChannelInfoListIn); + List peripheralCreatedCustomChInfo = + Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + if (testIncludeTestChannelInChain) { + assertEquals(4, peripheralCreatedCustomChInfo.size()); + } + else { + assertEquals(3, peripheralCreatedCustomChInfo.size()); + } + assertTrue(peripheralCreatedCustomChInfo.stream() + .anyMatch(e -> e.getLabel().equals("cloneBaseCh"))); + assertTrue(peripheralCreatedCustomChInfo.stream() + .anyMatch(e -> e.getLabel().equals("cloneDevelCh"))); + if (testIncludeTestChannelInChain) { + assertTrue(peripheralCreatedCustomChInfo.stream() + .anyMatch(e -> e.getLabel().equals("cloneTestCh"))); + } + assertTrue(peripheralCreatedCustomChInfo.stream() + .anyMatch(e -> e.getLabel().equals("cloneProdCh"))); + + Channel cloneBaseCh = ChannelFactory.lookupByLabel("cloneBaseCh"); + assertNotNull(cloneBaseCh); + Channel cloneDevelCh = ChannelFactory.lookupByLabel("cloneDevelCh"); + assertNotNull(cloneDevelCh); + Channel cloneTestCh = ChannelFactory.lookupByLabel("cloneTestCh"); + if (testIncludeTestChannelInChain) { + assertNotNull(cloneTestCh); + } + Channel cloneProdCh = ChannelFactory.lookupByLabel("cloneProdCh"); + assertNotNull(cloneProdCh); + + // tests + // vendorBaseCh -> cloneBaseCh + // ^ ^ + // vendorCh -> cloneDevelCh -> cloneTestCh -> cloneProdCh + + assertNull(vendorBaseCh.getParentChannel()); + assertNull(cloneBaseCh.getParentChannel()); + assertEquals(vendorBaseCh, vendorCh.getParentChannel()); + assertEquals(cloneBaseCh, cloneDevelCh.getParentChannel()); + if (testIncludeTestChannelInChain) { + assertNull(cloneTestCh.getParentChannel()); + } + assertNull(cloneProdCh.getParentChannel()); + + assertTrue(vendorBaseCh.asCloned().isEmpty(), "vendorBaseCh should not be a cloned channel"); + assertTrue(cloneBaseCh.asCloned().isPresent(), "cloneBaseCh should be a cloned channel"); + assertTrue(vendorCh.asCloned().isEmpty(), "vendorCh should not be a cloned channel"); + assertTrue(cloneDevelCh.asCloned().isPresent(), "cloneDevelCh should be a cloned channel"); + if (testIncludeTestChannelInChain) { + assertTrue(cloneTestCh.asCloned().isPresent(), "cloneTestCh should be a cloned channel"); + } + assertTrue(cloneProdCh.asCloned().isPresent(), "cloneProdCh should be a cloned channel"); + + assertEquals(vendorBaseCh, cloneBaseCh.asCloned().map(ClonedChannel::getOriginal).orElseThrow()); + assertEquals(vendorCh, cloneDevelCh.asCloned().map(ClonedChannel::getOriginal).orElseThrow()); + if (testIncludeTestChannelInChain) { + assertEquals(cloneDevelCh, cloneTestCh.asCloned().map(ClonedChannel::getOriginal).orElseThrow()); + assertEquals(cloneTestCh, cloneProdCh.asCloned().map(ClonedChannel::getOriginal).orElseThrow()); + } + else { + assertEquals(cloneDevelCh, cloneProdCh.asCloned().map(ClonedChannel::getOriginal).orElseThrow()); + } + + ArrayList channelsToTest = new ArrayList<>(); + channelsToTest.add(cloneBaseCh); + channelsToTest.add(cloneDevelCh); + channelsToTest.add(cloneProdCh); + if (testIncludeTestChannelInChain) { + channelsToTest.add(cloneTestCh); + } + for (Channel ch : channelsToTest) { + testUtils.testCustomChannel(ch, testPeripheralOrgId, + testIsGpgCheck, + testIssInstallerUpdates, + testArchLabel, + testChecksumLabel, + endOfLifeDate); + } + } + + @Test + public void ensureNotThrowingWhenDataIsValid() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + + testUtils.checkAddCustomChannelsApiNotThrowing(DUMMY_SERVER_FQDN, List.of(customChInfo)); + } + + @Test + public void ensureThrowsWhenMissingPeriperhalOrg() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + customChInfo.setPeripheralOrgId(75842L); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(customChInfo), "No org id"); + } + + @Test + public void ensureThrowsWhenMissingChannelArch() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + customChInfo.setChannelArchLabel("channel-dummy-arch"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(customChInfo), "No channel arch"); + } + + @Test + public void ensureThrowsWhenMissingChecksumType() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + customChInfo.setChecksumTypeLabel("sha123456"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(customChInfo), "No checksum type"); + } + + @Test + public void ensureThrowsWhenMissingParentChannel() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + customChInfo.setParentChannelLabel("missingParentChannelLabel"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(customChInfo), "No parent channel"); + } + + @Test + public void ensureNotThrowingWhenParentChannelIsCreatedBefore() throws Exception { + CustomChannelInfoJson customParentChInfo = testUtils.createValidCustomChInfo("parentChannel"); + + CustomChannelInfoJson customChildChInfo = testUtils.createValidCustomChInfo("childChannel"); + customChildChInfo.setParentChannelLabel("parentChannel"); + + testUtils.checkAddCustomChannelsApiNotThrowing(DUMMY_SERVER_FQDN, + Arrays.asList(customParentChInfo, customChildChInfo)); + } + + @Test + public void ensureThrowsWhenParentChannelIsCreatedAfter() throws Exception { + CustomChannelInfoJson customParentChInfo = testUtils.createValidCustomChInfo("parentChannel"); + + CustomChannelInfoJson customChildChInfo = testUtils.createValidCustomChInfo("childChannel"); + customChildChInfo.setParentChannelLabel("parentChannel"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, + Arrays.asList(customChildChInfo, customParentChInfo), "No parent channel"); + } + + @Test + public void ensureThrowsWhenMissingOriginalChannelInClonedChannels() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo(); + + CustomChannelInfoJson clonedCustomChInfo = testUtils.createValidCustomChInfo("clonedCustomCh"); + clonedCustomChInfo.setOriginalChannelLabel(customChInfo.getLabel() + "MISSING"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, + Arrays.asList(customChInfo, clonedCustomChInfo), "No original channel"); + } + + @Test + public void ensureNotThrowingWhenOriginalChannelIsCreatedBefore() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo("originalCustomCh"); + + CustomChannelInfoJson clonedCustomChInfo = testUtils.createValidCustomChInfo("clonedCustomCh"); + clonedCustomChInfo.setOriginalChannelLabel("originalCustomCh"); + + testUtils.checkAddCustomChannelsApiNotThrowing(DUMMY_SERVER_FQDN, + Arrays.asList(customChInfo, clonedCustomChInfo)); + } + + @Test + public void ensureThrowsWhenOriginalChannelIsCreatedAfter() throws Exception { + CustomChannelInfoJson customChInfo = testUtils.createValidCustomChInfo("originalCustomCh"); + + CustomChannelInfoJson clonedCustomChInfo = testUtils.createValidCustomChInfo("clonedCustomCh"); + clonedCustomChInfo.setOriginalChannelLabel("originalCustomCh"); + + testUtils.checkAddCustomChannelsApiThrows(DUMMY_SERVER_FQDN, + Arrays.asList(clonedCustomChInfo, customChInfo), "No original channel"); + } + + @Test + public void checkConversion() throws Exception { + User localUser = UserTestUtils.findNewUser("local_user_", "local_org_", true); + User peripheralUser = UserTestUtils.findNewUser("peripheral_user_", "peripheral_org_", true); + ChannelSoftwareHandler channelSoftwareHandler = new ChannelSoftwareHandler(null, null); + ProductName pn = ChannelFactoryTest.lookupOrCreateProductName(ChannelManager.RHEL_PRODUCT_NAME); + ChannelProduct cp = ErrataTestUtils.createTestChannelProduct(); + + //create vendor channels + String vendorBaseChannelTemplateName = "SLES11-SP3-Pool for x86_64"; + String vendorBaseChannelTemplateLabel = "sles11-sp3-pool-x86_64"; + Channel vendorBaseCh = testUtils.createVendorBaseChannel(vendorBaseChannelTemplateName, + vendorBaseChannelTemplateLabel); + + String vendorChannelTemplateName = "SLES11-SP3-Updates for x86_64"; + String vendorChannelTemplateLabel = "sles11-sp3-updates-x86_64"; + Channel vendorCh = testUtils.createVendorChannel(vendorChannelTemplateName, + vendorChannelTemplateLabel, vendorBaseCh); + vendorCh.setProductName(pn); + vendorCh.setProduct(cp); + vendorCh.setChecksumType(ChannelFactory.findChecksumTypeByLabel("sha512")); + + //test channel: clone of vendorChannel + int testChId = channelSoftwareHandler.clone(localUser, vendorCh.getLabel(), new HashMap<>(), true); + Channel testCh = ChannelFactory.lookupById((long) testChId); + testCh.setProduct(vendorCh.getProduct()); + + //production channel: clone of test channel + int productionChId = channelSoftwareHandler.clone(localUser, testCh.getLabel(), new HashMap<>(), true); + Channel productionCh = ChannelFactory.lookupById((long) productionChId); + productionCh.setProduct(testCh.getProduct()); + + CustomChannelInfoJson testChInfo = ChannelFactory.toCustomChannelInfo(testCh, + peripheralUser.getOrg().getId(), Optional.empty()); + + assertEquals(localUser.getOrg().getId(), testCh.getOrg().getId()); + assertEquals(peripheralUser.getOrg().getId(), testChInfo.getPeripheralOrgId()); + assertEquals("clone-of-sles11-sp3-updates-x86_64", testChInfo.getLabel()); + assertNull(testChInfo.getParentChannelLabel()); + assertEquals(ChannelManager.RHEL_PRODUCT_NAME, testChInfo.getProductNameLabel()); + assertEquals(vendorCh.getProduct().getProduct(), testChInfo.getChannelProductProduct()); + assertEquals(vendorCh.getProduct().getVersion(), testChInfo.getChannelProductVersion()); + assertEquals("sha512", testChInfo.getChecksumTypeLabel()); + assertEquals("sles11-sp3-updates-x86_64", testChInfo.getOriginalChannelLabel()); + + CustomChannelInfoJson productionChInfo = ChannelFactory.toCustomChannelInfo(productionCh, + peripheralUser.getOrg().getId(), Optional.empty()); + + assertEquals(localUser.getOrg().getId(), productionCh.getOrg().getId()); + assertEquals(peripheralUser.getOrg().getId(), productionChInfo.getPeripheralOrgId()); + assertEquals("clone-of-clone-of-sles11-sp3-updates-x86_64", productionChInfo.getLabel()); + assertNull(productionChInfo.getParentChannelLabel()); + assertEquals(ChannelManager.RHEL_PRODUCT_NAME, productionChInfo.getProductNameLabel()); + assertEquals(vendorCh.getProduct().getProduct(), productionChInfo.getChannelProductProduct()); + assertEquals(vendorCh.getProduct().getVersion(), productionChInfo.getChannelProductVersion()); + assertEquals("sha512", productionChInfo.getChecksumTypeLabel()); + assertEquals("clone-of-sles11-sp3-updates-x86_64", productionChInfo.getOriginalChannelLabel()); + } + + @Test + public void checkModifyCustomChannels() throws Exception { + // cloneDevelCh -> cloneTestCh -> cloneProdCh + CustomChannelInfoJson cloneDevelChInfo = testUtils.createValidCustomChInfo("cloneDevelCh"); + + CustomChannelInfoJson cloneTestChInfo = testUtils.createValidCustomChInfo("cloneTestCh"); + cloneTestChInfo.setOriginalChannelLabel("cloneDevelCh"); + + CustomChannelInfoJson cloneProdChInfo = testUtils.createValidCustomChInfo("cloneProdCh"); + cloneProdChInfo.setOriginalChannelLabel("cloneTestCh"); + + testUtils.checkAddCustomChannelsApiNotThrowing(DUMMY_SERVER_FQDN, + Arrays.asList(cloneDevelChInfo, cloneTestChInfo, cloneProdChInfo)); + + Channel cloneDevelCh = ChannelFactory.lookupByLabel("cloneDevelCh"); + assertNotNull(cloneDevelCh); + Channel cloneTestCh = ChannelFactory.lookupByLabel("cloneTestCh"); + assertNotNull(cloneTestCh); + Channel cloneProdCh = ChannelFactory.lookupByLabel("cloneProdCh"); + assertNotNull(cloneProdCh); + + + Date anotherEndOfLifeDate = testUtils.createDateUtil(2042, 4, 2); + User anotherPeripheralUser = UserTestUtils.findNewUser( + "another_peripheral_user_", "another_peripheral_org_", true); + + ModifyCustomChannelInfoJson modifyInfo = new ModifyCustomChannelInfoJson("cloneProdCh"); + modifyInfo.setPeripheralOrgId(anotherPeripheralUser.getOrg().getId()); + modifyInfo.setOriginalChannelLabel("cloneDevelCh"); + + modifyInfo.setBaseDir("baseDir_diff"); + modifyInfo.setName("name_diff"); + modifyInfo.setSummary("summary_diff"); + modifyInfo.setDescription("description_diff"); + modifyInfo.setProductNameLabel("productNameLabel_diff"); + modifyInfo.setGpgCheck(!cloneProdCh.isGPGCheck()); + modifyInfo.setGpgKeyUrl("gpgKeyUrl_diff"); + modifyInfo.setGpgKeyId("gpgKeyId_diff"); + modifyInfo.setGpgKeyFp("gpgKeyFp_diff"); + modifyInfo.setEndOfLifeDate(anotherEndOfLifeDate); + + modifyInfo.setChannelProductProduct("channelProductProduct_diff"); + modifyInfo.setChannelProductVersion("channelProductVersion_diff"); + modifyInfo.setChannelAccess("acc_diff"); //max 10 + modifyInfo.setMaintainerName("maintainerName__diff"); + modifyInfo.setMaintainerEmail("maintainerEmail_diff"); + modifyInfo.setMaintainerPhone("maintainerPhone_diff"); + modifyInfo.setSupportPolicy("supportPolicy_diff"); + modifyInfo.setUpdateTag("updateTag_diff"); + modifyInfo.setInstallerUpdates(!cloneProdCh.isInstallerUpdates()); + + testUtils.checkDifferentModifications(modifyInfo, cloneProdCh); + + String answer = (String) testUtils.testModifyCustomChannelsApiCall(DUMMY_SERVER_FQDN, List.of(modifyInfo)); + List peripheralModifiedCustomChInfo = + Arrays.asList(Json.GSON.fromJson(answer, ChannelInfoJson[].class)); + + assertEquals(1, peripheralModifiedCustomChInfo.size()); + testUtils.checkEqualModifications(modifyInfo, cloneProdCh); + } + + @Test + public void ensureNotThrowingWhenModifyingDataIsValid() throws Exception { + ModifyCustomChannelInfoJson modifyInfo = testUtils.createValidModifyCustomChInfo("customCh"); + testUtils.createTestChannel(modifyInfo, user); + + testUtils.checkModifyCustomChannelsApiNotThrowing(DUMMY_SERVER_FQDN, List.of(modifyInfo)); + } + + @Test + public void ensureThrowsWhenModifyingMissingPeriperhalOrg() throws Exception { + ModifyCustomChannelInfoJson modifyInfo = testUtils.createValidModifyCustomChInfo("customCh"); + testUtils.createTestChannel(modifyInfo, user); + + modifyInfo.setPeripheralOrgId(75842L); + + testUtils.checkModifyCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(modifyInfo), "No org id"); + } + + @Test + public void ensureThrowsWhenModifyingMissingOriginalChannelInClonedChannels() throws Exception { + ModifyCustomChannelInfoJson modifyInfo = testUtils.createValidModifyCustomChInfo("customCh"); + testUtils.createTestChannel(modifyInfo, user); + + modifyInfo.setOriginalChannelLabel(modifyInfo.getLabel() + "MISSING"); + + testUtils.checkModifyCustomChannelsApiThrows(DUMMY_SERVER_FQDN, List.of(modifyInfo), "No original channel"); + } +} diff --git a/java/code/src/com/suse/manager/hub/test/HubManagerTest.java b/java/code/src/com/suse/manager/hub/test/HubManagerTest.java new file mode 100644 index 000000000000..d07b62fa36d6 --- /dev/null +++ b/java/code/src/com/suse/manager/hub/test/HubManagerTest.java @@ -0,0 +1,893 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.hub.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.common.hibernate.HibernateFactory; +import com.redhat.rhn.common.security.PermissionException; +import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; +import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.redhat.rhn.domain.role.RoleFactory; +import com.redhat.rhn.domain.server.MgrServerInfo; +import com.redhat.rhn.domain.server.Server; +import com.redhat.rhn.domain.server.ServerFactory; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.domain.user.UserFactory; +import com.redhat.rhn.manager.entitlement.EntitlementManager; +import com.redhat.rhn.manager.formula.FormulaMonitoringManager; +import com.redhat.rhn.manager.setup.MirrorCredentialsManager; +import com.redhat.rhn.manager.system.ServerGroupManager; +import com.redhat.rhn.manager.system.entitling.SystemEntitlementManager; +import com.redhat.rhn.manager.system.entitling.SystemEntitler; +import com.redhat.rhn.manager.system.entitling.SystemUnentitler; +import com.redhat.rhn.taskomatic.TaskomaticApi; +import com.redhat.rhn.taskomatic.TaskomaticApiException; +import com.redhat.rhn.testing.JMockBaseTestCaseWithUser; +import com.redhat.rhn.testing.UserTestUtils; + +import com.suse.manager.hub.HubClientFactory; +import com.suse.manager.hub.HubExternalClient; +import com.suse.manager.hub.HubInternalClient; +import com.suse.manager.hub.HubManager; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.IssServer; +import com.suse.manager.model.hub.ManagerInfoJson; +import com.suse.manager.model.hub.TokenType; +import com.suse.manager.webui.services.iface.MonitoringManager; +import com.suse.manager.webui.services.iface.SaltApi; +import com.suse.manager.webui.services.test.TestSaltApi; +import com.suse.manager.webui.utils.token.IssTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenParser; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import org.jmock.Expectations; +import org.jmock.imposters.ByteBuddyClassImposteriser; +import org.jose4j.jwt.consumer.ErrorCodes; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.security.cert.CertificateException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class HubManagerTest extends JMockBaseTestCaseWithUser { + + private static final String LOCAL_SERVER_FQDN = "local-server.unit-test.local"; + + private static final String REMOTE_SERVER_FQDN = "remote-server.unit-test.local"; + + private User satAdmin; + + private HubFactory hubFactory; + + private HubManager hubManager; + + private HubClientFactory clientFactoryMock; + + private String originalServerSecret; + + private String originalFqdn; + + private String originalReportDb; + + private String originalProductVersion; + + static class MockTaskomaticApi extends TaskomaticApi { + private int invoked; + private List invokeNames; + private List invokeBunches; + private String invokeRootCaFilename; + private String invokeRootCaContent; + private String invokeGpgKeyContent; + private int expectedInvocations; + private List expectedInvocationNames; + private List expectedInvocationBunches; + private String expectedRootCaFilename; + private String expectedRootCaContent; + private String expectedGpgKeyContent; + + MockTaskomaticApi() { + resetTaskomaticCall(); + } + + public void resetTaskomaticCall() { + invoked = 0; + invokeNames = new ArrayList<>(); + invokeBunches = new ArrayList<>(); + invokeRootCaFilename = null; + invokeRootCaContent = null; + invokeGpgKeyContent = null; + } + + public void setExpectations(int expectedInvocationsIn, + List expectedInvocationNamesIn, + List expectedInvocationBunchesIn, + String expectedRootCaFilenameIn, + String expectedRootCaContentIn, + String expectedGpgKeyContentIn) { + expectedInvocations = expectedInvocationsIn; + expectedInvocationNames = expectedInvocationNamesIn; + expectedInvocationBunches = expectedInvocationBunchesIn; + expectedRootCaFilename = expectedRootCaFilenameIn; + expectedRootCaContent = expectedRootCaContentIn; + expectedGpgKeyContent = expectedGpgKeyContentIn; + } + + public void verifyTaskoCall() { + assertEquals(expectedInvocations, invoked); + assertEquals(expectedInvocationNames, invokeNames); + assertEquals(expectedInvocationBunches, invokeBunches); + for (String bunch : invokeBunches) { + if (bunch.equals("root-ca-cert-update-bunch")) { + assertEquals(expectedRootCaFilename, invokeRootCaFilename); + assertEquals(expectedRootCaContent, invokeRootCaContent); + } + else if (bunch.equals("custom-gpg-key-import-bunch")) { + assertEquals(expectedGpgKeyContent, invokeGpgKeyContent); + } + else { + fail("Unexpected bunch called: " + bunch); + } + } + } + + @Override + protected Object invoke(String name, Object... args) throws TaskomaticApiException { + invoked += 1; + invokeNames.add(name); + String bunch = (String) args[0]; + invokeBunches.add(bunch); + Map paramList = (Map) args[1]; + if (bunch.equals("root-ca-cert-update-bunch")) { + Map fileToCaCertMap = + (Map) paramList.get("filename_to_root_ca_cert_map"); + Optional> firstKeyVal = fileToCaCertMap.entrySet().stream().findFirst(); + if (firstKeyVal.isPresent()) { + invokeRootCaFilename = firstKeyVal.get().getKey(); + invokeRootCaContent = firstKeyVal.get().getValue(); + } + else { + invokeRootCaFilename = null; + invokeRootCaContent = null; + } + } + else if (bunch.equals("custom-gpg-key-import-bunch")) { + invokeGpgKeyContent = (String) paramList.get("gpg-key"); + } + return null; + } + } + + private MockTaskomaticApi mockTaskomaticApi; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + + satAdmin = UserTestUtils.createUser("satUser", user.getOrg().getId()); + satAdmin.addPermanentRole(RoleFactory.SAT_ADMIN); + UserFactory.save(satAdmin); + + setImposteriser(ByteBuddyClassImposteriser.INSTANCE); + + // Setting a fake hostname for the token validation + originalFqdn = ConfigDefaults.get().getHostname(); + originalServerSecret = Config.get().getString("server.secret_key"); + originalReportDb = Config.get().getString(ConfigDefaults.REPORT_DB_NAME); + originalProductVersion = Config.get().getString(ConfigDefaults.PRODUCT_VERSION_MGR); + + Config.get().setString(ConfigDefaults.SERVER_HOSTNAME, LOCAL_SERVER_FQDN); + Config.get().setString("server.secret_key", // my-super-secret-key-for-testing + "6D792D73757065722D7365637265742D6B65792D666F722D74657374696E670D0A"); + + Config.get().setString(ConfigDefaults.REPORT_DB_NAME, "reportdb"); + Config.get().setString(ConfigDefaults.PRODUCT_VERSION_MGR, "5.1.0"); + + hubFactory = new HubFactory(); + clientFactoryMock = mock(HubClientFactory.class); + + mockTaskomaticApi = new MockTaskomaticApi(); + SaltApi saltApi = new TestSaltApi(); + MonitoringManager monitoringManager = new FormulaMonitoringManager(saltApi); + ServerGroupManager serverGroupManager = new ServerGroupManager(saltApi); + SystemEntitlementManager sysEntMgr = new SystemEntitlementManager( + new SystemUnentitler(monitoringManager, serverGroupManager), + new SystemEntitler(saltApi, monitoringManager, serverGroupManager) + ); + + hubManager = new HubManager(hubFactory, clientFactoryMock, new MirrorCredentialsManager(), mockTaskomaticApi, + sysEntMgr); + } + + @AfterEach + @Override + public void tearDown() throws Exception { + super.tearDown(); + + Config.get().setString(ConfigDefaults.SERVER_HOSTNAME, originalFqdn); + Config.get().setString("server.secret_key", originalServerSecret); + Config.get().setString(ConfigDefaults.REPORT_DB_NAME, originalReportDb); + Config.get().setString(ConfigDefaults.PRODUCT_VERSION_MGR, originalProductVersion); + } + + /** + * This test ensure the implementation does not have any public method that are not requiring either a user + * or a token to enforce authorization. + */ + @Test + public void implementationDoesNotHaveAnyPublicUnprotectedMethods() { + List publicClassMethods = Arrays.stream(hubManager.getClass().getMethods()) + // Exclude methods declared by Object + .filter(method -> !Object.class.equals(method.getDeclaringClass())) + // Exclude non-public methods + .filter(method -> Modifier.isPublic(method.getModifiers())) + .toList(); + + // First parameter of the method must be either a User or an IssAccessToken + List> allowedFirstParameters = List.of(User.class, IssAccessToken.class); + + // Extract all methods that don't have a valid first parameter + List unprotectedMethods = publicClassMethods.stream() + .filter(method -> method.getParameterTypes().length == 0 || + !allowedFirstParameters.contains(method.getParameterTypes()[0])) + .map(Method::toGenericString) + .toList(); + + assertTrue(unprotectedMethods.isEmpty(), + "These methods seem to not enforce authorization, as the first parameter is not any of %s:%n\t%s" + .formatted(allowedFirstParameters, String.join("\n\t", unprotectedMethods)) + ); + + for (Method method : publicClassMethods) { + // Generate default values for all parameters except the first one. Since the method should fail due to + // validation the other parameters should be irrelevant + List params = Arrays.stream(method.getParameterTypes()) + .skip(1) + .map(paramType -> getDefaultValue(paramType)) + .collect(Collectors.toList()); + + Class firstParameterClass = method.getParameterTypes()[0]; + String expectedMessage = "You do not have permissions to perform this action. "; + + IssAccessToken expiredToken = new IssAccessToken( + TokenType.ISSUED, + "dummy", + "my.remote.server", + Instant.now().minus(30, ChronoUnit.DAYS) + ); + + if (User.class.equals(firstParameterClass)) { + params.add(0, user); + expectedMessage += "You need to have at least a SUSE Manager Administrator role to perform this action"; + } + else if (IssAccessToken.class.equals(firstParameterClass)) { + params.add(0, expiredToken); + expectedMessage += "Invalid token provided"; + } + else { + fail("Unable to identify a value for the first parameter type " + firstParameterClass); + // appeasing the compiler: this line will never be executed. + return; + } + + // Try to invoke the method. It should fail with a permission exception. Since we are using reflection + // it will be wrapped into an InvocationTargetException + InvocationTargetException wrapperException = assertThrows(InvocationTargetException.class, + () -> method.invoke(hubManager, params.toArray())); + + // Verify the actual exception is correct + assertInstanceOf(PermissionException.class, wrapperException.getCause(), + "Method " + method.toGenericString() + " is throwing an unexpected Exception"); + assertEquals(expectedMessage, wrapperException.getCause().getMessage(), + "Method " + method.toGenericString() + " is throwing an unexpected exception message"); + + } + } + + @Test + public void canIssueANewToken() throws Exception { + Instant expectedExpiration = Instant.now().truncatedTo(ChronoUnit.SECONDS).plus(525_600L, ChronoUnit.MINUTES); + String token = hubManager.issueAccessToken(satAdmin, REMOTE_SERVER_FQDN); + + // Ensure we get a token + assertNotNull(token); + + // Ensure the token is correctly stored in the database + IssAccessToken issAccessToken = hubFactory.lookupIssuedToken(token); + assertNotNull(issAccessToken); + assertEquals(REMOTE_SERVER_FQDN, issAccessToken.getServerFqdn()); + assertEquals(TokenType.ISSUED, issAccessToken.getType()); + assertFalse(issAccessToken.isExpired()); + assertTrue(issAccessToken.isValid()); + assertEquals( + expectedExpiration, + issAccessToken.getExpirationDate().toInstant().truncatedTo(ChronoUnit.SECONDS) + ); + + // Ensure we can decode the token correctly + Token parsedJwtToken = new TokenParser() + .usingServerSecret() + .parse(token); + + assertEquals(expectedExpiration, parsedJwtToken.getExpirationTime()); + assertEquals(REMOTE_SERVER_FQDN, parsedJwtToken.getClaim("fqdn", String.class)); + } + + @Test + public void canStoreThirdPartyToken() throws Exception { + // This token has the following jwt payload: + // { + // "jti" : "ftA6zJs1kBd7eGbGqeVKPQ", + // "exp" : 3313569600, + // "iat" : 1733393426, + // "nbf" : 1733393306, + // "fqdn" : "local-server.unit-test.local" (aka LOCAL_SERVER_FQDN) + // } + String token = """ + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey\ + JqdGkiOiJmdEE2ekpzMWtCZDdlR2JHcWVWS1BRI\ + iwiZXhwIjozMzEzNTY5NjAwLCJpYXQiOjE3MzMz\ + OTM0MjYsIm5iZiI6MTczMzM5MzMwNiwiZnFkbiI\ + 6ImxvY2FsLXNlcnZlci51bml0LXRlc3QubG9jYW\ + wifQ.6M9MOQvsiFr4EeyeAo36_2jUbV1Ju9ceCD\ + kI-mykFms"""; + + hubManager.storeAccessToken(satAdmin, REMOTE_SERVER_FQDN, token); + + // Ensure the token is correctly stored in the database + IssAccessToken issAccessToken = hubFactory.lookupAccessTokenFor(REMOTE_SERVER_FQDN); + assertNotNull(issAccessToken); + assertEquals(token, issAccessToken.getToken()); + assertEquals(TokenType.CONSUMED, issAccessToken.getType()); + assertNotNull(issAccessToken.getExpirationDate()); + } + + @Test + public void rejectsTokenIfFqdnIsNotMatching() { + // This token has the following jwt payload: + // { + // "jti" : "ftA6zJs1kBd7eGbGqeVKPQ", + // "exp" : 3313569600, + // "iat" : 1733393426, + // "nbf" : 1733393306, + // "fqdn" : "different-server.unit-test.local" + // } + + String token = """ + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey\ + JqdGkiOiJmdEE2ekpzMWtCZDdlR2JHcWVWS1BRI\ + iwiZXhwIjozMzEzNTY5NjAwLCJpYXQiOjE3MzMz\ + OTM0MjYsIm5iZiI6MTczMzM5MzMwNiwiZnFkbiI\ + 6ImRpZmZlcmVudC1zZXJ2ZXIudW5pdC10ZXN0Lm\ + xvY2FsIn0.GaW8A54CrfUI43MiCDw_7TPCUeL8l\ + TU76L5Yn9bXHGs"""; + + var exception = assertThrows( + TokenParsingException.class, + () -> hubManager.storeAccessToken(satAdmin, REMOTE_SERVER_FQDN, token) + ); + + assertEquals( + "FQDN do not match. Expected " + LOCAL_SERVER_FQDN + " got different-server.unit-test.local", + exception.getMessage() + ); + } + + @Test + public void rejectsTokenIfAlreadyExpired() { + // This token has the following jwt payload: + // { + // "jti" : "ftA6zJs1kBd7eGbGqeVKPQ", + // "exp": 1607249426, + // "iat": 1575713426, + // "nbf": 1575713306, + // "fqdn" : "local-server.unit-test.local" (aka LOCAL_SERVER_FQDN) + // } + + String token = """ + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey\ + JqdGkiOiJmdEE2ekpzMWtCZDdlR2JHcWVWS1BRI\ + iwiZXhwIjoxNjA3MjQ5NDI2LCJpYXQiOjE1NzU3\ + MTM0MjYsIm5iZiI6MTU3NTcxMzMwNiwiZnFkbiI\ + 6ImxvY2FsLXNlcnZlci51bml0LXRlc3QubG9jYW\ + wifQ.CWuFZX8KKFQ3Le7Qn0kGE_16ZCNs7QrneL\ + ajQABZuAw"""; + + TokenParsingException exception = assertThrows( + TokenParsingException.class, + () -> hubManager.storeAccessToken(satAdmin, "external-server.dev.local", token) + ); + + InvalidJwtException cause = assertInstanceOf(InvalidJwtException.class, exception.getCause()); + assertEquals(1, cause.getErrorDetails().size()); + assertEquals(ErrorCodes.EXPIRED, cause.getErrorDetails().get(0).getErrorCode()); + } + + @Test + public void canSaveHubAndPeripheralServers() throws TaskomaticApiException { + hubManager.saveNewServer(getValidToken("dummy.hub.fqdn"), IssRole.HUB, "dummy-certificate-data", + "dummy-gpg-key"); + + Optional issHub = hubFactory.lookupIssHubByFqdn("dummy.hub.fqdn"); + assertTrue(issHub.isPresent()); + assertEquals("dummy-certificate-data", issHub.get().getRootCa()); + assertEquals("dummy-gpg-key", issHub.get().getGpgKey()); + + hubManager.saveNewServer(getValidToken("dummy.peripheral.fqdn"), IssRole.PERIPHERAL, null, null); + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn("dummy.peripheral.fqdn"); + assertTrue(issPeripheral.isPresent()); + assertNull(issPeripheral.get().getRootCa()); + } + + @Test + public void canRetrieveHubAndPeripheralServers() { + hubFactory.save(new IssHub("dummy.hub.fqdn", null)); + hubFactory.save(new IssPeripheral("dummy.peripheral.fqdn", null)); + + IssServer result = hubManager.findServer(getValidToken("dummy.peripheral.fqdn"), IssRole.PERIPHERAL); + assertNotNull(result); + assertInstanceOf(IssPeripheral.class, result); + + result = hubManager.findServer(getValidToken("dummy.hub.fqdn"), IssRole.HUB); + assertNotNull(result); + assertInstanceOf(IssHub.class, result); + + result = hubManager.findServer(getValidToken("dummy.unknown.fqdn"), IssRole.HUB); + assertNull(result); + } + + @Test + public void canUpdateServer() { + hubFactory.save(new IssHub("dummy.hub.fqdn", null)); + hubFactory.save(new IssPeripheral("dummy.peripheral.fqdn", null)); + + IssHub hub = (IssHub) hubManager.findServer(getValidToken("dummy.hub.fqdn"), IssRole.HUB); + assertNull(hub.getRootCa()); + assertNull(hub.getMirrorCredentials()); + + SCCCredentials sccCredentials = CredentialsFactory.createSCCCredentials("user", "password"); + CredentialsFactory.storeCredentials(sccCredentials); + + hub.setMirrorCredentials(sccCredentials); + hub.setRootCa("--DUMMY--"); + + Optional issHub = hubFactory.lookupIssHubByFqdn("dummy.hub.fqdn"); + assertTrue(issHub.isPresent()); + assertEquals("--DUMMY--", issHub.get().getRootCa()); + assertNotNull(issHub.get().getMirrorCredentials()); + assertEquals("user", issHub.get().getMirrorCredentials().getUsername()); + assertEquals("password", issHub.get().getMirrorCredentials().getPassword()); + } + + @Test + public void canSaveRootCaAndGpg() throws TaskomaticApiException { + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(2, + List.of("tasko.scheduleSingleSatBunchRun", "tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch", "custom-gpg-key-import-bunch"), + "hub_dummy.hub.fqdn_root_ca.pem", "dummy-hub-certificate-data", + "dummy-gpg-key"); + hubManager.saveNewServer(getValidToken("dummy.hub.fqdn"), IssRole.HUB, "dummy-hub-certificate-data", + "dummy-gpg-key"); + mockTaskomaticApi.verifyTaskoCall(); + + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(1, + List.of("tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch"), + "hub_dummy2.hub.fqdn_root_ca.pem", "", ""); + hubManager.saveNewServer(getValidToken("dummy2.hub.fqdn"), IssRole.HUB, "", ""); + mockTaskomaticApi.verifyTaskoCall(); + + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(1, + List.of("tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch"), + "hub_dummy3.hub.fqdn_root_ca.pem", "", ""); + hubManager.saveNewServer(getValidToken("dummy3.hub.fqdn"), IssRole.HUB, null, null); + mockTaskomaticApi.verifyTaskoCall(); + + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(1, + List.of("tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch"), + "peripheral_dummy.periph.fqdn_root_ca.pem", + "dummy-periph-certificate-data", null); + hubManager.saveNewServer(getValidToken("dummy.periph.fqdn"), IssRole.PERIPHERAL, + "dummy-periph-certificate-data", null); + mockTaskomaticApi.verifyTaskoCall(); + + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(1, + List.of("tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch"), + "peripheral_dummy2.periph.fqdn_root_ca.pem", "", ""); + hubManager.saveNewServer(getValidToken("dummy2.periph.fqdn"), IssRole.PERIPHERAL, "", ""); + mockTaskomaticApi.verifyTaskoCall(); + + mockTaskomaticApi.resetTaskomaticCall(); + mockTaskomaticApi.setExpectations(1, + List.of("tasko.scheduleSingleSatBunchRun"), + List.of("root-ca-cert-update-bunch"), + "peripheral_dummy3.periph.fqdn_root_ca.pem", "", ""); + hubManager.saveNewServer(getValidToken("dummy3.periph.fqdn"), IssRole.PERIPHERAL, null, null); + mockTaskomaticApi.verifyTaskoCall(); + } + + @Test + public void canGenerateSCCCredentials() throws TaskomaticApiException { + String peripheralFqdn = "dummy.peripheral.fqdn"; + + IssAccessToken peripheralToken = getValidToken(peripheralFqdn); + var peripheral = (IssPeripheral) hubManager.saveNewServer(peripheralToken, IssRole.PERIPHERAL, null, null); + + // Ensure no credentials exists + assertEquals(0, CredentialsFactory.listCredentialsByType(HubSCCCredentials.class).stream() + .filter(creds -> peripheralFqdn.equals(creds.getPeripheralUrl())) + .count()); + + HubSCCCredentials hubSCCCredentials = hubManager.generateSCCCredentials(peripheralToken); + assertEquals("peripheral-%06d".formatted(peripheral.getId()), hubSCCCredentials.getUsername()); + assertNotNull(hubSCCCredentials.getPassword()); + assertEquals(peripheralFqdn, hubSCCCredentials.getPeripheralUrl()); + } + + @Test + public void canStoreSCCCredentials() throws TaskomaticApiException { + IssAccessToken hubToken = getValidToken("dummy.hub.fqdn"); + hubManager.saveNewServer(hubToken, IssRole.HUB, null, null); + + // Ensure no credentials exists + assertEquals(0, CredentialsFactory.listSCCCredentials().stream() + .filter(creds -> "https://dummy.hub.fqdn".equals(creds.getUrl())) + .count()); + + SCCCredentials sccCredentials = hubManager.storeSCCCredentials(hubToken, "dummy-username", "dummy-password"); + assertEquals("dummy-username", sccCredentials.getUsername()); + assertEquals("dummy-password", sccCredentials.getPassword()); + assertEquals("https://dummy.hub.fqdn", sccCredentials.getUrl()); + } + + @Test + public void canDeregisterHub() throws TokenBuildingException, TaskomaticApiException, TokenParsingException { + String fqdn = LOCAL_SERVER_FQDN; + createHubRegistration(fqdn, null, null); + hubManager.deleteIssServerLocal(satAdmin, fqdn); + + assertNull(hubFactory.lookupAccessTokenFor(fqdn)); + assertNull(hubFactory.lookupIssuedToken(fqdn)); + assertTrue(hubFactory.lookupIssHub().isEmpty(), "Failed to remove Hub"); + assertEquals(0, CredentialsFactory.listSCCCredentials().size()); + } + + @Test + public void canDeregisterPeripheral() throws TokenBuildingException, TaskomaticApiException, TokenParsingException { + String fqdn = LOCAL_SERVER_FQDN; + createPeripheralRegistration(fqdn, null); + hubManager.deleteIssServerLocal(satAdmin, fqdn); + + assertNull(hubFactory.lookupAccessTokenFor(fqdn)); + assertNull(hubFactory.lookupIssuedToken(fqdn)); + assertTrue(hubFactory.lookupIssPeripheralByFqdn(fqdn).isEmpty(), "Failed to remove Peripheral"); + assertEquals(0, CredentialsFactory.listCredentialsByType(HubSCCCredentials.class).size()); + } + + @Test + public void canDeregisterHubWithToken() throws TokenBuildingException, TaskomaticApiException, + TokenParsingException { + String fqdn = LOCAL_SERVER_FQDN; + IssAccessToken token = createHubRegistration(fqdn, null, null); + hubManager.deleteIssServerLocal(token, fqdn); + + assertNull(hubFactory.lookupAccessTokenFor(fqdn)); + assertNull(hubFactory.lookupIssuedToken(fqdn)); + assertTrue(hubFactory.lookupIssHub().isEmpty(), "Failed to remove Hub"); + assertEquals(0, CredentialsFactory.listSCCCredentials().size()); + } + + @Test + public void canDeregisterPeripheralWithToken() throws TokenBuildingException, TaskomaticApiException, + TokenParsingException { + String fqdn = LOCAL_SERVER_FQDN; + IssAccessToken token = createPeripheralRegistration(fqdn, null); + hubManager.deleteIssServerLocal(token, fqdn); + + assertNull(hubFactory.lookupAccessTokenFor(fqdn)); + assertNull(hubFactory.lookupIssuedToken(fqdn)); + assertTrue(hubFactory.lookupIssPeripheralByFqdn(fqdn).isEmpty(), "Failed to remove Peripheral"); + assertEquals(0, CredentialsFactory.listCredentialsByType(HubSCCCredentials.class).size()); + } + + @Test + public void canRegisterPeripheralWithUserNameAndPassword() + throws TokenBuildingException, CertificateException, IOException, TokenParsingException, + TaskomaticApiException { + HubExternalClient externalClient = mock(HubExternalClient.class); + HubInternalClient internalClient = mock(HubInternalClient.class); + + // The token generated by REMOTE_SERVER_FQDN for LOCAL_SERVER_FQDN + String remoteTokenForLocal = """ + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey\ + JqdGkiOiJmdEE2ekpzMWtCZDdlR2JHcWVWS1BRI\ + iwiZXhwIjozMzEzNTY5NjAwLCJpYXQiOjE3MzMz\ + OTM0MjYsIm5iZiI6MTczMzM5MzMwNiwiZnFkbiI\ + 6ImxvY2FsLXNlcnZlci51bml0LXRlc3QubG9jYW\ + wifQ.XldOvQJypIkb4E1am0JlBxsHYrx_7J77s1\ + vTrvoNlEU"""; + + ManagerInfoJson mgrInfo = new ManagerInfoJson("5.1.0", true, "reportdb", REMOTE_SERVER_FQDN, 5432); + + context().checking(new Expectations() {{ + allowing(clientFactoryMock).newExternalClient(REMOTE_SERVER_FQDN, "admin", "admin", null); + will(returnValue(externalClient)); + + allowing(externalClient).generateAccessToken(LOCAL_SERVER_FQDN); + will(returnValue(remoteTokenForLocal)); + + allowing(externalClient).close(); + + allowing(clientFactoryMock).newInternalClient(REMOTE_SERVER_FQDN, remoteTokenForLocal, null); + will(returnValue(internalClient)); + + allowing(internalClient).registerHub( + with(any(String.class)), + with(aNull(String.class)), + with(aNull(String.class)) + ); + + allowing(internalClient).storeCredentials(with(any(String.class)), with(any(String.class))); + + allowing(internalClient).getManagerInfo(); + will(returnValue(mgrInfo)); + + allowing(internalClient).storeReportDbCredentials(with(any(String.class)), with(any(String.class))); + }}); + + // Register the remote server as PERIPHERAL for this local server + hubManager.register(satAdmin, REMOTE_SERVER_FQDN, "admin", "admin", null); + + // Verify the remote server is saved as peripheral + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn(REMOTE_SERVER_FQDN); + assertTrue(issPeripheral.isPresent()); + + // Verify we have both tokens for the remote server + var consumed = hubFactory.lookupAccessTokenByFqdnAndType(REMOTE_SERVER_FQDN, TokenType.CONSUMED); + assertNotNull(consumed); + assertEquals(remoteTokenForLocal, consumed.getToken()); + + var issued = hubFactory.lookupAccessTokenByFqdnAndType(REMOTE_SERVER_FQDN, TokenType.ISSUED); + assertNotNull(issued); + + Optional optServer = ServerFactory.findByFqdn(REMOTE_SERVER_FQDN); + if (optServer.isPresent()) { + Server srv = optServer.get(); + MgrServerInfo mgrServerInfo = srv.getMgrServerInfo(); + assertEquals(mgrInfo.getReportDbName(), mgrServerInfo.getReportDbName()); + assertEquals(mgrInfo.getVersion(), mgrServerInfo.getVersion().getVersion()); + assertTrue(srv.hasEntitlement(EntitlementManager.FOREIGN)); + } + else { + fail("Server not found"); + } + } + + @Test + public void canReplaceTokensLocal() throws TokenBuildingException, TaskomaticApiException, TokenParsingException { + String hubFqdn = "hub.domain.top"; + + IssAccessToken currentToken = createHubRegistration(hubFqdn, null, null); + List tokenList = hubFactory.listAccessTokensByFqdn(hubFqdn) + .stream().map(IssAccessToken::getToken).toList(); + assertEquals(2, tokenList.size()); + assertTrue(tokenList.contains(currentToken.getToken()), "Expected token not found"); + + Token token = new IssTokenBuilder(hubFqdn) + .usingServerSecret() + .build(); + IssAccessToken newHubToken = new IssAccessToken(TokenType.ISSUED, token.getSerializedForm(), hubFqdn, + token.getExpirationTime()); + assertNotEquals(currentToken.getToken(), newHubToken.getToken()); + + String newRemoteToken = hubManager.replaceTokens(currentToken, newHubToken.getToken()); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + assertNotNull(newRemoteToken); + assertTrue(tokenList.stream().noneMatch(t -> t.equals(newRemoteToken))); + assertNotEquals(currentToken.getToken(), newRemoteToken); + + List newTokenList = hubFactory.listAccessTokensByFqdn(hubFqdn) + .stream().map(IssAccessToken::getToken).toList(); + assertEquals(2, newTokenList.size()); + assertNotEquals(tokenList, newTokenList); + } + + @Test + public void canReplaceTokensOnHub() throws TokenBuildingException, TaskomaticApiException, TokenParsingException, + CertificateException, IOException { + String peripherlaFqdn = "peripheral.domain.top"; + HubInternalClient internalClient = mock(HubInternalClient.class); + + IssAccessToken currentToken = createPeripheralRegistration(peripherlaFqdn, null); + List tokenList = hubFactory.listAccessTokensByFqdn(peripherlaFqdn) + .stream().map(IssAccessToken::getToken).toList(); + assertEquals(2, tokenList.size()); + assertTrue(tokenList.contains(currentToken.getToken()), "Expected token not found"); + + Token token = new IssTokenBuilder(peripherlaFqdn) + .usingServerSecret() + .build(); + IssAccessToken newHubToken = new IssAccessToken(TokenType.ISSUED, token.getSerializedForm(), peripherlaFqdn, + token.getExpirationTime()); + assertNotEquals(currentToken.getToken(), newHubToken.getToken()); + + context().checking(new Expectations() {{ + allowing(clientFactoryMock).newInternalClient(peripherlaFqdn, currentToken.getToken(), null); + will(returnValue(internalClient)); + + allowing(internalClient).replaceTokens(with(any(String.class))); + will(returnValue(newHubToken.getToken())); + }}); + + hubManager.replaceTokensHub(satAdmin, peripherlaFqdn); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + List newTokenList = hubFactory.listAccessTokensByFqdn(peripherlaFqdn) + .stream().map(IssAccessToken::getToken).toList(); + assertEquals(2, newTokenList.size()); + assertNotEquals(tokenList, newTokenList); + assertTrue(newTokenList.contains(newHubToken.getToken()), "Expected new token not found"); + } + + @Test + public void canUpdateServerDetails() throws TokenBuildingException, TaskomaticApiException, TokenParsingException { + createHubRegistration("hub.domain.com", "---- BEGIN ROOT CA ----", "---- BEGIN GPG PUB KEY -----"); + IssHub hub = hubFactory.lookupIssHubByFqdn("hub.domain.com").orElseGet(() -> fail("Hub Server not found")); + assertEquals("---- BEGIN ROOT CA ----", hub.getRootCa()); + assertEquals("---- BEGIN GPG PUB KEY -----", hub.getGpgKey()); + + Map data = Map.of("gpg_key", "---- BEGIN NEW GPG PUB KEY -----", + "root_ca", "---- BEGIN NEW ROOT CA ----"); + hubManager.updateServerData(satAdmin, "hub.domain.com", IssRole.valueOf("HUB"), data); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + hub = hubFactory.lookupIssHubByFqdn("hub.domain.com").orElseGet(() -> fail("Hub Server not found")); + assertEquals("---- BEGIN NEW ROOT CA ----", hub.getRootCa()); + assertEquals("---- BEGIN NEW GPG PUB KEY -----", hub.getGpgKey()); + assertThrows(IllegalArgumentException.class, + () -> hubManager.updateServerData(satAdmin, "hub1.domain.com", IssRole.valueOf("HUB"), data)); + + // PERIPHERAL + + createPeripheralRegistration("peripheral.domain.com", "---- BEGIN ROOT CA ----"); + IssPeripheral peripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral.domain.com") + .orElseGet(() -> fail("Peripheral Server not found")); + assertEquals("---- BEGIN ROOT CA ----", peripheral.getRootCa()); + + hubManager.updateServerData(satAdmin, "peripheral.domain.com", IssRole.valueOf("PERIPHERAL"), data); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + peripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral.domain.com") + .orElseGet(() -> fail("Peripheral Server not found")); + assertEquals("---- BEGIN NEW ROOT CA ----", peripheral.getRootCa()); + assertThrows(IllegalArgumentException.class, () -> hubManager.updateServerData(satAdmin, + "peripheral1.domain.com", IssRole.valueOf("PERIPHERAL"), data)); + } + + private static IssAccessToken getValidToken(String fdqn) { + return new IssAccessToken(TokenType.ISSUED, "dummy-token", fdqn); + } + + private static Object getDefaultValue(Class clazz) { + if (int.class.equals(clazz)) { + return 0; + } + else if (boolean.class.equals(clazz)) { + return false; + } + else if (double.class.equals(clazz)) { + return 0.0; + } + else if (float.class.equals(clazz)) { + return 0.0f; + } + else if (long.class.equals(clazz)) { + return 0L; + } + else if (short.class.equals(clazz)) { + return (short) 0; + } + else if (byte.class.equals(clazz)) { + return (byte) 0; + } + else if (char.class.equals(clazz)) { + return '\u0000'; + } + + return null; + } + + private IssAccessToken createPeripheralRegistration(String fqdn, String rootCA) throws TaskomaticApiException, + TokenBuildingException, TokenParsingException { + Config.get().setString(ConfigDefaults.SERVER_HOSTNAME, fqdn); + String peripheralTokenStr = hubManager.issueAccessToken(satAdmin, fqdn); + hubManager.storeAccessToken(satAdmin, fqdn, peripheralTokenStr); + IssAccessToken peripheralToken = hubFactory.lookupIssuedToken(peripheralTokenStr); + var peripheral = (IssPeripheral) hubManager.saveNewServer(peripheralToken, IssRole.PERIPHERAL, rootCA, null); + var hubSCCCredentials = CredentialsFactory.createHubSCCCredentials("peripheral-dummy-username", + "peripheral-dummy-password", peripheral.getFqdn()); + CredentialsFactory.storeCredentials(hubSCCCredentials); + + peripheral.setMirrorCredentials(hubSCCCredentials); + hubFactory.save(peripheral); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + assertEquals(TokenType.CONSUMED, hubFactory.lookupAccessTokenFor(fqdn).getType()); + assertTrue(hubFactory.lookupIssPeripheralByFqdn(fqdn).isPresent(), "Failed to create Peripheral"); + assertEquals(1, CredentialsFactory.listCredentialsByType(HubSCCCredentials.class).size()); + + return peripheralToken; + } + + private IssAccessToken createHubRegistration(String fqdn, String rootCA, String gpgKey) + throws TaskomaticApiException, TokenBuildingException, TokenParsingException { + Config.get().setString(ConfigDefaults.SERVER_HOSTNAME, fqdn); + String hubTokenStr = hubManager.issueAccessToken(satAdmin, fqdn); + hubManager.storeAccessToken(satAdmin, fqdn, hubTokenStr); + IssAccessToken hubToken = hubFactory.lookupIssuedToken(hubTokenStr); + hubManager.saveNewServer(hubToken, IssRole.HUB, rootCA, gpgKey); + hubManager.storeSCCCredentials(hubToken, "dummy-username", "dummy-password"); + HibernateFactory.getSession().flush(); + HibernateFactory.getSession().clear(); + + assertEquals(TokenType.CONSUMED, hubFactory.lookupAccessTokenFor(fqdn).getType()); + assertTrue(hubFactory.lookupIssHub().isPresent(), "Failed to create Hub"); + assertEquals(1, CredentialsFactory.listSCCCredentials().size()); + + return hubToken; + } +} diff --git a/java/code/src/com/suse/manager/matcher/MatcherRunner.java b/java/code/src/com/suse/manager/matcher/MatcherRunner.java index 158dd9b3c0da..a27ffc143b4b 100644 --- a/java/code/src/com/suse/manager/matcher/MatcherRunner.java +++ b/java/code/src/com/suse/manager/matcher/MatcherRunner.java @@ -27,6 +27,7 @@ import com.redhat.rhn.domain.server.PinnedSubscriptionFactory; import com.suse.cloud.CloudPaygManager; +import com.suse.manager.model.hub.HubFactory; import com.suse.manager.webui.services.impl.MonitoringService; import org.apache.logging.log4j.LogManager; @@ -100,12 +101,14 @@ public void run(String csvDelimiter) { Runtime r = Runtime.getRuntime(); ExecutorService errorReaderService = null; ExecutorService inputReaderService = null; + HubFactory hubFactory = new HubFactory(); try { boolean isSUMaPayg = cloudManager.isPaygInstance(); boolean isUyuni = ConfigDefaults.get().isUyuni(); boolean needsEntitlements = !isUyuni && !isSUMaPayg; - boolean includeSelf = !isSUMaPayg && !isUyuni && IssFactory.getCurrentMaster() == null; + boolean includeSelf = !isSUMaPayg && !isUyuni && IssFactory.getCurrentMaster() == null && + !hubFactory.isISSPeripheral(); boolean isSelfMonitoringEnabled = !isSUMaPayg && !isUyuni && MonitoringService.isMonitoringEnabled(); PinnedSubscriptionFactory.getInstance().cleanStalePins(); diff --git a/java/code/src/com/suse/manager/model/hub/AccessTokenDTO.java b/java/code/src/com/suse/manager/model/hub/AccessTokenDTO.java new file mode 100644 index 000000000000..e443c1143ebc --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/AccessTokenDTO.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +import java.util.Date; + +public class AccessTokenDTO { + + private long id; + + private String serverFqdn; + + private TokenType type; + + private String localizedType; + + private boolean valid; + + private Date expirationDate; + + private Date creationDate; + + private Date modificationDate; + + private Long hubId; + + private Long peripheralId; + + /** + * Default constructor + * @param idIn the token id + * @param serverFqdnIn the fqdn of the server associated with the token + * @param typeIn the token type + * @param validIn true if it is valid + * @param expirationDateIn the expiration date + * @param creationDateIn the creation date + * @param modificationDateIn the last modification date + * @param hubIdIn the id of the hub, if the fqdn is registered as a hub. null otherwise + * @param peripheralIdIn the id of the peripheral, if the fqdn is registered as a peripheral. null otherwise + */ + public AccessTokenDTO(long idIn, String serverFqdnIn, TokenType typeIn, boolean validIn, Date expirationDateIn, + Date creationDateIn, Date modificationDateIn, Long hubIdIn, Long peripheralIdIn) { + this.id = idIn; + this.serverFqdn = serverFqdnIn; + this.type = typeIn; + this.localizedType = typeIn.getDescription(); + this.valid = validIn; + // Create new instance to ensure this are java.util.Date otherwise GSON will not apply the type adapter + this.expirationDate = new Date(expirationDateIn.getTime()); + this.creationDate = new Date(creationDateIn.getTime()); + this.modificationDate = new Date(modificationDateIn.getTime()); + this.hubId = hubIdIn; + this.peripheralId = peripheralIdIn; + } + + + public long getId() { + return id; + } + + public void setId(long idIn) { + this.id = idIn; + } + + public String getServerFqdn() { + return serverFqdn; + } + + public void setServerFqdn(String serverFqdnIn) { + this.serverFqdn = serverFqdnIn; + } + + public TokenType getType() { + return type; + } + + + /** + * Set the type and update the localized label + * @param typeIn the token type + */ + public void setType(TokenType typeIn) { + this.type = typeIn; + this.localizedType = type != null ? type.getDescription() : null; + } + + public String getLocalizedType() { + return localizedType; + } + + public boolean isValid() { + return valid; + } + + public void setValid(boolean validIn) { + this.valid = validIn; + } + + public Date getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Date expirationDateIn) { + this.expirationDate = expirationDateIn; + } + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDateIn) { + this.creationDate = creationDateIn; + } + + public Date getModificationDate() { + return modificationDate; + } + + public void setModificationDate(Date modificationDateIn) { + this.modificationDate = modificationDateIn; + } + + public Long getHubId() { + return hubId; + } + + public void setHubId(Long hubIdIn) { + this.hubId = hubIdIn; + } + + public Long getPeripheralId() { + return peripheralId; + } + + public void setPeripheralId(Long peripheralIdIn) { + this.peripheralId = peripheralIdIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof AccessTokenDTO that)) { + return false; + } + + return new EqualsBuilder() + .append(getId(), that.getId()) + .append(isValid(), that.isValid()) + .append(getServerFqdn(), that.getServerFqdn()) + .append(getType(), that.getType()) + .append(getExpirationDate(), that.getExpirationDate()) + .append(getCreationDate(), that.getCreationDate()) + .append(getModificationDate(), that.getModificationDate()) + .append(getHubId(), that.getHubId()) + .append(getPeripheralId(), that.getPeripheralId()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(getId()) + .append(getServerFqdn()) + .append(getType()) + .append(isValid()) + .append(getExpirationDate()) + .append(getCreationDate()) + .append(getModificationDate()) + .append(getHubId()) + .append(getPeripheralId()) + .toHashCode(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("AccessTokenDTO{"); + sb.append("id=").append(id); + sb.append(", serverFqdn='").append(serverFqdn).append('\''); + sb.append(", type=").append(type); + sb.append(", valid=").append(valid); + sb.append(", expirationDate=").append(expirationDate); + sb.append(", creationDate=").append(creationDate); + sb.append(", modificationDate=").append(modificationDate); + sb.append(", hubId=").append(hubId); + sb.append(", peripheralId=").append(peripheralId); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/ChannelInfoJson.java b/java/code/src/com/suse/manager/model/hub/ChannelInfoJson.java new file mode 100644 index 000000000000..9e35aa373bde --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/ChannelInfoJson.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import java.util.Objects; + +public class ChannelInfoJson { + + private final Long id; + private final String name; + private final String label; + private final String summary; + private final Long orgId; + private final Long parentChannelId; + + /** + * Constructor + * + * @param idIn the id of the channel, if present + * @param nameIn the name of the channel + * @param labelIn the label of the channel + * @param summaryIn the summary of the channel + * @param orgIdIn the organization id of the channel + * @param parentChannelIdIn the parent channel of the channel + */ + public ChannelInfoJson(Long idIn, String nameIn, String labelIn, String summaryIn, + Long orgIdIn, Long parentChannelIdIn) { + this.id = idIn; + this.name = nameIn; + this.label = labelIn; + this.summary = summaryIn; + this.orgId = orgIdIn; + this.parentChannelId = parentChannelIdIn; + } + + /** + * @return the id of the channel + */ + public Long getId() { + return id; + } + + /** + * @return the label of the channel + */ + public String getLabel() { + return label; + } + + /** + * @return the name of the channel + */ + public String getName() { + return name; + } + + /** + * @return the summary of the channel + */ + public String getSummary() { + return summary; + } + + /** + * @return the organization id of the channel + */ + public Long getOrgId() { + return orgId; + } + + /** + * @return the parent channel of the channel + */ + public Long getParentChannelId() { + return parentChannelId; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ChannelInfoJson that)) { + return false; + } + return Objects.equals(getId(), that.getId()) && + Objects.equals(getName(), that.getName()) && + Objects.equals(getLabel(), that.getLabel()) && + Objects.equals(getSummary(), that.getSummary()) && + Objects.equals(getOrgId(), that.getOrgId()) && + Objects.equals(getParentChannelId(), that.getParentChannelId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId(), getName(), getLabel(), getSummary(), getOrgId(), getParentChannelId()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ChannelInfoJson{"); + sb.append("id=").append(id); + sb.append(", name='").append(name).append('\''); + sb.append(", label='").append(label).append('\''); + sb.append(", summary='").append(summary).append('\''); + sb.append(", orgId=").append(orgId); + sb.append(", parentChannelId=").append(parentChannelId); + sb.append('}'); + return sb.toString(); + } +} + diff --git a/java/code/src/com/suse/manager/model/hub/CustomChannelInfoJson.java b/java/code/src/com/suse/manager/model/hub/CustomChannelInfoJson.java new file mode 100644 index 000000000000..9b6f99d2d34c --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/CustomChannelInfoJson.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.suse.scc.model.SCCRepositoryJson; + +import java.util.Objects; + +public class CustomChannelInfoJson extends ModifyCustomChannelInfoJson { + + private String parentChannelLabel; + private String channelArchLabel; + private String checksumTypeLabel; + private SCCRepositoryJson repositoryInfo; + + /** + * Constructor + * + * @param labelIn The channel label + */ + public CustomChannelInfoJson(String labelIn) { + super(labelIn); + repositoryInfo = new SCCRepositoryJson(); + } + + /** + * @return Returns the parentChannel. + */ + public String getParentChannelLabel() { + return parentChannelLabel; + } + + /** + * @param p The parentChannel to set. + */ + public void setParentChannelLabel(String p) { + this.parentChannelLabel = p; + } + + /** + * @return Returns the channelArch id + */ + public String getChannelArchLabel() { + return channelArchLabel; + } + + /** + * @param c The channelArch label to set. + */ + public void setChannelArchLabel(String c) { + this.channelArchLabel = c; + } + + /** + * @return Returns the checksum type label + */ + public String getChecksumTypeLabel() { + return checksumTypeLabel; + } + + /** + * @param checksumTypeLabelIn The checksum type label to set. + */ + public void setChecksumTypeLabel(String checksumTypeLabelIn) { + this.checksumTypeLabel = checksumTypeLabelIn; + } + + /** + * @return SCCRepositoryJson object with repository info + */ + public SCCRepositoryJson getRepositoryInfo() { + return repositoryInfo; + } + + /** + * @param repositoryInfoIn The SCCRepositoryJson object with repository info + */ + public void setRepositoryInfo(SCCRepositoryJson repositoryInfoIn) { + repositoryInfo = repositoryInfoIn; + } + + @Override + public boolean equals(Object oIn) { + if (oIn == null || getClass() != oIn.getClass()) { + return false; + } + if (!super.equals(oIn)) { + return false; + } + CustomChannelInfoJson that = (CustomChannelInfoJson) oIn; + return Objects.equals(getParentChannelLabel(), that.getParentChannelLabel()) && + Objects.equals(getChannelArchLabel(), that.getChannelArchLabel()) && + Objects.equals(getChecksumTypeLabel(), that.getChecksumTypeLabel()) && + Objects.equals(getRepositoryInfo(), that.getRepositoryInfo()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getParentChannelLabel(), getChannelArchLabel(), getChecksumTypeLabel(), + getRepositoryInfo()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("CustomChannelInfoJson{"); + sb.append(toStringCore()); + sb.append("parentChannelLabel='").append(parentChannelLabel).append('\''); + sb.append(", channelArchLabel='").append(channelArchLabel).append('\''); + sb.append(", checksumTypeLabel='").append(checksumTypeLabel).append('\''); + sb.append(", repositoryInfo=").append(repositoryInfo); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/HubFactory.java b/java/code/src/com/suse/manager/model/hub/HubFactory.java new file mode 100644 index 000000000000..69e23ac2faa2 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/HubFactory.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.model.hub; + +import com.redhat.rhn.common.hibernate.HibernateFactory; +import com.redhat.rhn.domain.DatabaseEnumType; +import com.redhat.rhn.domain.channel.Channel; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.hibernate.type.StandardBasicTypes; + +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import javax.persistence.Tuple; + +public class HubFactory extends HibernateFactory { + + private static final Logger LOG = LogManager.getLogger(HubFactory.class); + + @Override + protected Logger getLogger() { + return LOG; + } + + /** + * Save a {@link IssHub} object + * @param issHubIn object to save + */ + public void save(IssHub issHubIn) { + saveObject(issHubIn); + } + + /** + * Save a {@link IssPeripheral} object + * @param issPeripheralIn object to save + */ + public void save(IssPeripheral issPeripheralIn) { + saveObject(issPeripheralIn); + } + + /** + * Save a {@link IssPeripheralChannels} object + * @param issPeripheralChannelsIn object to save + */ + public void save(IssPeripheralChannels issPeripheralChannelsIn) { + saveObject(issPeripheralChannelsIn); + } + + /** + * Remove a {@ink IssPeripheral} object + * @param peripheralIn the object to remove + */ + public void remove(IssPeripheral peripheralIn) { + removeObject(peripheralIn); + } + + /** + * Remove a {@ink IssHub} object + * @param hubIn the object to remove + */ + public void remove(IssHub hubIn) { + removeObject(hubIn); + } + + /** + * Lookup {@link IssHub} object by its FQDN + * @param fqdnIn the fqdn + * @return return {@link IssHub} with the given FQDN or empty + */ + public Optional lookupIssHubByFqdn(String fqdnIn) { + return getSession().createQuery("FROM IssHub WHERE fqdn = :fqdn", IssHub.class) + .setParameter("fqdn", fqdnIn) + .uniqueResultOptional(); + } + + /** + * Lookup {@link IssHub} object. + * A peripheral server should have not more than 1 Hub + * @return return {@link IssHub} + */ + public Optional lookupIssHub() { + return getSession().createQuery("FROM IssHub", IssHub.class) + .uniqueResultOptional(); + } + + /** + * @return return true, when this system is an Inter-Server-Sync Peripheral Server + */ + public boolean isISSPeripheral() { + return lookupIssHub().isPresent(); + } + + /** + * Count the total number of registered peripherals for this hub + * @return + */ + public Long countPeripherals() { + return getSession() + .createQuery("SELECT COUNT(*) FROM IssPeripheral k", Long.class) + .uniqueResult(); + } + + /** + * get the list of all the peripheral servers for a hub + * @param offset the first item to retrieve + * @param pageSize the maximum number of items to retrieve + * @return a list of paginated peripherals + */ + public List listPaginatedPeripherals(int offset, int pageSize) { + return getSession().createQuery("FROM IssPeripheral", IssPeripheral.class) + .setFirstResult(offset) + .setMaxResults(pageSize) + .list(); + } + + /** + * Find a peripheral already registered by its id + * @param id the id + * @return the peripheral entity + */ + public IssPeripheral findPeripheral(long id) { + return getSession().byId(IssPeripheral.class).load(id); + } + + /** + * Lookup {@link IssPeripheral} object by its FQDN + * @param fqdnIn the fqdn + * @return return {@link IssPeripheral} with the given FQDN or empty + */ + public Optional lookupIssPeripheralByFqdn(String fqdnIn) { + return getSession().createQuery("FROM IssPeripheral WHERE fqdn = :fqdn", IssPeripheral.class) + .setParameter("fqdn", fqdnIn) + .uniqueResultOptional(); + } + + /** + * List {@link IssPeripheralChannels} objects which reference the given {@link Channel} + * @param channelIn the channel + * @return return the list of {@link IssPeripheralChannels} objects + */ + public List listIssPeripheralChannelsByChannels(Channel channelIn) { + return getSession() + .createQuery("FROM IssPeripheralChannels WHERE channel = :channel", IssPeripheralChannels.class) + .setParameter("channel", channelIn) + .list(); + } + + /** + * Store a new access token + * @param fqdn the FQDN of the server + * @param token the token to establish a connection with the specified server + * @param type the type of token + * @param expiration when the token is no longer valid + * @return the id of the stored access token + */ + public IssAccessToken saveToken(String fqdn, String token, TokenType type, Instant expiration) { + // Lookup if this association already exists + IssAccessToken accessToken = lookupAccessTokenByFqdnAndType(fqdn, type); + if (accessToken == null) { + accessToken = new IssAccessToken(type, token, fqdn, expiration); + } + else { + accessToken.setToken(token); + accessToken.setValid(true); + accessToken.setExpirationDate(Date.from(expiration)); + } + + // Store the new token + getSession().saveOrUpdate(accessToken); + return accessToken; + } + + /** + * Updates an existing access token + * @param accessToken the access token to update + */ + public void updateToken(IssAccessToken accessToken) { + getSession().update(accessToken); + } + + /** + * Returns the issued access token information matching the given token + * @param token the string representation of the token + * @return the issued token, if present + */ + public IssAccessToken lookupIssuedToken(String token) { + return getSession() + .createQuery("FROM IssAccessToken k WHERE k.type = :type AND k.token = :token", IssAccessToken.class) + .setParameter("type", TokenType.ISSUED) + .setParameter("token", token) + .uniqueResult(); + } + + /** + * Returns the access token for the specified FQDN + * @param fqdn the FQDN of the peripheral/hub + * @return the access token associated to the entity, if present + */ + public IssAccessToken lookupAccessTokenFor(String fqdn) { + return getSession() + .createQuery("FROM IssAccessToken k WHERE k.type = :type AND k.serverFqdn = :fqdn", IssAccessToken.class) + .setParameter("type", TokenType.CONSUMED) + .setParameter("fqdn", fqdn) + .uniqueResult(); + } + + /** + * Returns the access token of the given type for the specified FQDN + * @param fqdn the FQDN of the peripheral/hub + * @param type the type of token + * @return the access token associated to the entity, if present + */ + public IssAccessToken lookupAccessTokenByFqdnAndType(String fqdn, TokenType type) { + return getSession() + .createQuery("FROM IssAccessToken k WHERE k.type = :type AND k.serverFqdn = :fqdn", IssAccessToken.class) + .setParameter("type", type) + .setParameter("fqdn", fqdn) + .uniqueResult(); + } + + /** + * Retrieves the access token with the given id + * @param id the id of the token + * @return the access token instance, if present + */ + public Optional lookupAccessTokenById(long id) { + return getSession() + .createQuery("FROM IssAccessToken k WHERE k.id = :id", IssAccessToken.class) + .setParameter("id", id) + .uniqueResultOptional(); + } + + /** + * Returns a list of access tokens for specified FQDN + * @param fqdn the FQDN of the server + * @return return the access tokens associated with the given fqdn + */ + public List listAccessTokensByFqdn(String fqdn) { + return getSession() + .createQuery("FROM IssAccessToken k WHERE k.serverFqdn = :fqdn", IssAccessToken.class) + .setParameter("fqdn", fqdn) + .list(); + } + + /** + * Delete all access tokens for the given server + * @param serverFqdn the FQDN for the server + * @return number of removed tokens + */ + public int removeAccessTokensFor(String serverFqdn) { + return getSession() + .createNativeQuery("DELETE FROM suseISSAccessToken WHERE server_fqdn = :fqdn") + .setParameter("fqdn", serverFqdn) + .executeUpdate(); + } + + /** + * Delete the access tokens with the given id + * @param id the id of the token + * @return true if the token was deleted, false otherwise + */ + public boolean removeAccessTokenById(long id) { + int tokenRemoved = getSession() + .createNativeQuery("DELETE FROM suseISSAccessToken WHERE id = :id") + .setParameter("id", id) + .executeUpdate(); + + return tokenRemoved != 0; + } + /** + * Count the existing access tokens + * @return the current number of access tokens + */ + public long countAccessToken() { + return getSession() + .createQuery("SELECT COUNT(*) FROM IssAccessToken k", Long.class) + .uniqueResult(); + } + + /** + * Lists the existing access token + * @param offset the first item to retrieve + * @param pageSize the maximum number of items to retrieve + * @return the list of tokens + */ + public List listAccessToken(int offset, int pageSize) { + return getSession().createNativeQuery(""" + SELECT k.id + , k.type + , k.server_fqdn + , k.valid + , k.expiration_date + , k.created + , k.modified + , h.id as hub_id + , p.id as peripheral_id + FROM suseissaccesstoken k + LEFT JOIN suseisshub h ON k.server_fqdn = h.fqdn + LEFT JOIN suseissperipheral p ON k.server_fqdn = p.fqdn + ORDER BY k.created DESC + """, Tuple.class) + .addScalar("id", StandardBasicTypes.LONG) + .addScalar("type", StandardBasicTypes.STRING) + .addScalar("server_fqdn", StandardBasicTypes.STRING) + .addScalar("valid", StandardBasicTypes.BOOLEAN) + .addScalar("expiration_date", StandardBasicTypes.TIMESTAMP) + .addScalar("created", StandardBasicTypes.TIMESTAMP) + .addScalar("modified", StandardBasicTypes.TIMESTAMP) + .addScalar("hub_id", StandardBasicTypes.LONG) + .addScalar("peripheral_id", StandardBasicTypes.LONG) + .setFirstResult(offset) + .setMaxResults(pageSize) + .stream() + .map(tuple -> new AccessTokenDTO( + tuple.get("id", Long.class), + tuple.get("server_fqdn", String.class), + DatabaseEnumType.findByLabel(TokenType.class, tuple.get("type", String.class)), + tuple.get("valid", Boolean.class), + tuple.get("expiration_date", Date.class), + tuple.get("created", Date.class), + tuple.get("modified", Date.class), + tuple.get("hub_id", Long.class), + tuple.get("peripheral_id", Long.class) + )) + .toList(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssAccessToken.java b/java/code/src/com/suse/manager/model/hub/IssAccessToken.java new file mode 100644 index 000000000000..08519eb72cf8 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssAccessToken.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.BaseDomainHelper; + +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenParser; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import org.hibernate.annotations.Type; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.Objects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import javax.persistence.Transient; + +@Entity +@Table(name = "suseISSAccessToken") +public class IssAccessToken extends BaseDomainHelper { + + private long id; + + private String token; + + private TokenType type; + + private String serverFqdn; + + private Date expirationDate; + + private boolean valid; + + /** + * Default constructor + */ + protected IssAccessToken() { + // Used by Hibernate + } + + /** + * Build a new access token with the default expiration period of 1 year + * @param typeIn the type of token + * @param tokenIn the token + * @param serverFqdnIn the FQDN of the server related to this token + */ + public IssAccessToken(TokenType typeIn, String tokenIn, String serverFqdnIn) { + this(typeIn, tokenIn, serverFqdnIn, Date.from(ZonedDateTime.now().plusYears(1).toInstant())); + } + + /** + * Build a new access token + * @param typeIn the type of token + * @param tokenIn the token + * @param serverFqdnIn the FQDN of the server related to this token + * @param expirationDateIn the instant the token expires + */ + public IssAccessToken(TokenType typeIn, String tokenIn, String serverFqdnIn, Instant expirationDateIn) { + this(typeIn, tokenIn, serverFqdnIn, Date.from(expirationDateIn)); + } + + /** + * Build a new access token + * @param typeIn the type of token + * @param tokenIn the token + * @param serverFqdnIn the FQDN of the server related to this token + * @param expirationDateIn the instant the token expires + */ + public IssAccessToken(TokenType typeIn, String tokenIn, String serverFqdnIn, Date expirationDateIn) { + this.token = tokenIn; + this.type = typeIn; + this.serverFqdn = serverFqdnIn; + this.expirationDate = expirationDateIn; + this.valid = true; + } + + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + public long getId() { + return id; + } + + public void setId(long idIn) { + this.id = idIn; + } + + @Column(name = "token") + public String getToken() { + return token; + } + + public void setToken(String tokenIn) { + this.token = tokenIn; + } + + @Column(name = "type") + @Type(type = "com.suse.manager.model.hub.TokenTypeEnumType") + public TokenType getType() { + return type; + } + + public void setType(TokenType typeIn) { + this.type = typeIn; + } + + @Column(name = "server_fqdn") + public String getServerFqdn() { + return serverFqdn; + } + + public void setServerFqdn(String serverFqdnIn) { + this.serverFqdn = serverFqdnIn; + } + + @Column(name = "expiration_date") + @Temporal(TemporalType.TIMESTAMP) + public Date getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Date expirationDateIn) { + this.expirationDate = expirationDateIn; + } + + @Column(name = "valid") + public boolean isValid() { + return valid; + } + + public void setValid(boolean validIn) { + this.valid = validIn; + } + + /** + * Checks if the current instance is expired. + * @return true if the current date is after the expiration date + */ + @Transient + public boolean isExpired() { + if (expirationDate == null) { + return false; + } + + return new Date().after(expirationDate); + } + + /** + * Retrieve the parsed token associated with this entity + * @return the parsed token + * @throws TokenParsingException if parsing the serialized value fails + */ + @Transient + public Token getParsedToken() throws TokenParsingException { + return new TokenParser() + .usingServerSecret() + .verifyingNotBefore() + .verifyingExpiration() + .parse(token); + + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof IssAccessToken issAccessToken)) { + return false; + } + return Objects.equals(getToken(), issAccessToken.getToken()) && + Objects.equals(getType(), issAccessToken.getType()) && + Objects.equals(getServerFqdn(), issAccessToken.getServerFqdn()); + } + + @Override + public int hashCode() { + return Objects.hash(getToken(), getType(), getServerFqdn()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("IssAccessToken{id =").append(id); + sb.append(", type=").append(type); + sb.append(", serverFqdn='").append(serverFqdn).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssHub.java b/java/code/src/com/suse/manager/model/hub/IssHub.java new file mode 100644 index 000000000000..dbf1721ae952 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssHub.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.BaseDomainHelper; +import com.redhat.rhn.domain.credentials.SCCCredentials; + +import java.util.Objects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +@Entity +@Table(name = "suseISSHub") +public class IssHub extends BaseDomainHelper implements IssServer { + private Long id; + private String fqdn; + private String rootCa; + private String gpgKey; + private SCCCredentials mirrorCredentials; + + protected IssHub() { + // Default empty Constructor for Hibernate + } + + @Transient + public IssRole getRole() { + return IssRole.HUB; + } + + /** + * Constructor + * @param fqdnIn the FQDN of the Hub Server + */ + public IssHub(String fqdnIn) { + this (fqdnIn, null); + } + + /** + * Constructor + * @param fqdnIn the FQDN of the Hub Server + * @param rootCaIn the root CA used by the Hub Server + */ + public IssHub(String fqdnIn, String rootCaIn) { + fqdn = fqdnIn; + rootCa = rootCaIn; + mirrorCredentials = null; + } + + /** + * @return return the ID + */ + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Override + public Long getId() { + return id; + } + + /** + * Get the FQDN of the Hub Server + * @return return the FQDN of the Hub Server + */ + @Column(name = "fqdn", unique = true) + @Override + public String getFqdn() { + return fqdn; + } + + /** + * Get the configured Root CA + * @return return the root ca + */ + @Column(name = "root_ca") + @Override + public String getRootCa() { + return rootCa; + } + + /** + * Get the configured GPG Key + * @return return the gpg key + */ + @Column(name = "gpg_key") + public String getGpgKey() { + return gpgKey; + } + + /** + * Get the mirror credentials. + * @return the credentials + */ + @OneToOne(targetEntity = SCCCredentials.class) + @JoinColumn(name = "mirror_creds_id") + public SCCCredentials getMirrorCredentials() { + return mirrorCredentials; + } + + /* + * will be autogenerated + */ + protected void setId(Long idIn) { + id = idIn; + } + + /** + * @param fqdnIn the FQDN + */ + @Override + public void setFqdn(String fqdnIn) { + fqdn = fqdnIn; + } + + /** + * @param rootCaIn the root ca + */ + @Override + public void setRootCa(String rootCaIn) { + rootCa = rootCaIn; + } + + /** + * @param gpgKeyIn the gpg key + */ + public void setGpgKey(String gpgKeyIn) { + gpgKey = gpgKeyIn; + } + + /** + * @param mirrorCredentialsIn the mirror credentials + */ + public void setMirrorCredentials(SCCCredentials mirrorCredentialsIn) { + mirrorCredentials = mirrorCredentialsIn; + } + + @Override + public boolean equals(Object oIn) { + if (this == oIn) { + return true; + } + if (!(oIn instanceof IssHub issHub)) { + return false; + } + return Objects.equals(getFqdn(), issHub.getFqdn()) && + Objects.equals(getRootCa(), issHub.getRootCa()) && + Objects.equals(getMirrorCredentials(), issHub.getMirrorCredentials()); + } + + @Override + public int hashCode() { + return Objects.hash(getFqdn(), getRootCa(), getMirrorCredentials()); + } + + @Override + public String toString() { + return "IssHub{" + + "id=" + id + + ", fqdn='" + fqdn + '\'' + + '}'; + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssPeripheral.java b/java/code/src/com/suse/manager/model/hub/IssPeripheral.java new file mode 100644 index 000000000000..5959a9fb9924 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssPeripheral.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.BaseDomainHelper; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; + +import org.apache.commons.lang3.builder.HashCodeBuilder; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +@Entity +@Table(name = "suseISSPeripheral") +public class IssPeripheral extends BaseDomainHelper implements IssServer { + private Long id; + private String fqdn; + private String rootCa; + private HubSCCCredentials mirrorCredentials; + private Set peripheralChannels; + + protected IssPeripheral() { + peripheralChannels = new HashSet<>(); + // Default empty Constructor for Hibernate + } + + @Transient + public IssRole getRole() { + return IssRole.PERIPHERAL; + } + + /** + * Constructor + * @param fqdnIn the FQDN of the Peripheral Server + */ + public IssPeripheral(String fqdnIn) { + this (fqdnIn, null); + } + + /** + * Constructor + * @param fqdnIn the FQDN of the Peripheral Server + * @param rootCaIn the root CA used by the Peripheral Server + */ + public IssPeripheral(String fqdnIn, String rootCaIn) { + fqdn = fqdnIn; + rootCa = rootCaIn; + mirrorCredentials = null; + peripheralChannels = new HashSet<>(); + } + + /** + * @return return the ID + */ + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Override + public Long getId() { + return id; + } + + /** + * Get the FQDN of the Peripheral Server + * @return return the FQDN of the Peripheral Server + */ + @Column(name = "fqdn", unique = true) + @Override + public String getFqdn() { + return fqdn; + } + + /** + * Get the configured Root CA + * @return return the root ca + */ + @Column(name = "root_ca") + @Override + public String getRootCa() { + return rootCa; + } + + /** + * Get the mirror credentials. + * @return the credentials + */ + @OneToOne(targetEntity = HubSCCCredentials.class) + @JoinColumn(name = "mirror_creds_id") + public HubSCCCredentials getMirrorCredentials() { + return mirrorCredentials; + } + + /** + * @return return channels which should be synced to the peripheral server + */ + @OneToMany(mappedBy = "peripheral", fetch = FetchType.LAZY) + public Set getPeripheralChannels() { + return peripheralChannels; + } + + /* + * will be autogenerated + */ + protected void setId(Long idIn) { + id = idIn; + } + + /** + * @param fqdnIn the FQDN + */ + @Override + public void setFqdn(String fqdnIn) { + fqdn = fqdnIn; + } + + /** + * @param rootCaIn the root ca + */ + @Override + public void setRootCa(String rootCaIn) { + rootCa = rootCaIn; + } + + /** + * @param mirrorCredentialsIn the mirror credentials + */ + public void setMirrorCredentials(HubSCCCredentials mirrorCredentialsIn) { + mirrorCredentials = mirrorCredentialsIn; + } + + public void setPeripheralChannels(Set peripheralChannelsIn) { + peripheralChannels = peripheralChannelsIn; + } + + @Override + public boolean equals(Object oIn) { + if (this == oIn) { + return true; + } + if (!(oIn instanceof IssPeripheral issPer)) { + return false; + } + return Objects.equals(getFqdn(), issPer.getFqdn()) && + Objects.equals(getRootCa(), issPer.getRootCa()) && + Objects.equals(getMirrorCredentials(), issPer.getMirrorCredentials()); + } + + @Override + public int hashCode() { + return new HashCodeBuilder().append(getFqdn()).append(getRootCa()).append(getMirrorCredentials()).toHashCode(); + } + + @Override + public String toString() { + return "IssPeripheral{" + + "id=" + id + + ", fqdn='" + fqdn + '\'' + + '}'; + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssPeripheralChannels.java b/java/code/src/com/suse/manager/model/hub/IssPeripheralChannels.java new file mode 100644 index 000000000000..b2a7133187f8 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssPeripheralChannels.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.BaseDomainHelper; +import com.redhat.rhn.domain.channel.Channel; + +import org.apache.commons.lang3.builder.HashCodeBuilder; + +import java.util.Objects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + + +@Entity +@Table(name = "suseISSPeripheralChannels") +public class IssPeripheralChannels extends BaseDomainHelper { + private Long id; + private IssPeripheral peripheral; + private Integer peripheralOrgId; + private Channel channel; + + protected IssPeripheralChannels() { + // Default empty Constructor for Hibernate + } + + /** + * Constructor + * @param peripheralIn the Peripheral Server + * @param channelIn the channel to be synchronized + */ + public IssPeripheralChannels(IssPeripheral peripheralIn, Channel channelIn) { + peripheral = peripheralIn; + channel = channelIn; + peripheralOrgId = null; + } + + /** + * Constructor + * @param peripheralIn the Peripheral Server + * @param channelIn the channel to be synchronized + * @param peripheralOrgIdIn the custom peripheral org id the channel should be assigned to + */ + public IssPeripheralChannels(IssPeripheral peripheralIn, Channel channelIn, int peripheralOrgIdIn) { + peripheral = peripheralIn; + channel = channelIn; + peripheralOrgId = peripheralOrgIdIn; + } + + /** + * @return return the ID + */ + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long getId() { + return id; + } + + /** + * Get the Peripheral Server. + * @return the peripheral server + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "peripheral_id") + public IssPeripheral getPeripheral() { + return peripheral; + } + + /** + * Get the Channel + * @return return the Channel + */ + @ManyToOne + @JoinColumn(name = "channel_id") + public Channel getChannel() { + return channel; + } + + /** + * Get the peripheral organization id where this channel should be assigned to. + * Vendor Channels will have NULL here + * @return the peripheral organization id or NULL + */ + @Column(name = "peripheral_org_id") + public Integer getPeripheralOrgId() { + return peripheralOrgId; + } + + /* + * will be autogenerated + */ + protected void setId(Long idIn) { + id = idIn; + } + + /** + * @param peripheralIn the peripheral server + */ + public void setPeripheral(IssPeripheral peripheralIn) { + peripheral = peripheralIn; + } + + /** + * @param channelIn the channel + */ + public void setChannel(Channel channelIn) { + channel = channelIn; + } + + /** + * @param peripheralOrgIdIn the peripheral organization id + */ + public void setPeripheralOrgId(Integer peripheralOrgIdIn) { + peripheralOrgId = peripheralOrgIdIn; + } + + @Override + public boolean equals(Object oIn) { + if (this == oIn) { + return true; + } + if (!(oIn instanceof IssPeripheralChannels that)) { + return false; + } + return Objects.equals(getPeripheral(), that.getPeripheral()) && + Objects.equals(getPeripheralOrgId(), that.getPeripheralOrgId()) && + Objects.equals(getChannel(), that.getChannel()); + } + + @Override + public int hashCode() { + return new HashCodeBuilder() + .append(getPeripheral()) + .append(getPeripheralOrgId()) + .append(getChannel()).toHashCode(); + } + + @Override + public String toString() { + return "IssPeripheralChannel{" + + "id=" + id + + ", peripheral='" + getPeripheral() + '\'' + + ", channel='" + getChannel() + '\'' + + '}'; + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssRole.java b/java/code/src/com/suse/manager/model/hub/IssRole.java new file mode 100644 index 000000000000..031be7ce473c --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssRole.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.Labeled; + +public enum IssRole implements Labeled { + HUB, + PERIPHERAL; + + @Override + public String getLabel() { + return name().toLowerCase(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/IssServer.java b/java/code/src/com/suse/manager/model/hub/IssServer.java new file mode 100644 index 000000000000..aeffc31a220d --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/IssServer.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +/** + * Common interface for ISS servers + */ +public interface IssServer { + + /** + * The role, either HUB or PERIPHERAL + * @return the server {@link IssRole} + */ + IssRole getRole(); + + /** + * Gets the ID + * @return the server ID + */ + Long getId(); + + /** + * Gets the FQDN + * @return the FQDN + */ + String getFqdn(); + + /** + * Gets the root certificate + * @return the root certificate, if specified + */ + String getRootCa(); + + /** + * Sets the FQDN + * @param fqdnIn the new FQDN + */ + void setFqdn(String fqdnIn); + + /** + * Sets the root certificate + * @param rootCaIn the new root certificate, or null if not needed + */ + void setRootCa(String rootCaIn); +} diff --git a/java/code/src/com/suse/manager/model/hub/ManagerInfoJson.java b/java/code/src/com/suse/manager/model/hub/ManagerInfoJson.java new file mode 100644 index 000000000000..e5b34e198e09 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/ManagerInfoJson.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import java.util.Objects; +import java.util.StringJoiner; + +public class ManagerInfoJson { + + private final String version; + + private final boolean reportDb; + + private final String reportDbName; + + private final String reportDbHost; + + private final int reportDbPort; + + + /** + * Default constructor + */ + public ManagerInfoJson() { + version = "0"; + reportDb = false; + reportDbName = ""; + reportDbHost = ""; + reportDbPort = 5432; + } + + /** + * Constructor + * @param versionIn the version + * @param reportDbIn has a report DB + * @param reportDbNameIn the report DB name + * @param reportDbHostIn the report DB hostname + * @param reportDbPortIn the report DB port number + */ + public ManagerInfoJson(String versionIn, boolean reportDbIn, String reportDbNameIn, + String reportDbHostIn, int reportDbPortIn) { + version = versionIn; + reportDb = reportDbIn; + reportDbName = reportDbNameIn; + reportDbHost = reportDbHostIn; + reportDbPort = reportDbPortIn; + } + + /** + * @return return the version + */ + public String getVersion() { + return version; + } + + /** + * @return return true when a report DB is configured + */ + public boolean hasReportDb() { + return reportDb; + } + + /** + * @return return the report DB name + */ + public String getReportDbName() { + return reportDbName; + } + + /** + * @return return the report DB hostname + */ + public String getReportDbHost() { + return reportDbHost; + } + + /** + * @return return the report DB port number + */ + public int getReportDbPort() { + return reportDbPort; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ManagerInfoJson that)) { + return false; + } + return Objects.equals(version, that.version) && + Objects.equals(reportDb, that.reportDb) && + Objects.equals(reportDbName, that.reportDbName) && + Objects.equals(reportDbHost, that.reportDbHost) && + Objects.equals(reportDbPort, that.reportDbPort); + } + + @Override + public int hashCode() { + return Objects.hash(version, reportDb, reportDbName, reportDbHost, reportDbPort); + } + + @Override + public String toString() { + return new StringJoiner(", ", ManagerInfoJson.class.getSimpleName() + "[", "]") + .add("version='" + getVersion() + "'") + .add("reportDb='" + hasReportDb() + "'") + .add("reportDbHost='" + getReportDbHost() + "'") + .toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/ModifyCustomChannelInfoJson.java b/java/code/src/com/suse/manager/model/hub/ModifyCustomChannelInfoJson.java new file mode 100644 index 000000000000..acb8bfb32911 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/ModifyCustomChannelInfoJson.java @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.channel.Channel; + +import java.util.Date; +import java.util.Objects; + +public class ModifyCustomChannelInfoJson { + + private final String label; + + private Long peripheralOrgId; + private String originalChannelLabel; + + private String baseDir; + private String name; + private String summary; + private String description; + private String productNameLabel; + private Boolean gpgCheck; + private String gpgKeyUrl; + private String gpgKeyId; + private String gpgKeyFp; + private Long endOfLifeDate; + + private String channelProductProduct; + private String channelProductVersion; + private String channelAccess; + private String maintainerName; + private String maintainerEmail; + private String maintainerPhone; + private String supportPolicy; + private String updateTag; + private Boolean installerUpdates; + + /** + * Constructor + * + * @param labelIn The channel label + */ + public ModifyCustomChannelInfoJson(String labelIn) { + label = labelIn; + + gpgCheck = true; + channelAccess = Channel.PRIVATE; + installerUpdates = false; + originalChannelLabel = null; + } + + private Date longToDate(Long longIn) { + if (null == longIn) { + return new Date(); + } + else { + return new Date(longIn); + } + } + + private Long dateToLong(Date dateIn) { + if (null != dateIn) { + return dateIn.getTime(); + } + + return 0L; + } + + /** + * @return Returns the label. + */ + public String getLabel() { + return label; + } + + /** + * @return Returns the peripheral org id + */ + public Long getPeripheralOrgId() { + return peripheralOrgId; + } + + /** + * @param o The peripheral org id to set. + */ + public void setPeripheralOrgId(Long o) { + this.peripheralOrgId = o; + } + + /** + * @return Returns the original channel label if cloned. + */ + public String getOriginalChannelLabel() { + return originalChannelLabel; + } + + /** + * @param originalChannelLabelIn The he original channel label if cloned. + */ + public void setOriginalChannelLabel(String originalChannelLabelIn) { + originalChannelLabel = originalChannelLabelIn; + } + + /** + * @return Returns the baseDir. + */ + public String getBaseDir() { + return baseDir; + } + + /** + * @param b The baseDir to set. + */ + public void setBaseDir(String b) { + this.baseDir = b; + } + + /** + * @return Returns the name. + */ + public String getName() { + return name; + } + + /** + * @param n The name to set. + */ + public void setName(String n) { + this.name = n; + } + + /** + * @return Returns the summary. + */ + public String getSummary() { + return summary; + } + + /** + * @param s The summary to set. + */ + public void setSummary(String s) { + this.summary = s; + } + + /** + * @return Returns the description. + */ + public String getDescription() { + return description; + } + + /** + * @param d The description to set. + */ + public void setDescription(String d) { + this.description = d; + } + + /** + * @return the productName id + */ + public String getProductNameLabel() { + return productNameLabel; + } + + /** + * @param p the productName idto set + */ + public void setProductNameLabel(String p) { + this.productNameLabel = p; + } + + /** + * @return the GPGCheck + */ + public Boolean isGpgCheck() { + return gpgCheck; + } + + /** + * @param gpgCheckIn the GPGCheck to set + */ + public void setGpgCheck(Boolean gpgCheckIn) { + this.gpgCheck = gpgCheckIn; + } + + /** + * @return Returns the gPGKeyUrl. + */ + public String getGpgKeyUrl() { + return gpgKeyUrl; + } + + /** + * @param k The gPGKeyUrl to set. + */ + public void setGpgKeyUrl(String k) { + gpgKeyUrl = k; + } + + /** + * @return Returns the gPGKeyId. + */ + public String getGpgKeyId() { + return gpgKeyId; + } + + /** + * @param k The gPGKeyId to set. + */ + public void setGpgKeyId(String k) { + gpgKeyId = k; + } + + /** + * @return Returns the gPGKeyFp. + */ + public String getGpgKeyFp() { + return gpgKeyFp; + } + + /** + * @param k The gPGKeyFP to set. + */ + public void setGpgKeyFp(String k) { + gpgKeyFp = k; + } + + + /** + * @return Returns the endOfLife. + */ + public Date getEndOfLifeDate() { + return longToDate(endOfLifeDate); + } + + /** + * @param endOfLifeDateIn The endOfLife to set. + */ + public void setEndOfLifeDate(Date endOfLifeDateIn) { + this.endOfLifeDate = dateToLong(endOfLifeDateIn); + } + + /** + * @return Returns the product name + */ + public String getChannelProductProduct() { + return channelProductProduct; + } + + /** + * @param channelProductProductIn The product name to set + */ + public void setChannelProductProduct(String channelProductProductIn) { + this.channelProductProduct = channelProductProductIn; + } + + /** + * @return Returns the product version + */ + public String getChannelProductVersion() { + return channelProductVersion; + } + + /** + * @param channelProductVersionIn The product version to set + */ + public void setChannelProductVersion(String channelProductVersionIn) { + this.channelProductVersion = channelProductVersionIn; + } + + /** + * @param acc public, protected, or private + */ + public void setChannelAccess(String acc) { + channelAccess = acc; + } + + /** + * @return public, protected, or private + */ + public String getChannelAccess() { + return channelAccess; + } + + /** + * @return maintainer's name + */ + public String getMaintainerName() { + return maintainerName; + } + + /** + * @param mname maintainer's name + */ + public void setMaintainerName(String mname) { + maintainerName = mname; + } + + /** + * @return maintainer's email + */ + public String getMaintainerEmail() { + return maintainerEmail; + } + + /** + * @param email maintainer's email + */ + public void setMaintainerEmail(String email) { + maintainerEmail = email; + } + + /** + * @return maintainer's phone number + */ + public String getMaintainerPhone() { + return maintainerPhone; + } + + /** + * @param phone maintainer's phone number (string) + */ + public void setMaintainerPhone(String phone) { + maintainerPhone = phone; + } + + /** + * @return channel's support policy + */ + public String getSupportPolicy() { + return supportPolicy; + } + + /** + * @param policy channel support policy + */ + public void setSupportPolicy(String policy) { + supportPolicy = policy; + } + + /** + * @return the updateTag + */ + public String getUpdateTag() { + return updateTag; + } + + /** + * @param updateTagIn the update tag + */ + public void setUpdateTag(String updateTagIn) { + updateTag = updateTagIn; + } + + /** + * @return Returns the installerUpdates. + */ + public Boolean isInstallerUpdates() { + return installerUpdates; + } + + /** + * @param installerUpdatesIn The installerUpdates to set. + */ + public void setInstallerUpdates(Boolean installerUpdatesIn) { + installerUpdates = installerUpdatesIn; + } + + @Override + public boolean equals(Object oIn) { + if (oIn == null || getClass() != oIn.getClass()) { + return false; + } + ModifyCustomChannelInfoJson that = (ModifyCustomChannelInfoJson) oIn; + return Objects.equals(getLabel(), that.getLabel()) && + Objects.equals(getPeripheralOrgId(), that.getPeripheralOrgId()) && + Objects.equals(getOriginalChannelLabel(), that.getOriginalChannelLabel()) && + Objects.equals(getBaseDir(), that.getBaseDir()) && + Objects.equals(getName(), that.getName()) && + Objects.equals(getSummary(), that.getSummary()) && + Objects.equals(getDescription(), that.getDescription()) && + Objects.equals(getProductNameLabel(), that.getProductNameLabel()) && + Objects.equals(isGpgCheck(), that.isGpgCheck()) && + Objects.equals(getGpgKeyUrl(), that.getGpgKeyUrl()) && + Objects.equals(getGpgKeyId(), that.getGpgKeyId()) && + Objects.equals(getGpgKeyFp(), that.getGpgKeyFp()) && + Objects.equals(getEndOfLifeDate(), that.getEndOfLifeDate()) && + Objects.equals(getChannelProductProduct(), that.getChannelProductProduct()) && + Objects.equals(getChannelProductVersion(), that.getChannelProductVersion()) && + Objects.equals(getChannelAccess(), that.getChannelAccess()) && + Objects.equals(getMaintainerName(), that.getMaintainerName()) && + Objects.equals(getMaintainerEmail(), that.getMaintainerEmail()) && + Objects.equals(getMaintainerPhone(), that.getMaintainerPhone()) && + Objects.equals(getSupportPolicy(), that.getSupportPolicy()) && + Objects.equals(getUpdateTag(), that.getUpdateTag()) && + Objects.equals(isInstallerUpdates(), that.isInstallerUpdates()); + } + + @Override + public int hashCode() { + return Objects.hash(getLabel(), getPeripheralOrgId(), getOriginalChannelLabel(), getBaseDir(), getName(), + getSummary(), getDescription(), getProductNameLabel(), isGpgCheck(), getGpgKeyUrl(), getGpgKeyId(), + getGpgKeyFp(), getEndOfLifeDate(), getChannelProductProduct(), getChannelProductVersion(), + getChannelAccess(), getMaintainerName(), getMaintainerEmail(), getMaintainerPhone(), + getSupportPolicy(), getUpdateTag(), isInstallerUpdates()); + } + + protected String toStringCore() { + final StringBuilder sb = new StringBuilder(); + sb.append("label='").append(label).append('\''); + sb.append(", peripheralOrgId=").append(peripheralOrgId); + sb.append(", originalChannelLabel='").append(originalChannelLabel).append('\''); + sb.append(", baseDir='").append(baseDir).append('\''); + sb.append(", name='").append(name).append('\''); + sb.append(", summary='").append(summary).append('\''); + sb.append(", description='").append(description).append('\''); + sb.append(", productNameLabel='").append(productNameLabel).append('\''); + sb.append(", gpgCheck=").append(gpgCheck); + sb.append(", gpgKeyUrl='").append(gpgKeyUrl).append('\''); + sb.append(", gpgKeyId='").append(gpgKeyId).append('\''); + sb.append(", gpgKeyFp='").append(gpgKeyFp).append('\''); + sb.append(", endOfLifeDate=").append(endOfLifeDate); + sb.append(", channelProductProduct='").append(channelProductProduct).append('\''); + sb.append(", channelProductVersion='").append(channelProductVersion).append('\''); + sb.append(", channelAccess='").append(channelAccess).append('\''); + sb.append(", maintainerName='").append(maintainerName).append('\''); + sb.append(", maintainerEmail='").append(maintainerEmail).append('\''); + sb.append(", maintainerPhone='").append(maintainerPhone).append('\''); + sb.append(", supportPolicy='").append(supportPolicy).append('\''); + sb.append(", updateTag='").append(updateTag).append('\''); + sb.append(", installerUpdates=").append(installerUpdates); + return sb.toString(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ModifyCustomChannelInfoJson{"); + sb.append(toStringCore()); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/OrgInfoJson.java b/java/code/src/com/suse/manager/model/hub/OrgInfoJson.java new file mode 100644 index 000000000000..b9e6fa774bef --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/OrgInfoJson.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import java.util.Objects; +import java.util.StringJoiner; + +public class OrgInfoJson { + + private final Long orgId; + + private final String orgName; + + /** + * Constructor + * + * @param orgIdIn the org id + * @param orgNameIn the org name + */ + public OrgInfoJson(long orgIdIn, String orgNameIn) { + orgId = orgIdIn; + orgName = orgNameIn; + } + + /** + * @return return the org id + */ + public long getOrgId() { + return orgId; + } + + /** + * @return return the org name + */ + public String getOrgName() { + return orgName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OrgInfoJson that)) { + return false; + } + return Objects.equals(orgId, that.orgId) && + Objects.equals(orgName, that.orgName); + } + + @Override + public int hashCode() { + return Objects.hash(orgId, orgName); + } + + @Override + public String toString() { + return new StringJoiner(", ", OrgInfoJson.class.getSimpleName() + "[", "]") + .add("orgId='" + getOrgId() + "'") + .add("orgName='" + getOrgName() + "'") + .toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/RegisterJson.java b/java/code/src/com/suse/manager/model/hub/RegisterJson.java new file mode 100644 index 000000000000..426dcafbe729 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/RegisterJson.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import java.util.Objects; + +public class RegisterJson { + + private String token; + + private String rootCA; + + private String gpgKey; + + /** + * Default constructor + */ + public RegisterJson() { + this(null, null, null); + } + + /** + * Builds a request instance + * @param tokenIn the token + * @param rootCAIn the root certificate + * @param gpgKeyIn the gpg key + */ + public RegisterJson(String tokenIn, String rootCAIn, String gpgKeyIn) { + this.token = tokenIn; + this.rootCA = rootCAIn; + this.gpgKey = gpgKeyIn; + } + + public String getToken() { + return token; + } + + public void setToken(String tokenIn) { + this.token = tokenIn; + } + + public String getRootCA() { + return rootCA; + } + + public void setRootCA(String rootCAIn) { + this.rootCA = rootCAIn; + } + + public String getGpgKey() { + return gpgKey; + } + + public void setGpgKey(String gpgKeyIn) { + gpgKey = gpgKeyIn; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RegisterJson that)) { + return false; + } + return Objects.equals(getToken(), that.getToken()) && + Objects.equals(getRootCA(), that.getRootCA()) && + Objects.equals(getGpgKey(), that.getGpgKey()); + } + + @Override + public int hashCode() { + return Objects.hash(getToken(), getRootCA(), getGpgKey()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("RegisterJson{"); + sb.append("token='").append(token).append('\''); + sb.append(", rootCA='").append(rootCA).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/SCCCredentialsJson.java b/java/code/src/com/suse/manager/model/hub/SCCCredentialsJson.java new file mode 100644 index 000000000000..b04362f33da2 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/SCCCredentialsJson.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import java.util.Objects; + +public class SCCCredentialsJson { + + private String username; + + private String password; + + /** + * Default constructor + */ + public SCCCredentialsJson() { + this(null, null); + } + + /** + * Builds a request instance + * @param usernameIn the username + * @param passwordIn the password + */ + public SCCCredentialsJson(String usernameIn, String passwordIn) { + this.username = usernameIn; + this.password = passwordIn; + } + + public String getUsername() { + return username; + } + + public void setUsername(String usernameIn) { + this.username = usernameIn; + } + + public String getPassword() { + return password; + } + + public void setPassword(String passwordIn) { + this.password = passwordIn; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SCCCredentialsJson that)) { + return false; + } + return Objects.equals(getUsername(), that.getUsername()) && Objects.equals(getPassword(), + that.getPassword()); + } + + @Override + public int hashCode() { + return Objects.hash(getUsername(), getPassword()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("SCCCredentialsJson{"); + sb.append("username='").append(username).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/TokenType.java b/java/code/src/com/suse/manager/model/hub/TokenType.java new file mode 100644 index 000000000000..9e40021cc48c --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/TokenType.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.redhat.rhn.common.localization.LocalizationService; +import com.redhat.rhn.domain.Labeled; + +public enum TokenType implements Labeled { + ISSUED, + CONSUMED; + + @Override + public String getLabel() { + return this.name().toLowerCase(); + } + + public String getDescription() { + return LocalizationService.getInstance().getMessage("hub.tokenType." + this.name().toLowerCase()); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/TokenTypeEnumType.java b/java/code/src/com/suse/manager/model/hub/TokenTypeEnumType.java new file mode 100644 index 000000000000..8f1334e71c7e --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/TokenTypeEnumType.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.model.hub; + +import com.redhat.rhn.domain.DatabaseEnumType; + +/** + * Maps the {@link TokenType} enum to its label + */ +public class TokenTypeEnumType extends DatabaseEnumType { + + /** + * Default Constructor + */ + public TokenTypeEnumType() { + super(TokenType.class); + } +} diff --git a/java/code/src/com/suse/manager/model/hub/test/HubFactoryTest.java b/java/code/src/com/suse/manager/model/hub/test/HubFactoryTest.java new file mode 100644 index 000000000000..27df31713fa8 --- /dev/null +++ b/java/code/src/com/suse/manager/model/hub/test/HubFactoryTest.java @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.model.hub.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.redhat.rhn.common.hibernate.HibernateFactory; +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.channel.ChannelFactory; +import com.redhat.rhn.domain.channel.test.ChannelFactoryTest; +import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; +import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.redhat.rhn.testing.BaseTestCaseWithUser; +import com.redhat.rhn.testing.TestUtils; + +import com.suse.manager.model.hub.AccessTokenDTO; +import com.suse.manager.model.hub.HubFactory; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssHub; +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssPeripheralChannels; +import com.suse.manager.model.hub.TokenType; + +import org.apache.commons.lang3.RandomStringUtils; +import org.hibernate.query.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +public class HubFactoryTest extends BaseTestCaseWithUser { + + private HubFactory hubFactory; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + hubFactory = new HubFactory(); + } + + @Test + public void testCreateIssHub() { + IssHub hub = new IssHub("hub.example.com"); + hubFactory.save(hub); + + Optional issHub = hubFactory.lookupIssHubByFqdn("hub2.example.com"); + assertFalse(issHub.isPresent(), "Hub object unexpectedly found"); + + issHub = hubFactory.lookupIssHubByFqdn("hub.example.com"); + assertTrue(issHub.isPresent(), "Hub object not found"); + assertNotNull(issHub.get().getId(), "ID should not be NULL"); + assertNotNull(issHub.get().getCreated(), "created should not be NULL"); + assertNull(issHub.get().getRootCa(), "Root CA should be NULL"); + + SCCCredentials sccCredentials = CredentialsFactory.createSCCCredentials("U123", "not so secret"); + CredentialsFactory.storeCredentials(sccCredentials); + + hub.setRootCa("----- BEGIN CA -----"); + hub.setMirrorCredentials(sccCredentials); + hubFactory.save(hub); + + issHub = hubFactory.lookupIssHubByFqdn("hub.example.com"); + assertTrue(issHub.isPresent(), "Hub object not found"); + assertEquals("----- BEGIN CA -----", issHub.get().getRootCa()); + assertEquals("U123", issHub.get().getMirrorCredentials().getUsername()); + } + + @Test + public void testCreateIssPeripheral() { + IssPeripheral peripheral = new IssPeripheral("peripheral1.example.com"); + hubFactory.save(peripheral); + + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral2.example.com"); + assertFalse(issPeripheral.isPresent(), "Peripheral object unexpectedly found"); + + issPeripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral1.example.com"); + assertTrue(issPeripheral.isPresent(), "Peripheral object not found"); + assertNotNull(issPeripheral.get().getId(), "ID should not be NULL"); + assertNotNull(issPeripheral.get().getCreated(), "created should not be NULL"); + assertNull(issPeripheral.get().getRootCa(), "Root CA should be NULL"); + + HubSCCCredentials sccCredentials = CredentialsFactory.createHubSCCCredentials("U123", "not so secret", "fqdn"); + CredentialsFactory.storeCredentials(sccCredentials); + + peripheral.setRootCa("----- BEGIN CA -----"); + peripheral.setMirrorCredentials(sccCredentials); + hubFactory.save(peripheral); + + issPeripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral1.example.com"); + assertTrue(issPeripheral.isPresent(), "Peripheral object not found"); + assertEquals("----- BEGIN CA -----", issPeripheral.get().getRootCa()); + assertEquals("U123", issPeripheral.get().getMirrorCredentials().getUsername()); + + } + + @Test + public void testCreateIssPeripheralChannels() throws Exception { + HubSCCCredentials sccCredentials = CredentialsFactory.createHubSCCCredentials("U123", "not so secret", "fqdn"); + CredentialsFactory.storeCredentials(sccCredentials); + + Channel baseChannel = ChannelFactoryTest.createBaseChannel(user); + Channel childChannel = ChannelFactoryTest.createTestChannel(user); + childChannel.setParentChannel(baseChannel); + ChannelFactory.save(baseChannel); + ChannelFactory.save(childChannel); + + IssPeripheral peripheral = new IssPeripheral("peripheral1.example.com"); + peripheral.setRootCa("----- BEGIN CA -----"); + peripheral.setMirrorCredentials(sccCredentials); + hubFactory.save(peripheral); + + IssPeripheralChannels pcBase = new IssPeripheralChannels(peripheral, baseChannel); + IssPeripheralChannels pcChild = new IssPeripheralChannels(peripheral, childChannel); + hubFactory.save(pcBase); + hubFactory.save(pcChild); + + TestUtils.flushAndEvict(peripheral); + + IssPeripheral peripheral2 = new IssPeripheral("peripheral2.example.com"); + peripheral2.setRootCa("----- BEGIN CA -----"); + peripheral2.setMirrorCredentials(sccCredentials); + hubFactory.save(peripheral2); + + IssPeripheralChannels pcBase2 = new IssPeripheralChannels(peripheral2, baseChannel); + hubFactory.save(pcBase2); + + TestUtils.flushAndEvict(peripheral2); + + Optional issPeripheral = hubFactory.lookupIssPeripheralByFqdn("peripheral1.example.com"); + assertTrue(issPeripheral.isPresent(), "Peripheral object not found"); + Set peripheralChannels = issPeripheral.get().getPeripheralChannels(); + assertEquals(2, peripheralChannels.size()); + + Optional issPeripheral2 = hubFactory.lookupIssPeripheralByFqdn("peripheral2.example.com"); + assertTrue(issPeripheral2.isPresent(), "Peripheral object not found"); + Set peripheralChannels2 = issPeripheral2.get().getPeripheralChannels(); + assertEquals(1, peripheralChannels2.size()); + + List pcWithBase = hubFactory.listIssPeripheralChannelsByChannels(baseChannel); + assertEquals(2, pcWithBase.size()); + + List pcWithChild = hubFactory.listIssPeripheralChannelsByChannels(childChannel); + assertEquals(1, pcWithChild.size()); + } + + @Test + public void testCreateAndLookupTokens() { + Instant expiration = Instant.now().truncatedTo(ChronoUnit.MINUTES).plus(60, ChronoUnit.DAYS); + + long currentTokens = countCurrentTokens(); + + hubFactory.saveToken("uyuni-hub.dev.local", "dummy-hub-token", TokenType.ISSUED, expiration); + hubFactory.saveToken("uyuni-peripheral.dev.local", "dummy-peripheral-token", TokenType.CONSUMED, expiration); + + assertEquals(currentTokens + 2, countCurrentTokens()); + + IssAccessToken hubAccessToken = hubFactory.lookupIssuedToken("dummy-hub-token"); + assertNotNull(hubAccessToken); + assertEquals("uyuni-hub.dev.local", hubAccessToken.getServerFqdn()); + assertEquals(TokenType.ISSUED, hubAccessToken.getType()); + assertEquals(Date.from(expiration), hubAccessToken.getExpirationDate()); + + IssAccessToken peripheralAccessToken = hubFactory.lookupAccessTokenFor("uyuni-peripheral.dev.local"); + assertNotNull(peripheralAccessToken); + assertEquals("dummy-peripheral-token", peripheralAccessToken.getToken()); + assertEquals(TokenType.CONSUMED, peripheralAccessToken.getType()); + assertEquals(Date.from(expiration), peripheralAccessToken.getExpirationDate()); + } + + @Test + public void canLookupTokensByFqdnAndType() { + Instant expiration = Instant.now().truncatedTo(ChronoUnit.MINUTES).plus(60, ChronoUnit.DAYS); + + hubFactory.saveToken("dummy.fqdn", "dummy-issued-token", TokenType.ISSUED, expiration); + hubFactory.saveToken("dummy.fqdn", "dummy-consumed-token", TokenType.CONSUMED, expiration); + + IssAccessToken issued = hubFactory.lookupAccessTokenByFqdnAndType("dummy.fqdn", TokenType.ISSUED); + assertNotNull(issued); + assertEquals("dummy.fqdn", issued.getServerFqdn()); + assertEquals(TokenType.ISSUED, issued.getType()); + assertEquals(Date.from(expiration), issued.getExpirationDate()); + + IssAccessToken consumed = hubFactory.lookupAccessTokenByFqdnAndType("dummy.fqdn", TokenType.CONSUMED); + assertNotNull(consumed); + assertEquals("dummy.fqdn", consumed.getServerFqdn()); + assertEquals(TokenType.CONSUMED, consumed.getType()); + assertEquals(Date.from(expiration), consumed.getExpirationDate()); + } + + @Test + public void ensureOnlyOneTokenIsStoredForFqdnAndType() { + Instant shortExpiration = Instant.now().truncatedTo(ChronoUnit.MINUTES).plus(7, ChronoUnit.DAYS); + Instant longExpiration = Instant.now().truncatedTo(ChronoUnit.MINUTES).plus(60, ChronoUnit.DAYS); + + String fqdn = getRandomFqdn(); + // Ensure no token exists with this fqdn + assertEquals(0, countCurrentTokens(fqdn)); + + // Should be possible to store both issued and consumed token + hubFactory.saveToken(fqdn, "dummy-issued-token", TokenType.ISSUED, longExpiration); + hubFactory.saveToken(fqdn, "dummy-consumed-token", TokenType.CONSUMED, longExpiration); + + assertEquals(2, countCurrentTokens(fqdn)); + + // Check if the token is correct + IssAccessToken issued = hubFactory.lookupAccessTokenByFqdnAndType(fqdn, TokenType.ISSUED); + assertNotNull(issued); + assertEquals(fqdn, issued.getServerFqdn()); + assertEquals("dummy-issued-token", issued.getToken()); + assertEquals(TokenType.ISSUED, issued.getType()); + assertEquals(Date.from(longExpiration), issued.getExpirationDate()); + + // Storing a new issued token should replace the existing one + hubFactory.saveToken(fqdn, "updated-issued-token", TokenType.ISSUED, shortExpiration); + + // We should still have 2 tokens + assertEquals(2, HibernateFactory.getSession() + .createQuery("SELECT COUNT(*) FROM IssAccessToken at WHERE at.serverFqdn = :fqdn", Long.class) + .setParameter("fqdn", fqdn) + .uniqueResult()); + + // Check if the token is updated correctly + issued = hubFactory.lookupAccessTokenByFqdnAndType(fqdn, TokenType.ISSUED); + assertNotNull(issued); + assertEquals(fqdn, issued.getServerFqdn()); + assertEquals("updated-issued-token", issued.getToken()); + assertEquals(TokenType.ISSUED, issued.getType()); + assertEquals(Date.from(shortExpiration), issued.getExpirationDate()); + } + + @Test + public void canCountAndListTokens() throws Exception { + Instant expiration = Instant.now().truncatedTo(ChronoUnit.MINUTES).plus(7, ChronoUnit.DAYS); + + Long initialTokenCount = countCurrentTokens(); + + // Store the first token + IssAccessToken tokenZero = hubFactory.saveToken(getRandomFqdn(), "zero", TokenType.ISSUED, expiration); + assertEquals(initialTokenCount + 1, hubFactory.countAccessToken()); + Thread.sleep(1_000); + + // Store multiple tokens + List generatedTokenIds = Stream.of("one", "two", "three", "four") + .map(value -> hubFactory.saveToken(getRandomFqdn(), value, TokenType.ISSUED, expiration)) + .map(IssAccessToken::getId) + .toList(); + + // Check if the count is correct + assertEquals(initialTokenCount + generatedTokenIds.size() + 1, hubFactory.countAccessToken()); + + // Ensure we can extract all items + List tokens = hubFactory.listAccessToken(0, 1_000); + assertEquals(initialTokenCount + generatedTokenIds.size() + 1, tokens.size()); + + // Ensure the list is sorted by creation date + tokens = hubFactory.listAccessToken(0, generatedTokenIds.size()); + assertEquals(generatedTokenIds.size(), tokens.size()); + + assertTrue(tokens.stream() + .allMatch(token -> generatedTokenIds.contains(token.getId()))); + + // Ensure we can skip items + tokens = hubFactory.listAccessToken(4, 1); + assertEquals(1, tokens.size()); + assertEquals(tokenZero.getId(), tokens.get(0).getId()); + } + + private static String getRandomFqdn() { + return "dummy.random.%s.fqdn".formatted(RandomStringUtils.randomAlphabetic(8)); + } + + private static Long countCurrentTokens() { + return countCurrentTokens(null); + } + + private static Long countCurrentTokens(String fqdn) { + String hql = "SELECT COUNT(*) FROM IssAccessToken at"; + if (fqdn != null) { + hql += " WHERE at.serverFqdn = :fqdn"; + } + + Query query = HibernateFactory.getSession().createQuery(hql, Long.class); + if (fqdn != null) { + query.setParameter("fqdn", fqdn); + } + + return query.uniqueResult(); + } +} diff --git a/java/code/src/com/suse/manager/reactor/messaging/RegisterMinionEventMessageAction.java b/java/code/src/com/suse/manager/reactor/messaging/RegisterMinionEventMessageAction.java index 3285d13bcbb1..a2e81f64674f 100644 --- a/java/code/src/com/suse/manager/reactor/messaging/RegisterMinionEventMessageAction.java +++ b/java/code/src/com/suse/manager/reactor/messaging/RegisterMinionEventMessageAction.java @@ -592,9 +592,6 @@ else if (!minion.getOrg().equals(org)) { minion.updateServerInfo(); - // Check for Uyuni Server and create basic info - SystemManager.updateMgrServerInfo(minion, grains); - mapHardwareGrains(minion, grains); if (isSaltSSH) { diff --git a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java index a2cb5d57103d..9c9d38f5bc41 100644 --- a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java +++ b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java @@ -158,7 +158,6 @@ public static void finishRegistration(MinionServer minion, Optional new IllegalArgumentException("Server has no key configured"))); - private static final JwtConsumer JWT_CONSUMER = new JwtConsumerBuilder() - .setVerificationKey(KEY) - .build(); - // cached value to avoid multiple calls private final String mountPointPath; @@ -93,7 +78,7 @@ public class DownloadController { ); /** - * If true, check via JWT tokens that files requested by a minion are actually accessible by that minon. Turning + * If true, check via JWT tokens that files requested by a minion are actually accessible by that minion. Turning * this flag to false disables the checks. */ private boolean checkTokens; @@ -102,6 +87,10 @@ public class DownloadController { * Invoked from Router. Initialize routes for Systems Views. */ public void initRoutes() { + get("/manager/download/hubsync/:sccrepoid/getPackage/:file", this::downloadPackageHub); + get("/manager/download/hubsync/:sccrepoid/getPackage/:org/:checksum/:file", this::downloadPackageHub); + get("/manager/download/hubsync/:sccrepoid/repodata/:file", this::downloadMetadataHub); + get("/manager/download/hubsync/:sccrepoid/media.1/:file", this::downloadMediaFilesHub); get("/manager/download/:channel/getPackage/:file", this::downloadPackage); get("/manager/download/:channel/getPackage/:org/:checksum/:file", this::downloadPackage); get("/manager/download/:channel/repodata/:file", this::downloadMetadata); @@ -110,6 +99,10 @@ public void initRoutes() { head("/manager/download/:channel/getPackage/:org/:checksum/:file", this::downloadPackage); head("/manager/download/:channel/repodata/:file", this::downloadMetadata); head("/manager/download/:channel/media.1/:file", this::downloadMediaFiles); + head("/manager/download/hubsync/:sccrepoid/getPackage/:file", this::downloadPackageHub); + head("/manager/download/hubsync/:sccrepoid/getPackage/:org/:checksum/:file", this::downloadPackageHub); + head("/manager/download/hubsync/:sccrepoid/repodata/:file", this::downloadMetadataHub); + head("/manager/download/hubsync/:sccrepoid/media.1/:file", this::downloadMediaFilesHub); } /** @@ -278,6 +271,25 @@ private boolean isKeyOrSignatureFile(String filename) { filename.equals("Release.gpg"); } + /** + * Download metadata taking the scc repo id and filename from the request path. + * + * @param request the request object + * @param response the response object + * @return an object to make spark happy + */ + public Object downloadMetadataHub(Request request, Response response) { + Long sccid = Long.parseLong(request.params(":sccrepoid")); + String filename = request.params(":file"); + + List vendorChannelBySccId = ChannelFactory.findVendorChannelBySccId(sccid); + String channelLabel = vendorChannelBySccId.stream().map(Channel::getLabel).findFirst().orElseThrow(() -> { + LOG.error("Repository for SCC ID {} not found", sccid); + return halt(HttpStatus.SC_NOT_FOUND, "Repository not found"); + }); + return downloadMetadata(request, response, channelLabel, filename); + } + /** * Download metadata taking the channel and filename from the request path. * @@ -288,6 +300,10 @@ private boolean isKeyOrSignatureFile(String filename) { public Object downloadMetadata(Request request, Response response) { String channelLabel = request.params(":channel"); String filename = request.params(":file"); + return downloadMetadata(request, response, channelLabel, filename); + } + + private Object downloadMetadata(Request request, Response response, String channelLabel, String filename) { String mountPoint = Config.get().getString(ConfigDefaults.REPOMD_CACHE_MOUNT_POINT, "/var/cache"); String prefix = Config.get().getString(ConfigDefaults.REPOMD_PATH_PREFIX, "rhn/repodata"); @@ -319,6 +335,24 @@ else if (filename.equals("modules.yaml")) { return downloadFile(request, response, file); } + /** + * Download media metadata taking the scc repo id and filename from the request path. + * + * @param request the request object + * @param response the response object + * @return an object to make spark happy + */ + public Object downloadMediaFilesHub(Request request, Response response) { + Long sccid = Long.parseLong(request.params(":sccrepoid")); + String filename = request.params(":file"); + + List vendorChannelBySccId = ChannelFactory.findVendorChannelBySccId(sccid); + String channelLabel = vendorChannelBySccId.stream().map(Channel::getLabel).findFirst().orElseThrow(() -> { + LOG.error("Repository for SCC ID {} not found", sccid); + return halt(HttpStatus.SC_NOT_FOUND, "Repository not found"); + }); + return downloadMediaFiles(request, response, channelLabel, filename); + } /** * Download media metadata taking the channel and filename from the request path. @@ -330,6 +364,10 @@ else if (filename.equals("modules.yaml")) { public Object downloadMediaFiles(Request request, Response response) { String channelLabel = request.params(":channel"); String filename = request.params(":file"); + return downloadMediaFiles(request, response, channelLabel, filename); + } + + private Object downloadMediaFiles(Request request, Response response, String channelLabel, String filename) { validatePaygCompliant(); processToken(request, channelLabel, filename); @@ -409,6 +447,28 @@ private File getMediaProductsFile(Channel channel) { return null; } + /** + * Download a package taking the scc repo id and RPM filename from the request path. + * + * @param request the request object + * @param response the response object + * @return an object to make spark happy + */ + public Object downloadPackageHub(Request request, Response response) { + + // we can't use request.params(:file) + // See https://bugzilla.suse.com/show_bug.cgi?id=972158 + // https://github.com/perwendel/spark/issues/490 + Long sccid = Long.parseLong(request.params(":sccrepoid")); + + List vendorChannelBySccId = ChannelFactory.findVendorChannelBySccId(sccid); + String channel = vendorChannelBySccId.stream().map(Channel::getLabel).findFirst().orElseThrow(() -> { + LOG.error("Repository for SCC ID {} not found", sccid); + return halt(HttpStatus.SC_NOT_FOUND, "Repository not found"); + }); + return downloadPackage(request, response, channel); + } + /** * Download a package taking the channel and RPM filename from the request path. * @@ -422,6 +482,10 @@ public Object downloadPackage(Request request, Response response) { // See https://bugzilla.suse.com/show_bug.cgi?id=972158 // https://github.com/perwendel/spark/issues/490 String channel = request.params(":channel"); + return downloadPackage(request, response, channel); + } + + private Object downloadPackage(Request request, Response response, String channel) { String path = ""; try { URI uri = new URI(request.url()); @@ -581,47 +645,43 @@ private void validateToken(String token, String channel, String filename) { sanitizeToken(token), filename) ); try { - JwtClaims claims = JWT_CONSUMER.processToClaims(token); - if (Optional.ofNullable(claims.getExpirationTime()) - .map(exp -> exp.isBefore(NumericDate.now())) + Token parsedToken = new TokenParser().usingServerSecret().parse(token); + if (Optional.ofNullable(parsedToken.getExpirationTime()) + .map(exp -> exp.isBefore(Instant.now())) .orElse(false)) { LOG.info("Forbidden: Token expired"); halt(HttpStatus.SC_FORBIDDEN, "Token expired"); } // enforce channel claim - Optional> channelClaim = Optional.ofNullable(claims.getStringListClaimValue("onlyChannels")) - // new versions of getStringListClaimValue() return an empty list instead of null - .filter(l -> !l.isEmpty()); - Opt.consume(channelClaim, - () -> LOG.info("Token ...{} does provide access to any channel", - sanitizeToken(token)), - channels -> { - if (!channels.contains(channel)) { - LOG.info("Forbidden: Token ...{} does not provide access to channel {}", - sanitizeToken(token), channel); - LOG.info("Token allow access only to the following channels: {}", String.join(",", channels)); - halt(HttpStatus.SC_FORBIDDEN, "Token does not provide access to channel " + channel); - } - }); + List onlyChannels = parsedToken.getListClaim("onlyChannels", String.class); + if (CollectionUtils.isEmpty(onlyChannels)) { + LOG.info("Token ...{} does not provide access to any channel", () -> sanitizeToken(token)); + } + else if (!onlyChannels.contains(channel)) { + LOG.info("Forbidden: Token ...{} does not provide access to channel {}", + () -> sanitizeToken(token), () -> channel); + LOG.info("Token allow access only to the following channels: {}", () -> String.join(",", onlyChannels)); + halt(HttpStatus.SC_FORBIDDEN, "Token does not provide access to channel %s".formatted(channel)); + } // enforce org claim - Optional orgClaim = Optional.ofNullable(claims.getClaimValue("org", Long.class)); - Opt.consume(orgClaim, () -> { + Long orgId = parsedToken.getClaim("org", Long.class); + if (orgId == null) { LOG.info("Forbidden: Token does not specify the organization"); halt(HttpStatus.SC_BAD_REQUEST, "Token does not specify the organization"); - }, orgId -> { - if (!ChannelFactory.isAccessibleBy(channel, orgId)) { - LOG.info("Forbidden: Token does not provide access to channel {}", channel); - halt(HttpStatus.SC_FORBIDDEN, "Token does not provide access to channel " + channel); - } - }); + } + else if (!ChannelFactory.isAccessibleBy(channel, orgId)) { + String sanitChannel = StringUtil.sanitizeLogInput(channel); + LOG.info("Forbidden: Token does not provide access to channel {}", sanitChannel); + halt(HttpStatus.SC_FORBIDDEN, "Token does not provide access to channel %s".formatted(sanitChannel)); + } } - catch (InvalidJwtException | MalformedClaimException e) { + catch (TokenParsingException e) { LOG.info("Forbidden: Token ...{} is not valid to access {} in {}: {}", - sanitizeToken(token), filename, channel, e.getMessage()); + () -> sanitizeToken(token), () -> filename, () -> channel, () -> e.getMessage()); halt(HttpStatus.SC_FORBIDDEN, - String.format("Token is not valid to access %s in %s: %s", filename, channel, e.getMessage())); + "Token is not valid to access %s in %s: %s".formatted(filename, channel, e.getMessage())); } } @@ -676,12 +736,12 @@ public void validateMinionInPayg(String token) { } else { try { - JwtClaims claims = JWT_CONSUMER.processToClaims(token); - boolean isValid = Optional.ofNullable(claims.getExpirationTime()) + Token parsedToken = new TokenParser().usingServerSecret().parse(token); + boolean isValid = Optional.ofNullable(parsedToken.getExpirationTime()) .map(exp -> { long timeDeltaSeconds = Duration.ofMinutes(EXPIRATION_TIME_MINUTES_IN_THE_FUTURE_TEMP_TOKEN) .toSeconds(); - long expireDeltaSeconds = exp.getValue() - NumericDate.now().getValue(); + long expireDeltaSeconds = exp.getEpochSecond() - NumericDate.now().getValue(); return expireDeltaSeconds > 0 && expireDeltaSeconds < timeDeltaSeconds; }) .orElse(false); @@ -691,7 +751,7 @@ public void validateMinionInPayg(String token) { halt(HttpStatus.SC_FORBIDDEN, "Forbidden: Token is expired or is not a short-token"); } } - catch (InvalidJwtException | MalformedClaimException e) { + catch (TokenParsingException e) { LOG.info("Forbidden: Short-token ...{} is not valid or is expired: {}", sanitizeToken(token), e.getMessage()); halt(HttpStatus.SC_FORBIDDEN, diff --git a/java/code/src/com/suse/manager/webui/controllers/ProductsController.java b/java/code/src/com/suse/manager/webui/controllers/ProductsController.java index b853f2380684..249f70d05cbe 100644 --- a/java/code/src/com/suse/manager/webui/controllers/ProductsController.java +++ b/java/code/src/com/suse/manager/webui/controllers/ProductsController.java @@ -47,6 +47,7 @@ import com.redhat.rhn.taskomatic.TaskomaticApiException; import com.redhat.rhn.taskomatic.domain.TaskoRun; +import com.suse.manager.model.hub.HubFactory; import com.suse.manager.model.products.ChannelJson; import com.suse.manager.model.products.Extension; import com.suse.manager.model.products.Product; @@ -138,9 +139,10 @@ public static String getMetadata(Request request, Response response, User user) private static ProductsPageMetadataJson getMetadataJson() { TaskoRun latestRun = TaskoFactory.getLatestRun("mgr-sync-refresh-bunch"); ContentSyncManager csm = new ContentSyncManager(); + HubFactory hubFactory = new HubFactory(); return new ProductsPageMetadataJson( - IssFactory.getCurrentMaster() == null, + IssFactory.getCurrentMaster() == null && !hubFactory.isISSPeripheral(), csm.isRefreshNeeded(null), latestRun != null && latestRun.getEndTime() == null, FileLocks.SCC_REFRESH_LOCK.isLocked(), @@ -152,21 +154,30 @@ private static ProductsPageMetadataJson getMetadataJson() { /** * Trigger a synchronization of Products * - * @param request the request + * @param request the request * @param response the response - * @param user the user + * @param user the user * @return a JSON flag of the success/failed result */ public static String synchronizeProducts(Request request, Response response, User user) { + return json(response, doSynchronizeProducts()); + } + + /** + * Trigger a synchronization of Products + * + * @return a boolean flag of the success/failed result + */ + public static boolean doSynchronizeProducts() { return FileLocks.SCC_REFRESH_LOCK.withFileLock(() -> { try { ContentSyncManager csm = new ContentSyncManager(); csm.updateSUSEProducts(csm.getProducts()); - return json(response, true); + return true; } catch (Exception e) { log.fatal(e.getMessage(), e); - return json(response, false); + return false; } }); } @@ -180,15 +191,24 @@ public static String synchronizeProducts(Request request, Response response, Use * @return a JSON flag of the success/failed result */ public static String synchronizeChannelFamilies(Request request, Response response, User user) { + return json(response, doSynchronizeChannelFamilies()); + } + + /** + * Trigger a synchronization of Channel Families + * + * @return a boolean flag of the success/failed result + */ + public static boolean doSynchronizeChannelFamilies() { return FileLocks.SCC_REFRESH_LOCK.withFileLock(() -> { try { ContentSyncManager csm = new ContentSyncManager(); csm.updateChannelFamilies(csm.readChannelFamilies()); - return json(response, true); + return true; } catch (Exception e) { log.fatal(e.getMessage(), e); - return json(response, false); + return false; } }); } @@ -196,21 +216,30 @@ public static String synchronizeChannelFamilies(Request request, Response respon /** * Trigger a synchronization of Repositories * - * @param request the request + * @param request the request * @param response the response - * @param user the user + * @param user the user * @return a JSON flag of the success/failed result */ public static String synchronizeRepositories(Request request, Response response, User user) { + return json(response, doSynchronizeRepositories()); + } + + /** + * Trigger a synchronization of Repositories + * + * @return a boolean flag of the success/failed result + */ + public static boolean doSynchronizeRepositories() { return FileLocks.SCC_REFRESH_LOCK.withFileLock(() -> { try { ContentSyncManager csm = new ContentSyncManager(); csm.updateRepositories(null); - return json(response, true); + return true; } catch (Exception e) { log.fatal(e.getMessage(), e); - return json(response, false); + return false; } }); } @@ -218,21 +247,30 @@ public static String synchronizeRepositories(Request request, Response response, /** * Trigger a synchronization of Subscriptions * - * @param request the request + * @param request the request * @param response the response - * @param user the user + * @param user the user * @return a JSON flag of the success/failed result */ public static String synchronizeSubscriptions(Request request, Response response, User user) { + return json(response, doSynchronizeSubscriptions()); + } + + /** + * Trigger a synchronization of Subscriptions + * + * @return a boolean flag of the success/failed result + */ + public static boolean doSynchronizeSubscriptions() { return FileLocks.SCC_REFRESH_LOCK.withFileLock(() -> { try { ContentSyncManager csm = new ContentSyncManager(); csm.updateSubscriptions(); - return json(response, true); + return true; } catch (Exception e) { log.fatal(e.getMessage(), e); - return json(response, false); + return false; } }); } diff --git a/java/code/src/com/suse/manager/webui/controllers/SystemsController.java b/java/code/src/com/suse/manager/webui/controllers/SystemsController.java index b99cc255cffb..3be963ab8549 100644 --- a/java/code/src/com/suse/manager/webui/controllers/SystemsController.java +++ b/java/code/src/com/suse/manager/webui/controllers/SystemsController.java @@ -42,7 +42,6 @@ import com.redhat.rhn.domain.rhnset.RhnSet; import com.redhat.rhn.domain.role.RoleFactory; import com.redhat.rhn.domain.server.MgrServerInfo; -import com.redhat.rhn.domain.server.MinionServer; import com.redhat.rhn.domain.server.Server; import com.redhat.rhn.domain.server.ServerFactory; import com.redhat.rhn.domain.server.ServerGroupFactory; @@ -60,6 +59,7 @@ import com.redhat.rhn.manager.system.SystemManager; import com.redhat.rhn.taskomatic.TaskomaticApiException; +import com.suse.manager.hub.HubManager; import com.suse.manager.reactor.utils.LocalDateTimeISOAdapter; import com.suse.manager.reactor.utils.OptionalTypeAdapterFactory; import com.suse.manager.utils.PagedSqlQueryBuilder; @@ -83,6 +83,8 @@ import org.apache.struts.action.ActionMessage; import org.apache.struts.action.ActionMessages; +import java.io.IOException; +import java.security.cert.CertificateException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Collections; @@ -388,14 +390,14 @@ public String mgrServerNewReportDbPassword(Request request, Response response, U return badRequest(response, "unknown_system"); } if (server.isMgrServer()) { - Optional minion = server.asMinionServer(); - if (minion.isEmpty()) { - if (LOG.isDebugEnabled()) { - LOG.error("System ({}) not a minion", StringUtil.sanitizeLogInput(sidStr)); - } - return badRequest(response, "system_not_mgr_server"); + try { + HubManager hubManager = new HubManager(); + hubManager.setReportDbUser(user, server, true); + } + catch (CertificateException | IOException e) { + LOG.error(e.getMessage(), e); + return badRequest(response, "set_reportdb_creds_failed"); } - SystemManager.setReportDbUser(minion.get(), true); } else { if (LOG.isErrorEnabled()) { diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/AdminViewsController.java b/java/code/src/com/suse/manager/webui/controllers/admin/AdminViewsController.java index 1dae8fc8f96b..581f4825ddc8 100644 --- a/java/code/src/com/suse/manager/webui/controllers/admin/AdminViewsController.java +++ b/java/code/src/com/suse/manager/webui/controllers/admin/AdminViewsController.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 SUSE LLC + * Copyright (c) 2019--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,16 +7,13 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.manager.webui.controllers.admin; import static com.suse.manager.webui.utils.SparkApplicationHelper.withCsrfToken; import static com.suse.manager.webui.utils.SparkApplicationHelper.withOrgAdmin; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withProductAdmin; import static com.suse.manager.webui.utils.SparkApplicationHelper.withUserPreferences; import static spark.Spark.get; @@ -30,6 +27,8 @@ import com.redhat.rhn.taskomatic.TaskomaticApi; import com.suse.manager.admin.PaygAdminManager; +import com.suse.manager.hub.HubManager; +import com.suse.manager.model.hub.HubFactory; import com.suse.manager.reactor.utils.OptionalTypeAdapterFactory; import com.suse.manager.webui.controllers.admin.mappers.PaygResponseMappers; import com.suse.manager.webui.utils.FlashScopeHelper; @@ -59,6 +58,7 @@ public class AdminViewsController { private static final PaygAdminManager PAYG_ADMIN_MANAGER = new PaygAdminManager(new TaskomaticApi()); + private static final HubManager HUB_MANAGER = new HubManager(); private AdminViewsController() { } @@ -79,6 +79,17 @@ public static void initRoutes(JadeTemplateEngine jade) { withUserPreferences(withCsrfToken(withOrgAdmin(AdminViewsController::showPayg))), jade); get("/manager/admin/setup/proxy", withUserPreferences(withCsrfToken(withOrgAdmin(AdminViewsController::showProxy))), jade); + + get("/manager/admin/hub/hub-details", + withUserPreferences(withCsrfToken(withOrgAdmin(AdminViewsController::showISSv3Hub))), jade); + get("/manager/admin/hub/peripherals", + withUserPreferences(withCsrfToken(withOrgAdmin(AdminViewsController::showISSv3Peripherals))), jade); + get("/manager/admin/hub/peripherals/details/:id", + withUserPreferences(withCsrfToken(withOrgAdmin(AdminViewsController::updateISSv3Peripheral))), jade); + get("/manager/admin/hub/peripherals/register", + withUserPreferences(withCsrfToken(withProductAdmin(AdminViewsController::registerPeripheral))), jade); + get("/manager/admin/hub/access-tokens", + withUserPreferences(withCsrfToken(withProductAdmin(AdminViewsController::listAccessTokens))), jade); } /** @@ -110,6 +121,46 @@ public static ModelAndView showPasswordPolicy(Request request, Response response return new ModelAndView(data, "controllers/admin/templates/password-policy.jade"); } + /** + * Show iss hub tab. + * @param request http request + * @param response http response + * @param user current user + * @return the view to show + */ + public static ModelAndView showISSv3Hub(Request request, Response response, User user) { + Map data = new HashMap<>(); + data.put("hub", HUB_MANAGER.getHub(user).orElse(null)); + return new ModelAndView(data, "controllers/admin/templates/hub-details.jade"); + } + + /** + * Show iss peripheral tab. + * @param request http request + * @param response http response + * @param user current user + * @return the view to show + */ + public static ModelAndView showISSv3Peripherals(Request request, Response response, User user) { + return new ModelAndView(new HashMap<>(), "controllers/admin/templates/list-peripherals.jade"); + } + + /** + * Show iss peripheral tab. + * @param request http request + * @param response http response + * @param user current user + * @return the view to show + */ + public static ModelAndView updateISSv3Peripheral(Request request, Response response, User user) { + Map data = new HashMap<>(); + long peripheralId = Long.parseLong(request.params("id")); + data.put("detailsData", GSON.toJson(null)); + data.put("channelsSyncData", GSON.toJson(HUB_MANAGER.getChannelSyncModelForPeripheral(user, peripheralId))); + return new ModelAndView(data, "controllers/admin/templates/update-peripheral.jade"); + } + + /** * show list of saved payg ssh connection data * @param request @@ -118,10 +169,12 @@ public static ModelAndView showPasswordPolicy(Request request, Response response * @return list of payg ssh connection data */ public static ModelAndView listPayg(Request request, Response response, User user) { + HubFactory hubFactory = new HubFactory(); Map data = new HashMap<>(); List payg = PAYG_ADMIN_MANAGER.list(); data.put("flashMessage", FlashScopeHelper.flash(request)); data.put("contentPaygInstances", GSON.toJson(PaygResponseMappers.mapPaygPropertiesResumeFromDB(payg))); + data.put("isIssPeripheral", hubFactory.isISSPeripheral()); return new ModelAndView(data, "controllers/admin/templates/payg_list.jade"); } @@ -173,4 +226,19 @@ public static ModelAndView showProxy(Request request, Response response, User us data.put("proxySettings", GSON.toJson(proxySettings)); return new ModelAndView(data, "controllers/admin/templates/proxy.jade"); } + + /** + * Register a new ISSv3 server as hub or peripheral + * @param request the request + * @param response the response + * @param user the logged-in user + * @return the registration form + */ + private static ModelAndView registerPeripheral(Request request, Response response, User user) { + return new ModelAndView(new HashMap<>(), "controllers/admin/templates/hub_register_peripheral.jade"); + } + + private static ModelAndView listAccessTokens(Request request, Response response, User user) { + return new ModelAndView(new HashMap<>(), "controllers/admin/templates/iss_token_list.jade"); + } } diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/ChannelSyncModel.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/ChannelSyncModel.java new file mode 100644 index 000000000000..262c4658f20b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/ChannelSyncModel.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.beans; + +import com.suse.manager.model.hub.OrgInfoJson; + +import java.util.List; +import java.util.Set; + +public class ChannelSyncModel { + private final List peripheralOrgs; + private final Set syncedPeripheralCustomChannels; + private final Set syncedPeripheralVendorChannels; + private final List availableCustomChannels; + private final List availableVendorChannels; + + /** + * Model for Peripheral Channel Sync get + * + * @param peripheralOrgsIn a List of Orgs available on the peripheral + * @param syncedPeripheralCustomChannelsIn the list of synced custom channels + * @param syncedPeripheralVendorChannelsIn the list of synced vendor channels + * @param availableCustomChannelsIn the list of available custom channels to sync + * @param availableVendorChannelsIn the list of available vendor channels to sync + */ + public ChannelSyncModel( + List peripheralOrgsIn, + Set syncedPeripheralCustomChannelsIn, + Set syncedPeripheralVendorChannelsIn, + List availableCustomChannelsIn, + List availableVendorChannelsIn) { + this.peripheralOrgs = peripheralOrgsIn; + // Group synced custom channels by orgId. + this.syncedPeripheralCustomChannels = syncedPeripheralCustomChannelsIn; + this.syncedPeripheralVendorChannels = syncedPeripheralVendorChannelsIn; + this.availableCustomChannels = availableCustomChannelsIn; + this.availableVendorChannels = availableVendorChannelsIn; + } + + public List getPeripheralOrgs() { + return peripheralOrgs; + } + + public Set getSyncedPeripheralCustomChannels() { + return syncedPeripheralCustomChannels; + } + + public Set getSyncedPeripheralVendorChannels() { + return syncedPeripheralVendorChannels; + } + + public List getAvailableCustomChannels() { + return availableCustomChannels; + } + + public List getAvailableVendorChannels() { + return availableVendorChannels; + } +} + + + diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/CreateTokenRequest.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/CreateTokenRequest.java new file mode 100644 index 000000000000..a81a0d271f8b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/CreateTokenRequest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.beans; + +import com.suse.manager.model.hub.TokenType; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +public class CreateTokenRequest { + + private TokenType type; + + private String fqdn; + + private String token; + + public TokenType getType() { + return type; + } + + public void setType(TokenType typeIn) { + this.type = typeIn; + } + + public String getFqdn() { + return fqdn; + } + + public void setFqdn(String fqdnIn) { + this.fqdn = fqdnIn; + } + + public String getToken() { + return token; + } + + public void setToken(String tokenIn) { + this.token = tokenIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof CreateTokenRequest that)) { + return false; + } + + return new EqualsBuilder() + .append(getType(), that.getType()) + .append(getFqdn(), that.getFqdn()) + .append(getToken(), that.getToken()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(getType()) + .append(getFqdn()) + .append(getToken()) + .toHashCode(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("CreateTokenRequest{"); + sb.append("type=").append(type); + sb.append(", fqdn='").append(fqdn).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/HubRegisterRequest.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/HubRegisterRequest.java new file mode 100644 index 000000000000..a571533453ab --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/HubRegisterRequest.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.beans; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +public class HubRegisterRequest { + + private String fqdn; + + private String token; + + private String username; + + private String password; + + private String rootCA; + + public String getFqdn() { + return fqdn; + } + + public void setFqdn(String fqdnIn) { + this.fqdn = fqdnIn; + } + + public String getToken() { + return token; + } + + public void setToken(String tokenIn) { + this.token = tokenIn; + } + + public String getUsername() { + return username; + } + + public void setUsername(String usernameIn) { + this.username = usernameIn; + } + + public String getPassword() { + return password; + } + + public void setPassword(String passwordIn) { + this.password = passwordIn; + } + + public String getRootCA() { + return rootCA; + } + + public void setRootCA(String rootCAIn) { + this.rootCA = rootCAIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof HubRegisterRequest that)) { + return false; + } + + return new EqualsBuilder() + .append(getFqdn(), that.getFqdn()) + .append(getToken(), that.getToken()) + .append(getUsername(), that.getUsername()) + .append(getPassword(), that.getPassword()) + .append(getRootCA(), that.getRootCA()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(getFqdn()) + .append(getToken()) + .append(getUsername()) + .append(getPassword()) + .append(getRootCA()) + .toHashCode(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("HubRegisterRequest{"); + sb.append("fqdn='").append(fqdn).append('\''); + sb.append(", token='").append(token).append('\''); + sb.append(", username='").append(username).append('\''); + sb.append(", rootCA='").append(rootCA).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3ChannelResponse.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3ChannelResponse.java new file mode 100644 index 000000000000..ade8c2fde60d --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3ChannelResponse.java @@ -0,0 +1,74 @@ +package com.suse.manager.webui.controllers.admin.beans; + +public class IssV3ChannelResponse { + + private final Long channelId; + private final String channelName; + private final String channelLabel; + private final String channelArch; + private final ChannelOrgResponse channelOrg; + /** + * Response with the channel info to fill the channel sync section of the ui + * @param channelIdIn the channel id + * @param channelNameIn the channel name + * @param channelLabelIn the channel label + * @param channelArchIn the channel architecture + * @param channelOrgIn the organization model, if null is a vendor channel + */ + public IssV3ChannelResponse( + Long channelIdIn, + String channelNameIn, + String channelLabelIn, + String channelArchIn, + ChannelOrgResponse channelOrgIn + ) { + channelId = channelIdIn; + channelName = channelNameIn; + channelLabel = channelLabelIn; + channelArch = channelArchIn; + channelOrg = channelOrgIn; + } + + public Long getChannelId() { + return channelId; + } + + public String getChannelName() { + return channelName; + } + + public String getChannelLabel() { + return channelLabel; + } + + public String getChannelArch() { + return channelArch; + } + + public ChannelOrgResponse getChannelOrg() { + return channelOrg; + } + + public static class ChannelOrgResponse { + private final Long orgId; + private final String orgName; + + /** + * Model for the channel organization + * @param orgIdIn + * @param orgNameIn + */ + public ChannelOrgResponse(Long orgIdIn, String orgNameIn) { + orgId = orgIdIn; + orgName = orgNameIn; + } + + public Long getOrgId() { + return orgId; + } + + public String getOrgName() { + return orgName; + } + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3HubResponse.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3HubResponse.java new file mode 100644 index 000000000000..95b6cbc3a42b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3HubResponse.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.controllers.admin.beans; + +public class IssV3HubResponse { + private String fqdn; + private boolean defaultHub; + private int knownOrgs; + private int unmappedOrgs; + + public IssV3HubResponse(String fqdnIn, boolean defaultHubIn, int knownOrgsIn, int unmappedOrgsIn) { + fqdn = fqdnIn; + defaultHub = defaultHubIn; + knownOrgs = knownOrgsIn; + unmappedOrgs = unmappedOrgsIn; + } + + public IssV3HubResponse() {} + + public String getFqdn() { + return fqdn; + } + + public void setFqdn(String fqdnIn) { + fqdn = fqdnIn; + } + + public boolean isDefaultHub() { + return defaultHub; + } + + public void setDefaultHub(boolean defaultHubIn) { + defaultHub = defaultHubIn; + } + + public int getKnownOrgs() { + return knownOrgs; + } + + public void setKnownOrgs(int knownOrgsIn) { + knownOrgs = knownOrgsIn; + } + + public int getUnmappedOrgs() { + return unmappedOrgs; + } + + public void setUnmappedOrgs(int unmappedOrgsIn) { + unmappedOrgs = unmappedOrgsIn; + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralDetailResponse.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralDetailResponse.java new file mode 100644 index 000000000000..b3204151b7a3 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralDetailResponse.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.controllers.admin.beans; + +public class IssV3PeripheralDetailResponse { +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralsResponse.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralsResponse.java new file mode 100644 index 000000000000..fc248409e31b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/IssV3PeripheralsResponse.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.controllers.admin.beans; + +import com.suse.manager.model.hub.IssPeripheral; +import com.suse.manager.model.hub.IssPeripheralChannels; + +public class IssV3PeripheralsResponse { + private final long id; + private final String fqdn; + private final long nChannelsSync; + private final long nSyncOrgs; + private final String rootCA; + + /** + * Response for peripherals list + * @param idIn + * @param fqdnIn + * @param nChannelsSyncIn + * @param nSyncOrgsIn + * @param rootCAIn + */ + public IssV3PeripheralsResponse( + long idIn, + String fqdnIn, + long nChannelsSyncIn, + long nSyncOrgsIn, + String rootCAIn + ) { + id = idIn; + fqdn = fqdnIn; + nChannelsSync = nChannelsSyncIn; + nSyncOrgs = nSyncOrgsIn; + rootCA = rootCAIn; + } + + public long getId() { + return id; + } + + public String getFqdn() { + return fqdn; + } + + public long getNChannelsSync() { + return nChannelsSync; + } + + public long getNSyncOrgs() { + return nSyncOrgs; + } + + public String getRootCA() { + return rootCA; + } + + /** + * Helper converter method from db entity + * @param peripheralEntity + * @return + */ + public static IssV3PeripheralsResponse fromIssEntity(IssPeripheral peripheralEntity) { + return new IssV3PeripheralsResponse( + peripheralEntity.getId(), + peripheralEntity.getFqdn(), + peripheralEntity.getPeripheralChannels().size(), + peripheralEntity.getPeripheralChannels().stream() + .map(IssPeripheralChannels::getPeripheralOrgId).distinct().count(), + peripheralEntity.getRootCa()); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/PeripheralResponse.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/PeripheralResponse.java new file mode 100644 index 000000000000..941bf8b507db --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/PeripheralResponse.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.beans; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +public class PeripheralResponse { + private long id; + private String fqdn; + private long nChannelsSync; + private long nAllChannels; + private long nOrgs; + + public PeripheralResponse(long id, String fqdn, long nChannelsSync, long nAllChannels, long nOrgs) { + this.id = id; + this.fqdn = fqdn; + this.nChannelsSync = nChannelsSync; + this.nAllChannels = nAllChannels; + this.nOrgs = nOrgs; + } + + public long getId() { + return id; + } + + public void setId(long idIn) { + this.id = idIn; + } + + public String getFqdn() { + return fqdn; + } + + public void setFqdn(String fqdnIn) { + this.fqdn = fqdnIn; + } + + public long getnChannelsSync() { + return nChannelsSync; + } + + public void setnChannelsSync(long nChannelsSyncIn) { + this.nChannelsSync = nChannelsSyncIn; + } + + public long getnAllChannels() { + return nAllChannels; + } + + public void setnAllChannels(long nAllChannelsIn) { + this.nAllChannels = nAllChannelsIn; + } + + public long getnOrgs() { + return nOrgs; + } + + public void setnOrgs(long nOrgsIn) { + this.nOrgs = nOrgsIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof PeripheralResponse that)) { + return false; + } + + return new EqualsBuilder() + .append(getId(), that.getId()) + .append(getnChannelsSync(), that.getnChannelsSync()) + .append(getnAllChannels(), that.getnAllChannels()) + .append(getnOrgs(), that.getnOrgs()) + .append(getFqdn(), that.getFqdn()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(getId()) + .append(getFqdn()) + .append(getnChannelsSync()) + .append(getnAllChannels()) + .append(getnOrgs()) + .toHashCode(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("PeripheralResponse{"); + sb.append("id=").append(id); + sb.append(", fqdn='").append(fqdn).append('\''); + sb.append(", nChannelsSync=").append(nChannelsSync); + sb.append(", nAllChannels=").append(nAllChannels); + sb.append(", nOrgs=").append(nOrgs); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/beans/ValidityRequest.java b/java/code/src/com/suse/manager/webui/controllers/admin/beans/ValidityRequest.java new file mode 100644 index 000000000000..d963b88c918e --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/beans/ValidityRequest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.beans; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +public class ValidityRequest { + private boolean valid; + + public boolean isValid() { + return valid; + } + + public void setValid(boolean validIn) { + this.valid = validIn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof ValidityRequest that)) { + return false; + } + + return new EqualsBuilder() + .append(isValid(), that.isValid()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(isValid()) + .toHashCode(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ValidityRequest{"); + sb.append("valid=").append(valid); + sb.append('}'); + return sb.toString(); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/handlers/HubApiController.java b/java/code/src/com/suse/manager/webui/controllers/admin/handlers/HubApiController.java new file mode 100644 index 000000000000..e03899c53d55 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/admin/handlers/HubApiController.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.controllers.admin.handlers; + +import static com.suse.manager.webui.utils.SparkApplicationHelper.badRequest; +import static com.suse.manager.webui.utils.SparkApplicationHelper.internalServerError; +import static com.suse.manager.webui.utils.SparkApplicationHelper.json; +import static com.suse.manager.webui.utils.SparkApplicationHelper.notFound; +import static com.suse.manager.webui.utils.SparkApplicationHelper.success; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withProductAdmin; +import static spark.Spark.delete; +import static spark.Spark.get; +import static spark.Spark.patch; +import static spark.Spark.post; + +import com.redhat.rhn.common.localization.LocalizationService; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.listview.PageControl; +import com.redhat.rhn.taskomatic.TaskomaticApiException; + +import com.suse.manager.hub.HubManager; +import com.suse.manager.hub.InvalidResponseException; +import com.suse.manager.model.hub.AccessTokenDTO; +import com.suse.manager.model.hub.IssAccessToken; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.model.hub.IssServer; +import com.suse.manager.model.hub.TokenType; +import com.suse.manager.webui.controllers.ECMAScriptDateAdapter; +import com.suse.manager.webui.controllers.admin.beans.ChannelSyncModel; +import com.suse.manager.webui.controllers.admin.beans.CreateTokenRequest; +import com.suse.manager.webui.controllers.admin.beans.HubRegisterRequest; +import com.suse.manager.webui.controllers.admin.beans.IssV3PeripheralsResponse; +import com.suse.manager.webui.controllers.admin.beans.ValidityRequest; +import com.suse.manager.webui.utils.FlashScopeHelper; +import com.suse.manager.webui.utils.PageControlHelper; +import com.suse.manager.webui.utils.gson.PagedDataResultJson; +import com.suse.manager.webui.utils.gson.ResultJson; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenException; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.security.cert.CertificateException; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import javax.net.ssl.SSLException; + +import spark.Request; +import spark.Response; + +public class HubApiController { + private static final Logger LOGGER = LogManager.getLogger(HubApiController.class); + + private static final LocalizationService LOC = LocalizationService.getInstance(); + + private final HubManager hubManager; + + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(Date.class, new ECMAScriptDateAdapter()) + .serializeNulls() + .create(); + + /** + * Default constructor + */ + public HubApiController() { + this(new HubManager()); + } + + /** + * Creates an instance with the given dependencies + * @param hubManagerIn the hub manager + */ + public HubApiController(HubManager hubManagerIn) { + this.hubManager = hubManagerIn; + } + + private String pass(Request request, Response response, User satAdmin) { + throw new NotImplementedException(); + } + + /** + * initialize all the API Routes for the ISSv3 support + * */ + public void initRoutes() { + // Hub management + get("/manager/api/admin/hub", withProductAdmin(this::pass)); + get("/manager/api/admin/hub/:id", withProductAdmin(this::pass)); + + // Peripherals management + get("/manager/api/admin/hub/peripherals/list", withProductAdmin(this::listPaginatedPeripherals)); + post("/manager/api/admin/hub/peripherals", withProductAdmin(this::registerPeripheral)); + get("/manager/api/admin/hub/peripheral/:id", withProductAdmin(this::pass)); + patch("/manager/api/admin/hub/peripheral/:id", withProductAdmin(this::pass)); + delete("/manager/api/admin/hub/peripheral/:id", withProductAdmin(this::deletePeripheral)); + + // Peripheral channels management + get("/manager/api/admin/hub/peripheral/:id/channels", withProductAdmin(this::getPeripheralChannelSyncStatus)); + post("/manager/api/admin/hub/peripheral/:id/channels", withProductAdmin(this::syncChannelsToPeripheral)); + delete("/manager/api/admin/hub/peripheral/:id/channels", withProductAdmin(this::desyncChannelsToPeripheral)); + + // Token management + get("/manager/api/admin/hub/access-tokens", withProductAdmin(this::listTokens)); + post("/manager/api/admin/hub/access-tokens", withProductAdmin(this::createToken)); + post("/manager/api/admin/hub/access-tokens/:id/validity", withProductAdmin(this::setAccessTokenValidity)); + delete("/manager/api/admin/hub/access-tokens/:id", withProductAdmin(this::deleteAccessToken)); + } + + private String getPeripheralChannelSyncStatus(Request request, Response response, User satAdmin) { + long peripheralId = Long.parseLong(request.params("id")); + ChannelSyncModel syncModel = hubManager.getChannelSyncModelForPeripheral(satAdmin, peripheralId); + return json(response, GSON.toJson(syncModel)); + } + + // Define a functional interface that allows throwing CertificateException + @FunctionalInterface + private interface ChannelsOperation { + void apply(User satAdmin, long peripheralId, List channelsId) throws CertificateException, IOException; + } + + // Common helper method to process the request + private String processChannelsOperation( + Request request, Response response, User satAdmin, ChannelsOperation operation + ) { + long peripheralId = Long.parseLong(request.params("id")); + Type listType = new TypeToken>() { }.getType(); + List channelsId = GSON.fromJson(request.body(), listType); + try { + operation.apply(satAdmin, peripheralId, channelsId); + } + catch (CertificateException eIn) { + LOGGER.error("Unable to parse the specified root certificate for the peripheral {}", peripheralId, eIn); + return badRequest(response, LOC.getMessage("hub.invalid_root_ca")); + } + catch (IOException eIn) { + LOGGER.error("Error while attempting to connect to peripheral server {}", peripheralId, eIn); + return internalServerError(response, LOC.getMessage("hub.error_connecting_remote")); + } + return success(response); + } + + private String syncChannelsToPeripheral(Request request, Response response, User satAdmin) { + return processChannelsOperation(request, response, satAdmin, hubManager::syncChannelsByIdForPeripheral); + } + + private String desyncChannelsToPeripheral(Request request, Response response, User satAdmin) { + return processChannelsOperation(request, response, satAdmin, hubManager::desyncChannelsByIdForPeripheral); + } + + private String listPaginatedPeripherals(Request request, Response response, User satAdmin) { + PageControlHelper pageHelper = new PageControlHelper(request); + PageControl pc = pageHelper.getPageControl(); + long totalSize = hubManager.countRegisteredPeripherals(satAdmin); + List peripherals = hubManager.listRegisteredPeripherals(satAdmin, pc).stream() + .map(IssV3PeripheralsResponse::fromIssEntity).toList(); + TypeToken> type = new TypeToken<>() { }; + return json(GSON, response, new PagedDataResultJson<>(peripherals, totalSize, Collections.emptySet()), type); + } + + private String deletePeripheral(Request request, Response response, User satAdmin) { + long peripheralId = Long.parseLong(request.params("id")); + try { + hubManager.deregister(satAdmin, peripheralId); + } + catch (CertificateException eIn) { + LOGGER.error("Unable to parse the specified root certificate for the peripheral {}", peripheralId, eIn); + return badRequest(response, LOC.getMessage("hub.invalid_root_ca")); + } + catch (IOException eIn) { + LOGGER.error("Error while attempting to connect to peripheral server {}", peripheralId, eIn); + return internalServerError(response, LOC.getMessage("hub.error_connecting_remote")); + } + return success(response); + } + + + private String registerPeripheral(Request request, Response response, User satAdmin) { + HubRegisterRequest issRequest; + + try { + issRequest = validateRegisterRequest(GSON.fromJson(request.body(), HubRegisterRequest.class)); + } + catch (JsonSyntaxException ex) { + LOGGER.error("Unable to parse JSON request", ex); + return badRequest(response, LOC.getMessage("hub.invalid_request")); + } + + String remoteServer = issRequest.getFqdn(); + + try { + String rootCA = issRequest.getRootCA(); + String token = issRequest.getToken(); + + if (StringUtils.isNotEmpty(token)) { + hubManager.register(satAdmin, remoteServer, token, rootCA); + } + else { + String username = issRequest.getUsername(); + String password = issRequest.getPassword(); + + hubManager.register(satAdmin, remoteServer, username, password, rootCA); + } + + FlashScopeHelper.flash(request, LOC.getMessage("hub.register_peripheral_success", remoteServer)); + + // Lookup the registered peripheral and return the id + IssServer peripheral = hubManager.findServer(satAdmin, remoteServer, IssRole.PERIPHERAL); + return success(response, ResultJson.success(peripheral.getId())); + } + catch (CertificateException ex) { + LOGGER.error("Unable to parse the specified root certificate {}", issRequest.getRootCA(), ex); + return badRequest(response, LOC.getMessage("hub.invalid_root_ca")); + } + catch (TaskomaticApiException ex) { + LOGGER.error("Unexpected error while processing root certificate {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.unexpected_error_root_ca")); + } + catch (TokenBuildingException ex) { + LOGGER.error("Unable issue a token for remote peripheral {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.unable_issue_token")); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the token received from peripheral {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.unable_parse_token")); + } + catch (SSLException ex) { + LOGGER.error("Unable to establish a secure connection to {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.unable_establish_secure_connection")); + } + catch (InvalidResponseException ex) { + LOGGER.error("Invalid response received from the remote server {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.invalid_remote_response")); + } + catch (IOException ex) { + LOGGER.error("Error while attempting to connect to remote server {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.error_connecting_remote")); + } + catch (RuntimeException ex) { + LOGGER.error("Unexpected error while registering remote server {}", remoteServer, ex); + return internalServerError(response, LOC.getMessage("hub.unexpected_error")); + } + } + + private String listTokens(Request request, Response response, User user) { + PageControlHelper pageHelper = new PageControlHelper(request); + PageControl pc = pageHelper.getPageControl(); + + long totalSize = hubManager.countAccessToken(user); + + List accessTokens = hubManager.listAccessToken(user, pc); + TypeToken> type = new TypeToken<>() { }; + return json(GSON, response, new PagedDataResultJson<>(accessTokens, totalSize, Collections.emptySet()), type); + } + + private String createToken(Request request, Response response, User user) { + CreateTokenRequest tokenRequest; + + try { + tokenRequest = validateCreationRequest(GSON.fromJson(request.body(), CreateTokenRequest.class)); + } + catch (JsonSyntaxException ex) { + LOGGER.error("Unable to parse JSON request", ex); + return badRequest(response, LOC.getMessage("hub.invalid_request")); + } + + switch (tokenRequest.getType()) { + case ISSUED -> { + try { + String serializedToken = hubManager.issueAccessToken(user, tokenRequest.getFqdn()); + return success(response, ResultJson.success(serializedToken)); + } + catch (TokenException | RuntimeException ex) { + LOGGER.error("Error while attempting to issue a token for {}", tokenRequest.getFqdn(), ex); + return internalServerError(response, "hub.unable_to_issue_token"); + } + } + case CONSUMED -> { + try { + hubManager.storeAccessToken(user, tokenRequest.getFqdn(), tokenRequest.getToken()); + return success(response, ResultJson.success(tokenRequest.getToken())); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the token of request {}", tokenRequest, ex); + return badRequest(response, "hub.unable_to_parse_token"); + } + catch (RuntimeException ex) { + LOGGER.error("Unable to process the store token request {}", tokenRequest, ex); + return internalServerError(response, "hub.unable_to_store_token"); + } + } + default -> { + return badRequest(response, "hub.invalid_request"); + } + } + } + + private String setAccessTokenValidity(Request request, Response response, User user) { + ValidityRequest validityRequest = GSON.fromJson(request.body(), ValidityRequest.class); + long tokenId = Long.parseLong(request.params("id")); + + IssAccessToken issAccessToken = hubManager.lookupAccessTokenById(user, tokenId).orElse(null); + if (issAccessToken == null) { + return notFound(response, LOC.getMessage("hub.invalid_token_id")); + } + + if (issAccessToken.isValid() == validityRequest.isValid()) { + return badRequest(response, LOC.getMessage("hub.invalid_token_state")); + } + + issAccessToken.setValid(validityRequest.isValid()); + hubManager.updateToken(user, issAccessToken); + + return success(response, ResultJson.success(issAccessToken.getModified())); + } + + private String deleteAccessToken(Request request, Response response, User user) { + long tokenId = Long.parseLong(request.params("id")); + + boolean result = hubManager.deleteAccessToken(user, tokenId); + if (!result) { + return badRequest(response, LOC.getMessage("hub.unable_delete_token")); + } + + return success(response); + } + + private static HubRegisterRequest validateRegisterRequest(HubRegisterRequest parsedRequest) { + if (StringUtils.isEmpty(parsedRequest.getFqdn())) { + throw new JsonSyntaxException("Missing required server FQDN in the request"); + } + + boolean missingToken = parsedRequest.getToken() == null; + boolean missingUserPassword = parsedRequest.getUsername() == null || parsedRequest.getPassword() == null; + if (missingToken && missingUserPassword) { + throw new JsonSyntaxException("Either token or username/password must be provided"); + } + + return parsedRequest; + } + + private static CreateTokenRequest validateCreationRequest(CreateTokenRequest request) { + if (request == null) { + throw new JsonSyntaxException("Request is empty"); + } + + if (StringUtils.isEmpty(request.getFqdn()) || request.getType() == null) { + throw new JsonSyntaxException("Missing required field"); + } + + // Check if the token field is consistent with the type requested + boolean tokenPresent = StringUtils.isNotEmpty(request.getToken()); + + if (request.getType() == TokenType.ISSUED && tokenPresent) { + throw new JsonSyntaxException("Token must be null when creating an ISSUED token"); + } + + if (request.getType() == TokenType.CONSUMED && !tokenPresent) { + throw new JsonSyntaxException("Token is required when creating a CONSUMED token"); + } + + return request; + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/admin/mappers/PaygResponseMappers.java b/java/code/src/com/suse/manager/webui/controllers/admin/mappers/PaygResponseMappers.java index 835318c6f1e8..ad325b6cb070 100644 --- a/java/code/src/com/suse/manager/webui/controllers/admin/mappers/PaygResponseMappers.java +++ b/java/code/src/com/suse/manager/webui/controllers/admin/mappers/PaygResponseMappers.java @@ -33,8 +33,8 @@ public class PaygResponseMappers { private PaygResponseMappers() { } /** - * map a list of ssh connection data objets fro database to UI objects containing only the sumary data - * @param paygSshDataDB list of payg ssh connection data from the databse + * map a list of ssh connection data objets from database to UI objects containing only the summary data + * @param paygSshDataDB list of payg ssh connection data from the database * @return a list of PaygResumeResponse */ public static List mapPaygPropertiesResumeFromDB(List paygSshDataDB) { @@ -53,7 +53,7 @@ public static List mapPaygPropertiesResumeFromDB(List params = new HashMap<>(); - params.put(token, ""); + params.put(token.getSerializedForm(), ""); params.put("2ndtoken", ""); Request request = getMockRequestWithParams(params); @@ -419,16 +408,14 @@ public void testTwoTokens() throws Exception { @Test public void testTokenDifferentChannel() throws Exception { // The added token is for a different channel - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.onlyChannels( - new HashSet<>( - Arrays.asList(channel.getLabel() + "WRONG"))); - String tokenOtherChannel = tokenBuilder.getToken(); + Token tokenOtherChannel = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .allowingOnlyChannels(Set.of(channel.getLabel() + "WRONG")) + .build(); Map params = new HashMap<>(); - params.put(tokenOtherChannel, ""); + params.put(tokenOtherChannel.getSerializedForm(), ""); Request request = getMockRequestWithParams(params); try { @@ -449,15 +436,13 @@ public void testTokenDifferentChannel() throws Exception { */ @Test public void testTokenWrongOrg() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId() + 1); - tokenBuilder.useServerSecret(); - tokenBuilder.onlyChannels( - new HashSet<>( - Arrays.asList(channel.getLabel()))); - String tokenOtherOrg = tokenBuilder.getToken(); + Token tokenOtherOrg = new DownloadTokenBuilder(user.getOrg().getId() + 1) + .usingServerSecret() + .allowingOnlyChannels(Set.of(channel.getLabel())) + .build(); Map params = new HashMap<>(); - params.put(tokenOtherOrg, ""); + params.put(tokenOtherOrg.getSerializedForm(), ""); Request request = getMockRequestWithParams(params); try { @@ -559,12 +544,12 @@ private void testCorrectChannel(Function requestFactory) throws * @throws Exception if anything goes wrong */ private void testCorrectChannel(Supplier pkgFile, Function requestFactory) throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.onlyChannels( - new HashSet<>( - Arrays.asList(channel.getLabel()))); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .allowingOnlyChannels(Set.of(channel.getLabel())) + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenChannel = accessToken.getToken(); Request request = requestFactory.apply(tokenChannel); @@ -583,10 +568,11 @@ private void testCorrectChannel(Supplier pkgFile, Function(Arrays.asList(channel.getLabel()))); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret().allowingOnlyChannels(Set.of(channel.getLabel())) + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenChannel = accessToken.getToken(); Map params = new HashMap<>(); @@ -612,9 +598,11 @@ public void testDownloadPackageWithSpecialCharacters() throws Exception { */ @Test public void testCorrectOrg() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenOrg = accessToken.getToken(); Map params = new HashMap<>(); @@ -641,11 +629,12 @@ public void testCorrectOrg() throws Exception { */ @Test public void testExpiredToken() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - // already expired - tokenBuilder.setExpirationTimeMinutesInTheFuture(-1); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(-1) // already expired + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String expiredToken = accessToken.getToken(); Map params = new HashMap<>(); @@ -696,9 +685,11 @@ public void testPaygNotCompliant() { */ @Test public void testDownloadComps() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenOrg = accessToken.getToken(); Map params = new HashMap<>(); @@ -750,9 +741,10 @@ public void testDownloadComps() throws Exception { */ @Test public void testDownloadModules() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .build(); + AccessToken accessToken = saveTokenToDataBase(token); String tokenOrg = accessToken.getToken(); Map params = new HashMap<>(); @@ -803,9 +795,11 @@ public void testDownloadModules() throws Exception { */ @Test public void testDownloadMediaProducts() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenOrg = accessToken.getToken(); Map params = new HashMap<>(); @@ -857,9 +851,11 @@ public void testDownloadMediaProducts() throws Exception { */ @Test public void testDownloadMissingFile() throws Exception { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilder.useServerSecret(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilder); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .build(); + + AccessToken accessToken = saveTokenToDataBase(token); String tokenOrg = accessToken.getToken(); Map params = new HashMap<>(); @@ -931,11 +927,12 @@ public void testValidateMinionOnPaygWithProducts() throws Exception { HibernateFactory.getSession().flush(); // Test case - Token passed minion has no products - DownloadTokenBuilder tokenBuilderPass = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilderPass.useServerSecret(); - tokenBuilderPass.setExpirationTimeMinutesInTheFuture(30); + Token token = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(30) + .build(); - AccessToken accessToken = saveTokenToDataBase(tokenBuilderPass); + AccessToken accessToken = saveTokenToDataBase(token); accessToken.setMinion(minion); TestUtils.saveAndFlush(accessToken); try { @@ -981,48 +978,54 @@ public void testValidateMinionInPaygShortToken() { downloadController = new DownloadController(cpg); // Test case - Token passed is not a short-token (must fail) - DownloadTokenBuilder tokenBuilderFail = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilderFail.useServerSecret(); - tokenBuilderFail.setExpirationTimeMinutesInTheFuture(360); try { - downloadController.validateMinionInPayg(tokenBuilderFail.getToken()); + Token tokenFail = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(360) + .build(); + + downloadController.validateMinionInPayg(tokenFail.getSerializedForm()); fail("Long lived token shouldn't have been accepted"); } catch (spark.HaltException e) { assertEquals(HttpStatus.SC_FORBIDDEN, e.getStatusCode()); assertTrue(e.getBody().contains("Forbidden: Token is expired or is not a short-token")); } - catch (JoseException e) { + catch (TokenBuildingException e) { fail("There was an issue when building the test token"); } // Test case - Token passed is short-lived (must pass) - DownloadTokenBuilder tokenBuilderPass = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilderPass.useServerSecret(); - tokenBuilderPass.setExpirationTimeMinutesInTheFuture(30); try { - downloadController.validateMinionInPayg(tokenBuilderPass.getToken()); + Token tokenPass = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(30) + .build(); + + downloadController.validateMinionInPayg(tokenPass.getSerializedForm()); } catch (spark.HaltException e) { fail("Short-lived token must've been accepted"); } - catch (JoseException e) { + catch (TokenBuildingException e) { fail("There was an issue when building the test token"); } // Test case - Token passed is expired - DownloadTokenBuilder tokenBuilderExpired = new DownloadTokenBuilder(user.getOrg().getId()); - tokenBuilderExpired.useServerSecret(); - tokenBuilderExpired.setExpirationTimeMinutesInTheFuture(-15); try { - downloadController.validateMinionInPayg(tokenBuilderExpired.getToken()); + Token tokenExpired = new DownloadTokenBuilder(user.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(-15) + .build(); + + downloadController.validateMinionInPayg(tokenExpired.getSerializedForm()); fail("A token in the past must no be accepted"); } catch (spark.HaltException e) { assertEquals(HttpStatus.SC_FORBIDDEN, e.getStatusCode()); assertTrue(e.getBody().contains("Forbidden: Short-token is not valid or is expired")); } - catch (JoseException e) { + catch (TokenBuildingException e) { fail("There was an issue when building the test token"); } } diff --git a/java/code/src/com/suse/manager/webui/menu/MenuTree.java b/java/code/src/com/suse/manager/webui/menu/MenuTree.java index 64b2b070f239..e128022255a5 100644 --- a/java/code/src/com/suse/manager/webui/menu/MenuTree.java +++ b/java/code/src/com/suse/manager/webui/menu/MenuTree.java @@ -452,7 +452,17 @@ private MenuItem getAdminNode(Map adminRoles) { .addChild(new MenuItem("Master Setup").withPrimaryUrl("/rhn/admin/iss/Master.do") .withVisibility(adminRoles.get("satellite"))) .addChild(new MenuItem("Slave Setup").withPrimaryUrl("/rhn/admin/iss/Slave.do") - .withVisibility(adminRoles.get("satellite")))) + .withVisibility(adminRoles.get("satellite"))) + ) + .addChild(new MenuItem("Hub Configuration") + .withVisibility(adminRoles.get("satellite")) + .withPrimaryUrl("/rhn/manager/admin/hub/peripherals") + .addChild(new MenuItem("Hub Details").withPrimaryUrl("/rhn/manager/admin/hub/hub-details") + .withVisibility(adminRoles.get("satellite"))) + .addChild(new MenuItem("Peripherals Configuration") + .withPrimaryUrl("/rhn/manager/admin/hub/peripherals") + .withVisibility(adminRoles.get("satellite"))) + ) .addChild(new MenuItem("Task Schedules") .withPrimaryUrl("/rhn/admin/SatSchedules.do") .withAltUrl("/rhn/admin/BunchDetail.do") diff --git a/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java b/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java index df29e6316e65..36f9f306c5f6 100644 --- a/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java +++ b/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016 SUSE LLC + * Copyright (c) 2016--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.manager.webui.services; @@ -115,12 +111,13 @@ import com.suse.manager.webui.services.iface.SaltApi; import com.suse.manager.webui.services.impl.SaltSSHService; import com.suse.manager.webui.services.pillar.MinionPillarManager; -import com.suse.manager.webui.utils.DownloadTokenBuilder; import com.suse.manager.webui.utils.SaltModuleRun; import com.suse.manager.webui.utils.SaltState; import com.suse.manager.webui.utils.SaltSystemReboot; import com.suse.manager.webui.utils.salt.custom.MgrActionChains; import com.suse.manager.webui.utils.salt.custom.ScheduleMetadata; +import com.suse.manager.webui.utils.token.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.TokenBuildingException; import com.suse.salt.netapi.calls.LocalAsyncResult; import com.suse.salt.netapi.calls.LocalCall; import com.suse.salt.netapi.calls.modules.State; @@ -153,7 +150,6 @@ import org.cobbler.Distro; import org.cobbler.Profile; import org.cobbler.SystemRecord; -import org.jose4j.lang.JoseException; import java.io.File; import java.io.IOException; @@ -1392,18 +1388,19 @@ private Map, List> imageInspectAction( } private String getChannelUrl(MinionServer minion, String channelLabel) { - DownloadTokenBuilder tokenBuilder = new DownloadTokenBuilder(minion.getOrg().getId()); - tokenBuilder.useServerSecret(); - tokenBuilder.setExpirationTimeMinutesInTheFuture( - Config.get().getInt(ConfigDefaults.TEMP_TOKEN_LIFETIME) - ); - tokenBuilder.onlyChannels(Collections.singleton(channelLabel)); - String token = ""; + String token; + try { - token = tokenBuilder.getToken(); - } - catch (JoseException e) { + token = new DownloadTokenBuilder(minion.getOrg().getId()) + .usingServerSecret() + .expiringAfterMinutes(Config.get().getInt(ConfigDefaults.TEMP_TOKEN_LIFETIME)) + .allowingOnlyChannels(Set.of(channelLabel)) + .build() + .getSerializedForm(); + } + catch (TokenBuildingException e) { LOG.error("Could not generate token for {}", channelLabel, e); + token = ""; } String host = minion.getChannelHost(); diff --git a/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java b/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java index b16f1ac97f68..d7eb8c54d446 100644 --- a/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java +++ b/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 SUSE LLC + * Copyright (c) 2015--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,10 +7,6 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.manager.webui.utils; @@ -679,6 +675,26 @@ public static String message(Response response, String message) { return json(response, Collections.singletonMap("message", message), new TypeToken<>() { }); } + /** + * Serialize a success + * @param response the http response + * @return a JSON string + */ + public static String success(Response response) { + return json(response, ResultJson.success(), new TypeToken<>() { }); + } + + /** + * Serialize a success + * @param response the http response + * @param data the data + * @return a JSON string + * @param the type of data + */ + public static String success(Response response, T data) { + return json(response, data, new TypeToken<>() { }); + } + /** * Serialize the result and set the response content type to JSON. * @param response the http response diff --git a/java/code/src/com/suse/manager/webui/utils/TokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/TokenBuilder.java deleted file mode 100644 index b5dd2e8bc283..000000000000 --- a/java/code/src/com/suse/manager/webui/utils/TokenBuilder.java +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2015 SUSE LLC - * - * This software is licensed to you under the GNU General Public License, - * version 2 (GPLv2). There is NO WARRANTY for this software, express or - * implied, including the implied warranties of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 - * along with this software; if not, see - * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. - */ -package com.suse.manager.webui.utils; - -import com.redhat.rhn.common.conf.Config; -import com.redhat.rhn.common.conf.ConfigDefaults; - -import org.apache.commons.codec.DecoderException; -import org.apache.commons.codec.binary.Hex; -import org.jose4j.jwa.AlgorithmConstraints; -import org.jose4j.jws.AlgorithmIdentifiers; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.jwt.JwtClaims; -import org.jose4j.jwt.NumericDate; -import org.jose4j.jwt.consumer.InvalidJwtException; -import org.jose4j.jwt.consumer.JwtConsumer; -import org.jose4j.jwt.consumer.JwtConsumerBuilder; -import org.jose4j.keys.HmacKey; -import org.jose4j.lang.JoseException; - -import java.security.Key; -import java.time.Instant; -import java.util.Optional; - -/** - * Utility functions to generate JWT tokens. - */ -public class TokenBuilder { - - private static final int NOT_BEFORE_MINUTES = 2; - - /** - * The secret used to generate the token signature - */ - private Optional secret = Optional.empty(); - - /** - * Optional expiration date for the token - * (minutes in the future) - * - * @note: Default is a year - */ - private long expirationTimeMinutesInTheFuture = Config.get().getInt( - ConfigDefaults.TOKEN_LIFETIME, - 525600 - ); - - private Instant issuedAt = Instant.now(); - - /** - * Use the server configured secret key string as the secret. - */ - public void useServerSecret() { - this.secret = - Optional.ofNullable(Config.get().getString("server.secret_key")); - if (!this.secret.isPresent()) { - throw new IllegalArgumentException("Server has no secret key"); - } - } - - /** - * Sets the secret to derive the key to sign the token. - * - * @param secretIn the secret to use - * (Has to be a hex (even-length) string) - */ - public void setSecret(String secretIn) { - this.secret = Optional.ofNullable(secretIn); - if (!this.secret.isPresent()) { - throw new IllegalArgumentException("Invalid secret"); - } - } - - /** - * @return the server secret if set or {@link Optional#empty()} - */ - public static Optional getServerSecret() { - return Optional.ofNullable(Config.get().getString("server.secret_key")); - } - - /** - * Create a cryptographic key from the given secret. - * - * @param secret the secret to use for generating the key in hex - * string format - * @return the key - */ - public static Key getKeyForSecret(String secret) { - try { - byte[] bytes = Hex.decodeHex(secret.toCharArray()); - return new HmacKey(bytes); - } - catch (DecoderException e) { - throw new IllegalArgumentException(e); - } - } - - /** - * Set expiration time of the token. - * @param minutes minutes in the future when the token expires - */ - public void setExpirationTimeMinutesInTheFuture(long minutes) { - this.expirationTimeMinutesInTheFuture = minutes; - } - - /** - * get the currently set expiration time in minutes in the future. - * @return expiration time in minutes in the future. - */ - public long getExpirationTimeMinutesInTheFuture() { - return expirationTimeMinutesInTheFuture; - } - - /** - * set the issued at date. - * @param issuedAtIn issued at data. - */ - public void setIssuedAt(Instant issuedAtIn) { - this.issuedAt = issuedAtIn; - } - - /** - * get the issued at data. - * @return issued at data. - */ - public Instant getIssuedAt() { - return issuedAt; - } - - /** - * @return the current token JWT claims - */ - public JwtClaims getClaims() { - JwtClaims claims = new JwtClaims(); - claims.setExpirationTimeMinutesInTheFuture(expirationTimeMinutesInTheFuture); - claims.setIssuedAt(NumericDate.fromSeconds(issuedAt.getEpochSecond())); - claims.setNotBeforeMinutesInThePast(NOT_BEFORE_MINUTES); - claims.setGeneratedJwtId(); - return claims; - } - - /** - * @return a download token with the current builder parameters. - * @throws JoseException if there is an error generating the token - */ - public String getToken() throws JoseException { - JwtClaims claims = getClaims(); - - JsonWebSignature jws = new JsonWebSignature(); - jws.setPayload(claims.toJson()); - jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); - jws.setKey(getKeyForSecret( - this.secret.orElseThrow( - () -> new IllegalArgumentException("No secret has been set")))); - - return jws.getCompactSerialization(); - } - - - /** - * Verify that a token is one that we built. - * - * @param token token to verify - * - * @return true if the token is valid, false otherwise - */ - public static boolean verifyToken(String token) { - JwtConsumer consumer = new JwtConsumerBuilder() - .setRequireExpirationTime() - .setVerificationKey(getKeyForSecret( - getServerSecret().orElseThrow( - () -> new IllegalArgumentException("No secret has been set")))) - .setJwsAlgorithmConstraints( - new AlgorithmConstraints( - AlgorithmConstraints.ConstraintType.WHITELIST, - AlgorithmIdentifiers.HMAC_SHA256) - ) - .build(); - try { - consumer.processToClaims(token); - } - catch (InvalidJwtException e) { - return false; - } - return true; - } -} diff --git a/java/code/src/com/suse/manager/webui/utils/test/TokenBuilderTest.java b/java/code/src/com/suse/manager/webui/utils/test/TokenBuilderTest.java deleted file mode 100644 index bdf41babe356..000000000000 --- a/java/code/src/com/suse/manager/webui/utils/test/TokenBuilderTest.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2015--2021 SUSE LLC - * - * This software is licensed to you under the GNU General Public License, - * version 2 (GPLv2). There is NO WARRANTY for this software, express or - * implied, including the implied warranties of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 - * along with this software; if not, see - * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. - */ -package com.suse.manager.webui.utils.test; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.redhat.rhn.testing.BaseTestCaseWithUser; -import com.redhat.rhn.testing.TestUtils; - -import com.suse.manager.webui.utils.TokenBuilder; - -import org.apache.commons.codec.digest.DigestUtils; -import org.jose4j.jwt.NumericDate; -import org.junit.jupiter.api.Test; - -import java.security.Key; - -/** - * Tests for the TokenBuilder class. - */ -public class TokenBuilderTest extends BaseTestCaseWithUser { - - @Test - public void testGetKey() { - String secret = DigestUtils.sha256Hex(TestUtils.randomString()); - Key key = TokenBuilder.getKeyForSecret(secret); - assertNotNull(key); - assertEquals(32, key.getEncoded().length); - } - - @Test - public void testGetKeyConvert() { - String secret = DigestUtils.sha256Hex("0123456789abcd"); - Key key = TokenBuilder.getKeyForSecret(secret); - assertNotNull(key); - assertArrayEquals(new byte[]{ - -88, 44, -110, 39, -52, 84, -57, 71, 86, 32, -50, -123, -70, 31, -54, 30, 111, - 82, -84, -119, -99, 20, -82, 114, -21, 38, 65, 25, -50, 88, 44, -8}, key.getEncoded()); - } - - @Test - public void testExpectsHexSecret() { - try { - // randomString() len is 13 - TokenBuilder.getKeyForSecret(TestUtils.randomString()); - fail("secret should be a hex string"); - } - catch (IllegalArgumentException e) { - assertContains(e.getMessage(), "Odd number of characters."); - } - } - - @Test - public void testDefaultExpiresInAYear() throws Exception { - TokenBuilder tokenBuilder = new TokenBuilder(); - tokenBuilder.useServerSecret(); - NumericDate expDate = tokenBuilder.getClaims().getExpirationTime(); - assertNotNull(expDate); - } - - @Test - public void testVerifyToken() throws Exception { - TokenBuilder tokenBuilder = new TokenBuilder(); - tokenBuilder.useServerSecret(); - String token = tokenBuilder.getToken(); - assertTrue(TokenBuilder.verifyToken(token)); - } - - @Test - public void testWrongOriginToken() { - String wrongOriginToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva" + - "G4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; - assertFalse(TokenBuilder.verifyToken(wrongOriginToken)); - } -} diff --git a/java/code/src/com/suse/manager/webui/utils/token/AbstractTokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/token/AbstractTokenBuilder.java new file mode 100644 index 000000000000..bb9c893fd76b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/AbstractTokenBuilder.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2015--2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.webui.utils.token; + +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; + +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.NumericDate; +import org.jose4j.lang.JoseException; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +/** + * Base class for builders of JWT tokens + * + * @param the type of the builder class, used for allowing method chaining in the derived implementations + */ +abstract class AbstractTokenBuilder> extends SecretHolder { + + private static final int NOT_BEFORE_MINUTES = 2; + + // Optional expiration date for the token (minutes in the future). Default is one year. + private long expirationTimeMinutesInTheFuture = Config.get().getLong(ConfigDefaults.TOKEN_LIFETIME, 525_600L); + + // Number of minutes before the token becomes valid + private long notBeforeMinutesInThePast = NOT_BEFORE_MINUTES; + + private Instant issuedAt = Instant.now(); + + /** + * Use the server configured secret key string as the secret. + * @return the builder + */ + public B usingServerSecret() { + setSecret(Objects.requireNonNull(Config.get().getString("server.secret_key"), "Server has no secret key")); + return self(); + } + + /** + * Sets the secret to derive the key to sign the token. + * + * @param secretIn the secret to use (Has to be a hex (even-length) string) + * @return the builder + */ + public B withCustomSecret(String secretIn) { + setSecret(Objects.requireNonNull(secretIn, "Invalid secret")); + return self(); + } + + /** + * Set the number of minutes before which the token is not valid + * @param minutes the number of minutes + * @return the builder + */ + public B validBeforeMinutes(long minutes) { + this.notBeforeMinutesInThePast = minutes; + return self(); + } + + /** + * Set expiration time of the token. + * @param minutes minutes in the future when the token expires + * @return the builder + */ + public B expiringAfterMinutes(long minutes) { + expirationTimeMinutesInTheFuture = minutes; + return self(); + } + + /** + * set the issued at date. + * @param issuedAtIn issued at data. + * @return the builder + */ + public B issuedAt(Instant issuedAtIn) { + issuedAt = issuedAtIn; + return self(); + } + + /** + * @return the current token JWT claims + */ + protected JwtClaims getClaims() { + JwtClaims claims = new JwtClaims(); + + // Compute directly the expiration and not before, instead of relying on the methods provided by jose4j + // Those methods use now() instead of the issuing date, causing flakiness in the tests + Instant expiration = issuedAt.plus(expirationTimeMinutesInTheFuture, ChronoUnit.MINUTES); + Instant notBefore = issuedAt.minus(notBeforeMinutesInThePast, ChronoUnit.MINUTES); + + claims.setGeneratedJwtId(); + claims.setExpirationTime(NumericDate.fromSeconds(expiration.getEpochSecond())); + claims.setIssuedAt(NumericDate.fromSeconds(issuedAt.getEpochSecond())); + claims.setNotBefore(NumericDate.fromSeconds(notBefore.getEpochSecond())); + + return claims; + } + + /** + * @return a download token with the current builder parameters. + * @throws TokenBuildingException if there is an error generating the token + */ + public Token build() throws TokenBuildingException { + JwtClaims claims = getClaims(); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); + jws.setKey(getKeyForSecret()); + + try { + return new Token(jws.getCompactSerialization(), claims); + } + catch (JoseException ex) { + throw new TokenBuildingException("Unable to create token", ex); + } + } + + @SuppressWarnings("unchecked") + private B self() { + return (B) this; + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/DefaultTokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/token/DefaultTokenBuilder.java new file mode 100644 index 000000000000..de643ac5a627 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/DefaultTokenBuilder.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +public class DefaultTokenBuilder extends AbstractTokenBuilder { +} diff --git a/java/code/src/com/suse/manager/webui/utils/DownloadTokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/token/DownloadTokenBuilder.java similarity index 70% rename from java/code/src/com/suse/manager/webui/utils/DownloadTokenBuilder.java rename to java/code/src/com/suse/manager/webui/utils/token/DownloadTokenBuilder.java index fccbf324ee3f..d20c811e7f94 100644 --- a/java/code/src/com/suse/manager/webui/utils/DownloadTokenBuilder.java +++ b/java/code/src/com/suse/manager/webui/utils/token/DownloadTokenBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 SUSE LLC + * Copyright (c) 2015--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,29 +7,24 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ -package com.suse.manager.webui.utils; +package com.suse.manager.webui.utils.token; import org.jose4j.jwt.JwtClaims; +import java.util.ArrayList; import java.util.Optional; import java.util.Set; /** * Utility functions to generate download access tokens. */ -public class DownloadTokenBuilder extends TokenBuilder { +public class DownloadTokenBuilder extends AbstractTokenBuilder { - /** - * The organization the token will give access to - */ + // The organization the token will give access to private final long orgId; - /** + /* * By default, a token gives access to all channels in the organization. * If this is set, only the specified channels will be allowed * (whitelist of channel label list) @@ -48,21 +43,21 @@ public DownloadTokenBuilder(long orgIdIn) { * The token would only allow access to the given list of channels * in the organization. * @param channels list of channels to allow access to + * @return the builder */ - public void onlyChannels(Set channels) { + public DownloadTokenBuilder allowingOnlyChannels(Set channels) { this.onlyChannels = Optional.of(channels); + return this; } /** * @return the current token JWT claims */ @Override - public JwtClaims getClaims() { + protected JwtClaims getClaims() { JwtClaims claims = super.getClaims(); claims.setClaim("org", this.orgId); - onlyChannels.ifPresent(channels -> - claims.setStringListClaim("onlyChannels", - channels.stream().toList())); + onlyChannels.ifPresent(channels -> claims.setStringListClaim("onlyChannels", new ArrayList<>(channels))); return claims; } } diff --git a/java/code/src/com/suse/manager/webui/utils/token/IssTokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/token/IssTokenBuilder.java new file mode 100644 index 000000000000..01004b41dce0 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/IssTokenBuilder.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +import org.jose4j.jwt.JwtClaims; + +public class IssTokenBuilder extends AbstractTokenBuilder { + + private final String fqdn; + + /** + * Create an instance specifying the FQDN + * + * @param fqdnIn the FQDN of the hub/peripheral this token belongs to + */ + public IssTokenBuilder(String fqdnIn) { + this.fqdn = fqdnIn; + } + + @Override + public JwtClaims getClaims() { + JwtClaims claims = super.getClaims(); + claims.setClaim("fqdn", fqdn); + return claims; + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/RemoteTokenException.java b/java/code/src/com/suse/manager/webui/utils/token/RemoteTokenException.java new file mode 100644 index 000000000000..33b97f34fced --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/RemoteTokenException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +/** + * An exception happening remotely during the processing of a token + */ +public class RemoteTokenException extends TokenException { + + /** + * Builds an instance with the given message + * @param message the message + */ + public RemoteTokenException(String message) { + super(message); + } + + /** + * Builds an instance with the given cause and message + * @param message the message + * @param cause what caused this exception + */ + public RemoteTokenException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/SecretHolder.java b/java/code/src/com/suse/manager/webui/utils/token/SecretHolder.java new file mode 100644 index 000000000000..83c88d986665 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/SecretHolder.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; +import org.jose4j.keys.HmacKey; + +import java.security.Key; +import java.util.Optional; + +/** + * A simple class to hold a secret and create a key from it + */ +public class SecretHolder { + + // The secret used to generate the token signature + private Optional secret; + + /** + * Builds a holder with an empty secret + */ + public SecretHolder() { + this.secret = Optional.empty(); + } + + /** + * Builds a holder with the given secret + * @param secretIn the secret + */ + public SecretHolder(String secretIn) { + this.secret = Optional.of(secretIn); + } + + public Optional getSecret() { + return secret; + } + + /** + * Removes the current stored secret + */ + public void clearSecret() { + this.secret = Optional.empty(); + } + + public void setSecret(String secretIn) { + this.secret = Optional.of(secretIn); + } + + /** + * Create a cryptographic key from the current secret. + * @return the key + */ + public Key getKeyForSecret() { + char[] secretData = secret.map(String::toCharArray) + .orElseThrow(() -> new IllegalArgumentException("No secret has been set")); + + try { + byte[] bytes = Hex.decodeHex(secretData); + return new HmacKey(bytes); + } + catch (DecoderException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/Token.java b/java/code/src/com/suse/manager/webui/utils/token/Token.java new file mode 100644 index 000000000000..8e024f0c6e19 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/Token.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +import com.suse.utils.Exceptions; + +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.MalformedClaimException; +import org.jose4j.jwt.NumericDate; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * A JWT token + */ +public class Token { + + private final JwtClaims claims; + + private final String serializedForm; + + /** + * Builds an instance with the given token and claims + * @param serializedFormIn the serialized form of the token + * @param jwtClaims the claims contained in the token + */ + public Token(String serializedFormIn, JwtClaims jwtClaims) { + this.claims = jwtClaims; + this.serializedForm = serializedFormIn; + } + + /** + * Retrieves the JWT ID + * @return the JWT id + * @throws TokenParsingException if parsing the claims fails + */ + public String getJwtId() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> claims.getJwtId(), + ex -> new TokenParsingException("Unable to parse jwt id", ex) + ); + } + + /** + * Retrieves the subject + * @return the subject + * @throws TokenParsingException if parsing the claims fails + */ + public String getSubject() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> claims.getSubject(), + ex -> new TokenParsingException("Unable to parse subject", ex) + ); + } + + /** + * Retrieves the audience + * @return the audience + * @throws TokenParsingException if parsing the claims fails + */ + public List getAudience() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> claims.getAudience(), + ex -> new TokenParsingException("Unable to parse audience", ex) + ); + } + + /** + * Retrieves the instant when the token was issued + * @return the instant when the token was issued + * @throws TokenParsingException if parsing the claims fails + */ + public Instant getIssuingTime() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> numericDateToInstant(claims.getIssuedAt()), + ex -> new TokenParsingException("Unable to parse the issued time", ex) + ); + } + + /** + * Retrieves the instant when the token expires + * @return the instant when the token expires + * @throws TokenParsingException if parsing the claims fails + */ + public Instant getExpirationTime() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> numericDateToInstant(claims.getExpirationTime()), + ex -> new TokenParsingException("Unable to parse the expiration time", ex) + ); + } + + /** + * Retrieves the instant before which the token is not valid + * @return the instant before which the token is not valid + * @throws TokenParsingException if parsing the claims fails + */ + public Instant getNotBeforeTime() throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> numericDateToInstant(claims.getNotBefore()), + ex -> new TokenParsingException("Unable to parse the not before time", ex) + ); + } + + /** + * Retrieves the specified claim from the token + * @param claim the name of the claim + * @param claimType the type of the claim + * @return the claim extracted from the token + * @param the type of the claim + * @throws TokenParsingException if parsing the claims fails + */ + public T getClaim(String claim, Class claimType) throws TokenParsingException { + return Exceptions.handleByWrapping( + () -> claims.getClaimValue(claim, claimType), + ex -> new TokenParsingException("Unable to parse claim %s".formatted(claim), ex) + ); + } + + /** + * Retrieves the specified claim from the token. The claim value must be a list. + * @param claim the name of the claim + * @param listItemType the type of items of the list claim + * @return the claim extracted from the token + * @param the type of item of the list claim + * @throws TokenParsingException if parsing the claims fails + */ + public List getListClaim(String claim, Class listItemType) throws TokenParsingException { + try { + List uncheckedList = claims.getClaimValue(claim, List.class); + if (uncheckedList == null) { + return List.of(); + } + + return uncheckedList.stream() + .map(listItemType::cast) + .toList(); + } + catch (MalformedClaimException ex) { + throw new TokenParsingException("Unable to parse claim %s".formatted(claim), ex); + } + catch (ClassCastException ex) { + throw new TokenParsingException( + "Some items of the list %s are not of type %s".formatted(claim, listItemType.getName()) + ); + } + } + + /** + * Retrieves the serialized form of the token + * @return the serialized token + */ + public String getSerializedForm() { + return serializedForm; + } + + /** + * Converts a jose4j {@link NumericDate} to a standard java {@link Instant}. + * + * @param claimValue the numeric date to convert + * @return the instant representing the same numeric date + */ + private static Instant numericDateToInstant(NumericDate claimValue) { + return Optional.ofNullable(claimValue) + .map(NumericDate::getValue) + .map(Instant::ofEpochSecond) + .orElse(null); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/TokenBuildingException.java b/java/code/src/com/suse/manager/webui/utils/token/TokenBuildingException.java new file mode 100644 index 000000000000..204ab567d635 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/TokenBuildingException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +/** + * An exception happening during the generation of a token + */ +public class TokenBuildingException extends TokenException { + + /** + * Builds an instance with the given message + * @param message the message + */ + public TokenBuildingException(String message) { + super(message); + } + + /** + * Builds an instance with the given cause and message + * @param message the message + * @param cause what caused this exception + */ + public TokenBuildingException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/TokenException.java b/java/code/src/com/suse/manager/webui/utils/token/TokenException.java new file mode 100644 index 000000000000..173371f15adc --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/TokenException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +/** + * An exception happening while processing a token + */ +public class TokenException extends Exception { + + /** + * Builds an instance with the given message + * @param message the message + */ + public TokenException(String message) { + super(message); + } + + /** + * Builds an instance with the given cause and message + * @param message the message + * @param cause what caused this exception + */ + public TokenException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/TokenParser.java b/java/code/src/com/suse/manager/webui/utils/token/TokenParser.java new file mode 100644 index 000000000000..4524c1f0ca98 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/TokenParser.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +import com.redhat.rhn.common.conf.Config; + +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; + +import java.util.Objects; + +public class TokenParser extends SecretHolder { + + private boolean verifySignature = true; + + private boolean verifyNotBefore = false; + + private boolean verifyExpiration = false; + + /** + * Skip the verification of the signature + * @return the parser + */ + public TokenParser skippingSignatureVerification() { + this.verifySignature = false; + clearSecret(); + return this; + } + + /** + * Use the server configured secret key string as the secret. + * @return the parser + */ + public TokenParser usingServerSecret() { + setSecret(Objects.requireNonNull(Config.get().getString("server.secret_key"), "Server has no secret key")); + return this; + } + + /** + * Sets the secret to derive the key to sign the token. + * + * @param secretIn the secret to use (Has to be a hex (even-length) string) + * @return the parser + */ + public TokenParser withCustomSecret(String secretIn) { + setSecret(Objects.requireNonNull(secretIn, "Invalid secret")); + return this; + } + + /** + * Skip the verification of the expiration date + * @return the parser + */ + public TokenParser skippingExpirationVerification() { + this.verifyExpiration = false; + return this; + } + + /** + * Enforce the verification of the expiration date + * @return the parser + */ + public TokenParser verifyingExpiration() { + this.verifyExpiration = true; + return this; + } + + /** + * Skip the verification of the not-before date + * @return the parser + */ + public TokenParser skippingNotBeforeVerification() { + this.verifyNotBefore = false; + return this; + } + + /** + * Enforce the verification of the not-before date + * @return the parser + */ + public TokenParser verifyingNotBefore() { + this.verifyNotBefore = true; + return this; + } + + /** + * Verify that a token is valid. + * + * @param token token to verify + * + * @return true if the token is valid, false otherwise + */ + public boolean verify(String token) { + JwtConsumerBuilder builder = createJwtConsumerBuilder(); + + try { + builder.build().processToClaims(token); + } + catch (InvalidJwtException e) { + return false; + } + + return true; + } + + /** + * Extract the claims from the token + * @param serializedForm the token + * @return the parsed claims + */ + public Token parse(String serializedForm) throws TokenParsingException { + + JwtConsumerBuilder jwtConsumerBuilder = createJwtConsumerBuilder(); + + try { + return new Token(serializedForm, jwtConsumerBuilder.build().processToClaims(serializedForm)); + } + catch (InvalidJwtException ex) { + throw new TokenParsingException("Unable to parse token claims", ex); + } + } + + private JwtConsumerBuilder createJwtConsumerBuilder() { + JwtConsumerBuilder jwtConsumerBuilder = new JwtConsumerBuilder() + .setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.HMAC_SHA256); + + if (verifySignature) { + jwtConsumerBuilder.setVerificationKey(getKeyForSecret()); + } + else { + jwtConsumerBuilder.setSkipSignatureVerification(); + } + + if (verifyExpiration) { + jwtConsumerBuilder.setRequireExpirationTime(); + } + + if (verifyNotBefore) { + jwtConsumerBuilder.setRequireNotBefore(); + } + + return jwtConsumerBuilder; + } + +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/TokenParsingException.java b/java/code/src/com/suse/manager/webui/utils/token/TokenParsingException.java new file mode 100644 index 000000000000..ff39aa90cb15 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/TokenParsingException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token; + +/** + * An exception happening while parsing a token + */ +public class TokenParsingException extends TokenException { + + /** + * Builds an instance with the given message + * @param message the message + */ + public TokenParsingException(String message) { + super(message); + } + + /** + * Builds an instance with the given cause and message + * @param message the message + * @param cause what caused this exception + */ + public TokenParsingException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/WebSockifyTokenBuilder.java b/java/code/src/com/suse/manager/webui/utils/token/WebSockifyTokenBuilder.java similarity index 78% rename from java/code/src/com/suse/manager/webui/utils/WebSockifyTokenBuilder.java rename to java/code/src/com/suse/manager/webui/utils/token/WebSockifyTokenBuilder.java index 742304c61c91..6d0a03a45bcb 100644 --- a/java/code/src/com/suse/manager/webui/utils/WebSockifyTokenBuilder.java +++ b/java/code/src/com/suse/manager/webui/utils/token/WebSockifyTokenBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 SUSE LLC + * Copyright (c) 2019--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -12,18 +12,18 @@ * granted to use or replicate Red Hat trademarks that are incorporated * in this software or its documentation. */ -package com.suse.manager.webui.utils; +package com.suse.manager.webui.utils.token; import org.jose4j.jwt.JwtClaims; /** * Utility functions to generate WebSockify tokens. */ -public class WebSockifyTokenBuilder extends TokenBuilder { +public class WebSockifyTokenBuilder extends AbstractTokenBuilder { - private String host; + private final String host; - private int port; + private final int port; /** * Construct a token builder @@ -33,11 +33,11 @@ public class WebSockifyTokenBuilder extends TokenBuilder { public WebSockifyTokenBuilder(String hostIn, int portIn) { this.host = hostIn; this.port = portIn; - setExpirationTimeMinutesInTheFuture(5); + expiringAfterMinutes(5); } @Override - public JwtClaims getClaims() { + protected JwtClaims getClaims() { JwtClaims claims = super.getClaims(); claims.setClaim("host", this.host); claims.setClaim("port", this.port); diff --git a/java/code/src/com/suse/manager/webui/utils/token/test/DefaultTokenBuilderTest.java b/java/code/src/com/suse/manager/webui/utils/token/test/DefaultTokenBuilderTest.java new file mode 100644 index 000000000000..bed6783ecb5e --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/test/DefaultTokenBuilderTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2015--2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.manager.webui.utils.token.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.suse.manager.webui.utils.token.DefaultTokenBuilder; +import com.suse.manager.webui.utils.token.Token; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * Tests for the AbstractTokenBuilder class. + */ +public class DefaultTokenBuilderTest { + + @Test + public void testDefaultValues() throws Exception { + // Truncate the reference time to the seconds to avoid flakiness + Instant referenceTime = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + Token token = new DefaultTokenBuilder() + .issuedAt(referenceTime) + .usingServerSecret() + .build(); + + // Check the issuing time is correct + assertNotNull(token.getIssuingTime()); + assertEquals(referenceTime, token.getIssuingTime()); + + // By default, expiration should be 1 year + assertNotNull(token.getExpirationTime()); + assertEquals(365, Duration.between(referenceTime, token.getExpirationTime()).toDays()); + + // By default, the token is valid even 2 minutes before being issued + assertNotNull(token.getNotBeforeTime()); + assertEquals(2, Duration.between(token.getNotBeforeTime(), referenceTime).toMinutes()); + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/test/SecretHolderTest.java b/java/code/src/com/suse/manager/webui/utils/token/test/SecretHolderTest.java new file mode 100644 index 000000000000..45c72bb53c39 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/test/SecretHolderTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token.test; + +import static com.redhat.rhn.testing.RhnBaseTestCase.assertContains; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import com.redhat.rhn.testing.TestUtils; + +import com.suse.manager.webui.utils.token.SecretHolder; + +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.Test; + +import java.security.Key; + +public class SecretHolderTest { + + @Test + public void testGetKey() { + String secret = DigestUtils.sha256Hex(TestUtils.randomString()); + SecretHolder secretHolder = new SecretHolder(secret); + Key key = secretHolder.getKeyForSecret(); + + assertNotNull(key); + assertEquals(32, key.getEncoded().length); + } + + @Test + public void testGetKeyConvert() { + String secret = DigestUtils.sha256Hex("0123456789abcd"); + SecretHolder secretHolder = new SecretHolder(secret); + Key key = secretHolder.getKeyForSecret(); + + assertNotNull(key); + assertArrayEquals(new byte[]{ + -88, 44, -110, 39, -52, 84, -57, 71, 86, 32, -50, -123, -70, 31, -54, 30, 111, + 82, -84, -119, -99, 20, -82, 114, -21, 38, 65, 25, -50, 88, 44, -8}, key.getEncoded()); + } + + @Test + public void testExpectsHexSecret() { + try { + // randomString() len is 13 + String wrongSecret = TestUtils.randomString(); + SecretHolder secretHolder = new SecretHolder(wrongSecret); + secretHolder.getKeyForSecret(); + fail("secret should be a hex string"); + } + catch (IllegalArgumentException e) { + assertContains(e.getMessage(), "Odd number of characters."); + } + } +} diff --git a/java/code/src/com/suse/manager/webui/utils/token/test/TokenParserTest.java b/java/code/src/com/suse/manager/webui/utils/token/test/TokenParserTest.java new file mode 100644 index 000000000000..1ad8a3d0eb41 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/token/test/TokenParserTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.webui.utils.token.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.suse.manager.webui.utils.token.DefaultTokenBuilder; +import com.suse.manager.webui.utils.token.Token; +import com.suse.manager.webui.utils.token.TokenException; +import com.suse.manager.webui.utils.token.TokenParser; +import com.suse.manager.webui.utils.token.TokenParsingException; + +import org.junit.jupiter.api.Test; + +public class TokenParserTest { + + @Test + public void testVerifyToken() throws TokenException { + String token = new DefaultTokenBuilder() + .usingServerSecret() + .build() + .getSerializedForm(); + + TokenParser tokenParser = new TokenParser().usingServerSecret(); + assertTrue(tokenParser.verify(token)); + } + + @Test + public void testWrongOriginToken() { + String wrongOriginToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva" + + "G4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + TokenParser tokenParser = new TokenParser().usingServerSecret(); + assertFalse(tokenParser.verify(wrongOriginToken)); + } + + @Test + public void testParseClaimsWithoutVerifying() throws TokenParsingException { + Token parsedToken = new TokenParser() + .skippingExpirationVerification() + .skippingNotBeforeVerification() + .skippingSignatureVerification() + .parse(""" + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJUb2tlblBhcnNlclRlc3QiLCJqd\ + GkiOiI3ZjY5NWJiMi01YzhhLTRiMjktODIzOS1kYWY1Njg3ZTk0N2YiLCJuYW1lIjoiSm9obiBE\ + b2UiLCJpYXQiOjE1MTYyMzkwMjJ9.Pz_wi8p73rGlHjM8s68FTSKjc8OkGFvXeBC25KTS2e8"""); + + assertEquals("TokenParserTest", parsedToken.getSubject()); + assertEquals("7f695bb2-5c8a-4b29-8239-daf5687e947f", parsedToken.getJwtId()); + assertEquals("John Doe", parsedToken.getClaim("name", String.class)); + } +} diff --git a/java/code/src/com/suse/manager/xmlrpc/InvalidCertificateException.java b/java/code/src/com/suse/manager/xmlrpc/InvalidCertificateException.java new file mode 100644 index 000000000000..1db0924b98fa --- /dev/null +++ b/java/code/src/com/suse/manager/xmlrpc/InvalidCertificateException.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.xmlrpc; + +import com.redhat.rhn.FaultException; + +/** + * The provided certificate is not valid + */ +public class InvalidCertificateException extends FaultException { + + /** + * Constructor + */ + public InvalidCertificateException() { + super(12000 , "invalidCertificate" , "Invalid certificate provided"); + } + + /** + * Constructor + * + * @param message exception message + */ + public InvalidCertificateException(String message) { + super(12000 , "invalidCertificate" , message); + } + + /** + * Constructor + * @param cause the cause (which is saved for later retrieval + * by the Throwable.getCause() method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public InvalidCertificateException(Throwable cause) { + super(12000 , "invalidCertificate" , "Invalid certificate provided", cause); + } +} diff --git a/java/code/src/com/suse/manager/xmlrpc/iss/HubHandler.java b/java/code/src/com/suse/manager/xmlrpc/iss/HubHandler.java new file mode 100644 index 000000000000..de7cbdaf681d --- /dev/null +++ b/java/code/src/com/suse/manager/xmlrpc/iss/HubHandler.java @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.xmlrpc.iss; + +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.xmlrpc.BaseHandler; +import com.redhat.rhn.frontend.xmlrpc.InvalidParameterException; +import com.redhat.rhn.frontend.xmlrpc.InvalidTokenException; +import com.redhat.rhn.frontend.xmlrpc.TokenAlreadyExistsException; +import com.redhat.rhn.frontend.xmlrpc.TokenCreationException; +import com.redhat.rhn.frontend.xmlrpc.TokenExchangeFailedException; +import com.redhat.rhn.taskomatic.TaskomaticApiException; + +import com.suse.manager.hub.HubManager; +import com.suse.manager.model.hub.IssRole; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenException; +import com.suse.manager.webui.utils.token.TokenParsingException; +import com.suse.manager.xmlrpc.InvalidCertificateException; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.hibernate.exception.ConstraintViolationException; + +import java.io.IOException; +import java.security.cert.CertificateException; +import java.util.Map; + +/** + * HubHandler + * + * @apidoc.namespace sync.hub + * @apidoc.doc Contains methods to set up and manage Hub Inter-Server synchronization + */ +public class HubHandler extends BaseHandler { + + private static final Logger LOGGER = LogManager.getLogger(HubHandler.class); + + private final HubManager hubManager; + + /** + * Default constructor + */ + public HubHandler() { + this(new HubManager()); + } + + /** + * Builds a handler with the specified dependencies + * @param hubManagerIn the hub manager + */ + public HubHandler(HubManager hubManagerIn) { + this.hubManager = hubManagerIn; + } + + /** + * Generate a new access token for ISS for accessing this system + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the peripheral/hub that will be using this access token + * @return the serialized form of the token + * + * @apidoc.doc Generate a new access token for ISS for accessing this system + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "FQDN of the peripheral/hub that will be using this access token") + * @apidoc.returntype #param("string", "The serialized form of the token") + */ + public String generateAccessToken(User loggedInUser, String fqdn) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + try { + return hubManager.issueAccessToken(loggedInUser, fqdn); + } + catch (TokenException ex) { + LOGGER.error("Unable to issue a token for {}", fqdn, ex); + throw new TokenCreationException(); + } + catch (ConstraintViolationException ex) { + LOGGER.error("Unable to issue a token, it already exists for {}", fqdn, ex); + throw new TokenAlreadyExistsException(); + } + } + + /** + * Store a third party access token for ISS + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the peripheral/hub that generated this access token + * @param token the access token + * @return 1 on success, exception otherwise + * + * @apidoc.doc Generate a new access token for ISS for accessing this system + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the peripheral/hub that generated this access token") + * @apidoc.param #param_desc("string", "token", "the access token") + * @apidoc.returntype #return_int_success() + */ + public int storeAccessToken(User loggedInUser, String fqdn, String token) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + if (StringUtils.isEmpty(token)) { + throw new InvalidParameterException("No token specified"); + } + + try { + hubManager.storeAccessToken(loggedInUser, fqdn, token); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to process the token from {}", fqdn, ex); + throw new InvalidTokenException(); + } + catch (ConstraintViolationException ex) { + LOGGER.error("Unable to store token, it already exists for {}", fqdn, ex); + throw new TokenAlreadyExistsException(); + } + return 1; + } + + /** + * Replace the auth tokens for connections between this hub and the given peripheral server + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the remote peripheral server which tokens should be changed + * @return 1 on success, otherwise exception + * + * @apidoc.doc Replace the auth tokens for connections between this hub and the given peripheral server + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote peripheral server to replace the tokens") + * @apidoc.returntype #return_int_success() + */ + public int replaceTokens(User loggedInUser, String fqdn) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + try { + hubManager.replaceTokensHub(loggedInUser, fqdn); + } + catch (CertificateException ex) { + LOGGER.error("Unable to load the provided certificate", ex); + throw new InvalidCertificateException(ex); + } + catch (TokenBuildingException ex) { + LOGGER.error("Unable to create a token for {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (IOException ex) { + LOGGER.error("Unable to connect to remote server {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the specified token", ex); + throw new TokenExchangeFailedException(ex); + } + return 1; + } + + /** + * Registers automatically a remote PERIPHERAL server. + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the remote server to register + * @param username the name of the user, needed to access the remote server. It must have the sat admin role. + * @param password the password of the user, needed to access the remote server. + * @return 1 on success, exception otherwise + * + * @apidoc.doc Registers automatically a remote server with the specified ISS role. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote server to register") + * @apidoc.param #param_desc("string", "username", "the name of the user, needed to access the remote server + * It must have the sat admin role") + * @apidoc.param #param_desc("string", "password", "the password of the user, needed to access the remote + * server") + * @apidoc.returntype #return_int_success() + */ + public int registerPeripheral(User loggedInUser, String fqdn, String username, String password) { + return registerPeripheral(loggedInUser, fqdn, username, password, null); + } + + /** + * Registers automatically a remote PERIPHERAL server. + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the remote server to register + * @param username the name of the user, needed to access the remote server. It must have the sat admin role. + * @param password the password of the user, needed to access the remote server. + * @param rootCA the root CA certificate, in case it's needed to establish a secure connection + * @return 1 on success, exception otherwise + * + * @apidoc.doc Registers automatically a remote server with the specified ISS role. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote server to register") + * @apidoc.param #param_desc("string", "username", "the name of the user, needed to access the remote server + * It must have the sat admin role") + * @apidoc.param #param_desc("string", "password", "the password of the user, needed to access the remote + * server") + * @apidoc.param #param_desc("string", "rootCA", "the root CA certificate, in case it's needed to establish a secure + * connection") + * @apidoc.returntype #return_int_success() + */ + public int registerPeripheral(User loggedInUser, String fqdn, String username, String password, String rootCA) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { + throw new InvalidParameterException("No credentials specified"); + } + + try { + hubManager.register(loggedInUser, fqdn, username, password, rootCA); + } + catch (CertificateException ex) { + LOGGER.error("Unable to load the provided certificate", ex); + throw new InvalidCertificateException(ex); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the specified token", ex); + throw new TokenExchangeFailedException(ex); + } + catch (TokenBuildingException ex) { + LOGGER.error("Unable to create a token for {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (IOException ex) { + LOGGER.error("Unable to connect to remote server {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (TaskomaticApiException ex) { + LOGGER.error("Unable to schedule root CA certificate update {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + + return 1; + } + + /** + * Registers a remote PERIPHERAL server using an existing specified access token. + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the remote server to register + * @param token the token used to authenticate on the remote server. + * @return 1 on success, exception otherwise + * + * @apidoc.doc Registers a remote server with the specified ISS role using an existing specified access token. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote server to register") + * @apidoc.param #param_desc("string", "token", "the token used to authenticate on the remote server.") + * @apidoc.returntype #return_int_success() + */ + public int registerPeripheralWithToken(User loggedInUser, String fqdn, String token) { + return registerPeripheralWithToken(loggedInUser, fqdn, token, null); + } + + /** + * Registers a remote PERIPHERAL server using an existing specified access token. + * @param loggedInUser the user logged in. It must have the sat admin role. + * @param fqdn the FQDN of the remote server to register + * @param token the token used to authenticate on the remote server. + * @param rootCA the root CA certificate, in case it's needed to establish a secure connection + * @return 1 on success, exception otherwise + * + * @apidoc.doc Registers a remote server with the specified ISS role using an existing specified access token. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote server to register") + * @apidoc.param #param_desc("string", "token", "the token used to authenticate on the remote server.") + * @apidoc.param #param_desc("string", "rootCA", "the root CA certificate, in case it's needed to establish a secure + * connection") + * @apidoc.returntype #return_int_success() + */ + public int registerPeripheralWithToken(User loggedInUser, String fqdn, String token, String rootCA) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + if (StringUtils.isEmpty(token)) { + throw new InvalidParameterException("No token"); + } + + try { + hubManager.register(loggedInUser, fqdn, token, rootCA); + } + catch (CertificateException ex) { + LOGGER.error("Unable to load the provided certificate", ex); + throw new InvalidCertificateException(ex); + } + catch (TokenParsingException ex) { + LOGGER.error("Unable to parse the specified token", ex); + throw new TokenExchangeFailedException(ex); + } + catch (TokenBuildingException ex) { + LOGGER.error("Unable to create a token for {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (IOException ex) { + LOGGER.error("Unable to connect to remote server {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + catch (TaskomaticApiException ex) { + LOGGER.error("Unable to schedule root CA certificate update {}", fqdn, ex); + throw new TokenExchangeFailedException(ex); + } + + return 1; + } + + /** + * De-register the server locally identified by the fqdn. + * @param loggedInUser the user + * @param fqdn the FQDN of the server to de-register + * @return 1 on success, exception otherwise + * + * @apidoc.doc De-register the server locally identified by the fqdn. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "the FQDN of the remote server to de-register") + * @apidoc.returntype #return_int_success() + */ + public int deregister(User loggedInUser, String fqdn) { + ensureSatAdmin(loggedInUser); + + if (StringUtils.isEmpty(fqdn)) { + throw new InvalidParameterException("No FQDN specified"); + } + + try { + hubManager.deleteIssServerLocal(loggedInUser, fqdn); + } + catch (Exception ex) { + LOGGER.error("De-registration failed for {} ", fqdn, ex); + throw ex; + } + return 1; + } + + /** + * Set server details + * + * @param loggedInUser The current user + * @param fqdn the FQDN identifying the Hub or Peripheral Server + * @param role the role which should be changed + * @param data the new data + * @return 1 on success, exception otherwise + * + * @apidoc.doc Set server details. All arguments are optional and will only be modified + * if included in the struct. + * @apidoc.param #session_key() + * @apidoc.param #param_desc("string", "fqdn", "The FDN of Hub or Periperal server to lookup details for.") + * @apidoc.param #param_desc("string", "role", "The role which should be updated. Either 'HUB' or 'PERIPHERAL'.") + * @apidoc.param + * #struct_begin("details") + * #prop_desc("string", "root_ca", "The root ca") + * #prop_desc("string", "gpg_key", "The root gpg key - only for role HUB") + * #struct_end() + * @apidoc.returntype #return_int_success() + */ + public int setDetails(User loggedInUser, String fqdn, String role, Map data) { + ensureSatAdmin(loggedInUser); + hubManager.updateServerData(loggedInUser, fqdn, IssRole.valueOf(role), data); + return 1; + } +} diff --git a/java/code/src/com/suse/manager/xmlrpc/iss/RestHubExternalClient.java b/java/code/src/com/suse/manager/xmlrpc/iss/RestHubExternalClient.java new file mode 100644 index 000000000000..ad99991e7136 --- /dev/null +++ b/java/code/src/com/suse/manager/xmlrpc/iss/RestHubExternalClient.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.xmlrpc.iss; + +import com.redhat.rhn.common.util.http.HttpClientAdapter; + +import com.suse.manager.hub.HubExternalClient; +import com.suse.manager.webui.controllers.ECMAScriptDateAdapter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.cookie.Cookie; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.security.cert.Certificate; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * HTTP Client for the Hub Inter-Server-Sync External-facing APIs + */ +public class RestHubExternalClient implements HubExternalClient { + + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(Date.class, new ECMAScriptDateAdapter()) + .serializeNulls() + .create(); + + private final String remoteHost; + + private final HttpClientAdapter httpClientAdapter; + + private Cookie sessionCookie; + + /** + * Builds an instance that connects to the given host using the given username/password combination. + * @param remoteHostIn the remote host + * @param username the username + * @param password the password + * @param rootCA the root certificate, if needed to establish a secure connection + * @throws IOException when a failure happens while establishing the connection + */ + public RestHubExternalClient(String remoteHostIn, String username, String password, Optional rootCA) + throws IOException { + List maybeRootCAs = rootCA.stream().toList(); + + this.remoteHost = remoteHostIn; + this.httpClientAdapter = new HttpClientAdapter(maybeRootCAs, true); + this.sessionCookie = null; + + login(username, password); + } + + @Override + public String generateAccessToken(String fqdn) throws IOException { + HttpPost request = createPostRequest("sync.hub", "generateAccessToken", Map.of("fqdn", fqdn)); + HttpResponse response = httpClientAdapter.executeRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new IOException("Unexpected response code %d".formatted(statusCode)); + } + + String body = EntityUtils.toString(response.getEntity()); + Map responseMap = GSON.fromJson(body, new TypeToken>() { }.getType()); + + Object result = responseMap.get("result"); + if (!(result instanceof String token)) { + throw new IOException("Unexpected response in JSON object %s".formatted(result)); + } + + return token; + } + + @Override + public void storeAccessToken(String fqdn, String token) throws IOException { + HttpPost request = createPostRequest("sync.hub", "storeAccessToken", Map.of("fqdn", fqdn, "token", token)); + HttpResponse response = httpClientAdapter.executeRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new IOException("Unexpected response code %d".formatted(statusCode)); + } + } + + private void login(String username, String password) throws IOException { + try { + HttpPost request = createPostRequest("auth", "login", Map.of("login", username, "password", password)); + HttpResponse response = httpClientAdapter.executeRequest(request, username, password); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new IOException("Unexpected response code %d".formatted(statusCode)); + } + + List ptxSessionCookie = httpClientAdapter.getCookies("pxt-session-cookie"); + if (ptxSessionCookie.size() != 1) { + throw new IOException("One and only one ptx-session-cookie is expected"); + } + + sessionCookie = ptxSessionCookie.get(0); + } + catch (IOException e) { + sessionCookie = null; + throw e; + } + } + + private void logout() throws IOException { + if (sessionCookie == null) { + return; + } + + HttpPost request = createPostRequest("auth", "logout"); + HttpResponse response = httpClientAdapter.executeRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new IOException("Unexpected response code %d".formatted(statusCode)); + } + + sessionCookie = null; + } + + @Override + public void close() throws IOException { + logout(); + } + + private HttpPost createPostRequest(String namespace, String method) { + return createPostRequest(namespace, method, Map.of()); + } + + private HttpPost createPostRequest(String namespace, String method, Map paramtersMap) { + String url = "https://%s/rhn/manager/api/%s/%s".formatted(remoteHost, namespace.replace(".", "/"), method); + HttpPost request = new HttpPost(url); + + if (!paramtersMap.isEmpty()) { + String jsonBody = GSON.toJson(paramtersMap, new TypeToken>() { }.getType()); + request.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); + } + + return request; + } +} diff --git a/java/code/src/com/suse/manager/xmlrpc/iss/test/HubHandlerTest.java b/java/code/src/com/suse/manager/xmlrpc/iss/test/HubHandlerTest.java new file mode 100644 index 000000000000..fbac62af7461 --- /dev/null +++ b/java/code/src/com/suse/manager/xmlrpc/iss/test/HubHandlerTest.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.manager.xmlrpc.iss.test; + +import static org.jmock.AbstractExpectations.returnValue; +import static org.jmock.AbstractExpectations.throwException; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.redhat.rhn.frontend.xmlrpc.InvalidTokenException; +import com.redhat.rhn.frontend.xmlrpc.PermissionCheckFailureException; +import com.redhat.rhn.frontend.xmlrpc.TokenCreationException; +import com.redhat.rhn.frontend.xmlrpc.TokenExchangeFailedException; +import com.redhat.rhn.frontend.xmlrpc.test.BaseHandlerTestCase; + +import com.suse.manager.hub.HubManager; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.manager.webui.utils.token.TokenException; +import com.suse.manager.webui.utils.token.TokenParsingException; +import com.suse.manager.xmlrpc.InvalidCertificateException; +import com.suse.manager.xmlrpc.iss.HubHandler; + +import org.jmock.Expectations; +import org.jmock.imposters.ByteBuddyClassImposteriser; +import org.jmock.junit5.JUnit5Mockery; +import org.jmock.lib.concurrent.Synchroniser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.io.IOException; +import java.security.cert.CertificateException; + +@ExtendWith(JUnit5Mockery.class) +public class HubHandlerTest extends BaseHandlerTestCase { + + @RegisterExtension + protected final JUnit5Mockery context = new JUnit5Mockery() {{ + setThreadingPolicy(new Synchroniser()); + }}; + + private HubManager hubManagerMock; + + private HubHandler hubHandler; + + @BeforeEach + public void setup() { + context.setThreadingPolicy(new Synchroniser()); + context.setImposteriser((ByteBuddyClassImposteriser.INSTANCE)); + + hubManagerMock = context.mock(HubManager.class); + hubHandler = new HubHandler(hubManagerMock); + } + + @Test + public void ensureOnlySatAdminCanAccessToTokenGeneration() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock).issueAccessToken(satAdmin, "uyuni-server.dev.local"); + expectations.will(returnValue("dummy-token")); + context.checking(expectations); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.generateAccessToken(regular, "uyuni-server.dev.local") + ); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.generateAccessToken(admin, "uyuni-server.dev.local") + ); + + assertDoesNotThrow( + () -> hubHandler.generateAccessToken(satAdmin, "uyuni-server.dev.local") + ); + } + + @Test + public void throwsCorrectExceptionWhenIssuingFails() throws TokenException { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock).issueAccessToken(satAdmin, "uyuni-server.dev.local"); + expectations.will(throwException(new TokenBuildingException("unexpected error"))); + context.checking(expectations); + + assertThrows(TokenCreationException.class, + () -> hubHandler.generateAccessToken(satAdmin, "uyuni-server.dev.local")); + } + + @Test + public void ensureOnlySatAdminCanAccessToTokenStorage() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock).storeAccessToken(satAdmin, "uyuni-server.dev.local", "dummy-token"); + context.checking(expectations); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.storeAccessToken(regular, "uyuni-server.dev.local", "dummy-token") + ); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.storeAccessToken(admin, "uyuni-server.dev.local", "dummy-token") + ); + + assertDoesNotThrow( + () -> hubHandler.storeAccessToken(satAdmin, "uyuni-server.dev.local", "dummy-token") + ); + } + + @Test + public void throwsCorrectExceptionWhenStoringFails() throws TokenParsingException { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock).storeAccessToken(satAdmin, "uyuni-server.dev.local", "dummy-token"); + expectations.will(throwException(new TokenParsingException("Cannot parse"))); + context.checking(expectations); + + assertThrows(InvalidTokenException.class, + () -> hubHandler.storeAccessToken(satAdmin, "uyuni-server.dev.local", "dummy-token")); + } + + @Test + public void ensureOnlySatAdminCanRegister() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock) + .register(satAdmin, "remote-server.dev.local", "admin", "admin", null); + context.checking(expectations); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.registerPeripheral(regular, "remote-server.dev.local", "admin", "admin") + ); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.registerPeripheral(admin, "remote-server.dev.local", "admin", "admin") + ); + + assertDoesNotThrow( + () -> hubHandler.registerPeripheral(satAdmin, "remote-server.dev.local", "admin", "admin") + ); + } + + @Test + public void throwsCorrectExceptionsWhenRegisteringFails() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-certificate.dev.local", "admin", "admin", "dummy"); + expectations.will(throwException(new CertificateException("Unable to parse"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-parsing.dev.local", "admin", "admin", "dummy"); + expectations.will(throwException(new TokenParsingException("Unable to parse"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-building.dev.local", "admin", "admin", "dummy"); + expectations.will(throwException(new TokenBuildingException("Unable to build"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-connecting.dev.local", "admin", "admin", "dummy"); + expectations.will(throwException(new IOException("Unable to connect"))); + + context.checking(expectations); + + assertThrows( + InvalidCertificateException.class, + () -> hubHandler.registerPeripheral(satAdmin, "fails-certificate.dev.local", "admin", "admin", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheral(satAdmin, "fails-parsing.dev.local", "admin", "admin", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheral(satAdmin, "fails-building.dev.local", "admin", "admin", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheral(satAdmin, "fails-connecting.dev.local", "admin", "admin", "dummy") + ); + } + + @Test + public void ensureOnlySatAdminCanRegisterWithToken() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock) + .register(satAdmin, "remote-server.dev.local", "token", null); + context.checking(expectations); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.registerPeripheralWithToken(regular, "remote-server.dev.local", "token") + ); + + assertThrows( + PermissionCheckFailureException.class, + () -> hubHandler.registerPeripheralWithToken(admin, "remote-server.dev.local", "token") + ); + + assertDoesNotThrow( + () -> hubHandler.registerPeripheralWithToken(satAdmin, "remote-server.dev.local", "token") + ); + } + + @Test + public void throwsCorrectExceptionsWhenRegisteringWithTokenFails() throws Exception { + Expectations expectations = new Expectations(); + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-certificate.dev.local", "token", "dummy"); + expectations.will(throwException(new CertificateException("Unable to parse"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-parsing.dev.local", "token", "dummy"); + expectations.will(throwException(new TokenParsingException("Unable to parse"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-building.dev.local", "token", "dummy"); + expectations.will(throwException(new TokenBuildingException("Unable to build"))); + + expectations.allowing(hubManagerMock) + .register(satAdmin, "fails-connecting.dev.local", "token", "dummy"); + expectations.will(throwException(new IOException("Unable to connect"))); + + context.checking(expectations); + + assertThrows( + InvalidCertificateException.class, + () -> hubHandler.registerPeripheralWithToken(satAdmin, "fails-certificate.dev.local", "token", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheralWithToken(satAdmin, "fails-parsing.dev.local", "token", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheralWithToken(satAdmin, "fails-building.dev.local", "token", "dummy") + ); + + assertThrows( + TokenExchangeFailedException.class, + () -> hubHandler.registerPeripheralWithToken(satAdmin, "fails-connecting.dev.local", "token", "dummy") + ); + } +} diff --git a/java/code/src/com/suse/scc/SCCEndpoints.java b/java/code/src/com/suse/scc/SCCEndpoints.java new file mode 100644 index 000000000000..fadadd7c4f59 --- /dev/null +++ b/java/code/src/com/suse/scc/SCCEndpoints.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2015--2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.scc; + +import static com.suse.manager.webui.utils.SparkApplicationHelper.asJson; +import static spark.Spark.get; + +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.HubSCCCredentials; +import com.redhat.rhn.domain.credentials.SCCCredentials; +import com.redhat.rhn.domain.product.ChannelTemplate; +import com.redhat.rhn.domain.product.SUSEProductFactory; +import com.redhat.rhn.domain.scc.SCCRepository; + +import com.suse.manager.hub.RouteWithSCCAuth; +import com.suse.manager.reactor.utils.OptionalTypeAdapterFactory; +import com.suse.manager.webui.utils.token.DownloadTokenBuilder; +import com.suse.manager.webui.utils.token.TokenBuildingException; +import com.suse.scc.client.SCCClient; +import com.suse.scc.client.SCCClientException; +import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; +import com.suse.scc.client.SCCFileClient; +import com.suse.scc.client.SCCWebClient; +import com.suse.scc.model.SCCRepositoryJson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import javax.servlet.http.HttpServletResponse; + +import spark.Request; +import spark.Response; +import spark.Route; +import spark.Spark; +import spark.template.jade.JadeTemplateEngine; + +public class SCCEndpoints { + + /** + * A Hub deliver custom repositories via organization/repositories SCC endpoint. + * We need a fake repo ID for it. + */ + public static final Long CUSTOM_REPO_FAKE_SCC_ID = Long.MIN_VALUE; + + private final String uuid; + private final URI sccUrl; + + private static final Logger LOG = LogManager.getLogger(SCCEndpoints.class); + + /** + * Constructor + * @param uuidIn the UUID + * @param sccUrlIn the SCC URL + */ + public SCCEndpoints(String uuidIn, URI sccUrlIn) { + this.uuid = uuidIn; + this.sccUrl = sccUrlIn; + } + + private Route withSCCAuth(RouteWithSCCAuth route) { + return (request, response) -> { + String authorization = request.headers("Authorization"); + if (authorization == null) { + response.header("www-authenticate", "Basic realm=\"SCC Connect API\""); + Spark.halt(HttpServletResponse.SC_UNAUTHORIZED); + } + + String[] auth = authorization.split(" ", 2); + if (auth.length != 2 || !auth[0].equalsIgnoreCase("basic")) { + Spark.halt(HttpServletResponse.SC_BAD_REQUEST); + } + var decoded = new String(Base64.getDecoder().decode(auth[1]), StandardCharsets.UTF_8); + var userpass = decoded.split(":", 2); + if (userpass.length != 2) { + Spark.halt(HttpServletResponse.SC_BAD_REQUEST); + } + + var username = userpass[0]; + var password = userpass[1]; + + Optional credentials = + CredentialsFactory.listCredentialsByType(HubSCCCredentials.class).stream().filter(c -> + c.getUsername().equals(username) && + MessageDigest.isEqual(c.getPassword().getBytes(), password.getBytes()) + ).findFirst(); + + + return credentials + .map(c -> route.handle(request, response, c)) + .orElseGet(() -> { + Spark.halt(HttpServletResponse.SC_UNAUTHORIZED); + return null; + }); + }; + } + + /** + * Initialize routs + * @param jade jade + */ + public void initRoutes(JadeTemplateEngine jade) { + get("/hub/scc/connect/organizations/products/unscoped", asJson(withSCCAuth(this::unscoped))); + get("/hub/scc/connect/organizations/repositories", asJson(withSCCAuth(this::repositories))); + get("/hub/scc/connect/organizations/subscriptions", asJson(withSCCAuth(this::subscriptions))); + get("/hub/scc/connect/organizations/orders", asJson(withSCCAuth(this::orders))); + get("/hub/scc/suma/product_tree.json", asJson(this::productTree)); + } + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX") + .registerTypeAdapterFactory(new OptionalTypeAdapterFactory()) + .create(); + + /** + * organization/products/unscoped endpoint + * @param request the request + * @param response the response + * @param credentials the Hub credentials + * @return return unscoped json as string + */ + public String unscoped(Request request, Response response, HubSCCCredentials credentials) { + return serveEndpoint(SCCClient::listProducts); + } + + /** + * Build a {@link SCCRepositoryJson} object for a Hub custom repository + * @param label the label + * @param hostname the hostname + * @param token the token + * @return return {@link SCCRepositoryJson} object for a Hub custom repository + */ + public static SCCRepositoryJson buildCustomRepoJson(String label, String hostname, String token) { + SCCRepositoryJson json = new SCCRepositoryJson(); + json.setSCCId(CUSTOM_REPO_FAKE_SCC_ID); + json.setEnabled(true); + json.setName(label); + json.setDescription(""); + json.setUrl("https://%1$s/rhn/manager/download/hubsync/%2$s/?%3$s".formatted(hostname, label, token)); + json.setInstallerUpdates(false); + json.setAutorefresh(false); + json.setDistroTarget(""); + return json; + } + + /** + * Build and return a short living token for a Hub repository sync + * @param channelLabel the channel label to the create the token for + * @return the token + */ + public static Optional buildHubRepositoryToken(String channelLabel) { + try { + DownloadTokenBuilder builder = new DownloadTokenBuilder(0) + .usingServerSecret() + // Short lived 2 day + 4 hours tokens refreshed on ever sync + .expiringAfterMinutes(2L * (24 + 2) * 60) + .allowingOnlyChannels(Set.of(channelLabel)); + return Optional.of(builder.build().getSerializedForm()); + } + catch (TokenBuildingException e) { + LOG.error("Error creating token for channel: {}", channelLabel, e); + return Optional.empty(); + } + } + + /** + * Build a {@link SCCRepositoryJson} object for a Hub Vendor repository + * @param channelTemplate the channel template + * @param hostname the hostname + * @param token the token + * @return return {@link SCCRepositoryJson} object for a Hub vendor repository + */ + private static SCCRepositoryJson buildVendorRepoJson(ChannelTemplate channelTemplate, String hostname, + String token) { + SCCRepository repository = channelTemplate.getRepository(); + SCCRepositoryJson json = new SCCRepositoryJson(); + json.setSCCId(repository.getSccId()); + json.setEnabled(channelTemplate.isMandatory()); + json.setName(repository.getName()); + json.setDescription(repository.getDescription()); + json.setUrl("https://%1$s/rhn/manager/download/hubsync/%2$d/?%3$s".formatted( + hostname, repository.getSccId(), token)); + json.setInstallerUpdates(repository.isInstallerUpdates()); + json.setAutorefresh(repository.isAutorefresh()); + json.setDistroTarget(repository.getDistroTarget()); + return json; + } + + + /** + * Endpoint serving ISS peripheral channel information in scc repository format + * + * @param request + * @param response + * @param credentials + * @return return the repositories + */ + public String repositories(Request request, Response response, HubSCCCredentials credentials) { + var hostname = ConfigDefaults.get().getJavaHostname(); + var peripheral = credentials.getIssPeripheral(); + var channels = peripheral.getPeripheralChannels(); + var jsonRepos = channels.stream().map(c -> { + Channel channel = c.getChannel(); + String label = channel.getLabel(); + String tokenString = buildHubRepositoryToken(label).orElse(""); + return SUSEProductFactory.lookupByChannelLabelFirst(label) + .map(channelTemplate -> buildVendorRepoJson(channelTemplate, hostname, tokenString)) + .orElseGet(() -> buildCustomRepoJson(label, hostname, tokenString)); + }).toList(); + return gson.toJson(jsonRepos); + } + + /** + * Endpoint serving ISS peripheral subscription information + * + * @param request + * @param response + * @param credentials + * @return return always empty list + */ + public String subscriptions(Request request, Response response, HubSCCCredentials credentials) { + return "[]"; + } + + /** + * Endpoint serving ISS peripheral order information + * + * @param request + * @param response + * @param credentials + * @return return always empty list + */ + public String orders(Request request, Response response, HubSCCCredentials credentials) { + return "[]"; + } + + private String serveEndpoint(Function fn) { + return ConfigDefaults.get().getOfflineMirrorDir() + .map(path -> fn.apply(new SCCFileClient(Paths.get(path)))) + .or(() -> CredentialsFactory.listSCCCredentials().stream() + .filter(SCCCredentials::isPrimary) + .findFirst() + .map(cred -> { + String username = cred.getUsername(); + Path path = Paths.get(SCCConfig.DEFAULT_LOGGING_DIR).resolve(username); + try { + return fn.apply(new SCCFileClient(path)); + } + catch (SCCClientException e) { + String password = cred.getPassword(); + + SCCConfig config = new SCCConfigBuilder() + .setUrl(sccUrl) + .setUsername(username) + .setPassword(password) + .setUuid(uuid) + .setLoggingDir(SCCConfig.DEFAULT_LOGGING_DIR) + .setSkipOwner(false) + .createSCCConfig(); + + return fn.apply(new SCCWebClient(config)); + } + }) + ).map(gson::toJson).orElse("[]"); + } + + /** + * Endpoint serving ISS peripheral product tree + * + * @param request + * @param response + * @return return the product tree + */ + public String productTree(Request request, Response response) { + return serveEndpoint(SCCClient::productTree); + } +} diff --git a/java/code/src/com/suse/scc/client/SCCConfig.java b/java/code/src/com/suse/scc/client/SCCConfig.java index 24d6bd20cf6d..c584819883e1 100644 --- a/java/code/src/com/suse/scc/client/SCCConfig.java +++ b/java/code/src/com/suse/scc/client/SCCConfig.java @@ -15,8 +15,9 @@ package com.suse.scc.client; import java.net.URI; -import java.net.URISyntaxException; +import java.security.cert.Certificate; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -25,20 +26,6 @@ */ public class SCCConfig { - /** Default SCC URL. */ - private static final URI DEFAULT_URL; - // Fairly complex (yet valid) initialization code for the constant - static { - URI temp = null; - try { - temp = new URI("https://scc.suse.com"); - } - catch (URISyntaxException e) { - // never happens - } - DEFAULT_URL = temp; - } - /** Default directory where to save logging files. */ public static final String DEFAULT_LOGGING_DIR = "/var/lib/spacewalk/scc/scc-data/"; @@ -54,9 +41,6 @@ public class SCCConfig { /** The client UUID for SCC debugging. */ private String uuid; - /** The local resource path for local access to SMT files. */ - private String localResourcePath; - /** Path to the logging directory. */ private String loggingDir; @@ -64,54 +48,8 @@ public class SCCConfig { /** True to skip owner setting in tests */ private boolean skipOwner = false; + private List additionalCerts; - /** - * Instantiates a new SCC config to read from a local file and default - * logging directory. - * @param localResourcePathIn the local resource path - */ - public SCCConfig(String localResourcePathIn) { - this(DEFAULT_URL, null, null, null, localResourcePathIn, DEFAULT_LOGGING_DIR, false, null); - } - - /** - * Instantiates a new SCC config to read from SCC with default logging directory. - * @param urlIn the url - * @param usernameIn the username - * @param passwordIn the password - * @param uuidIn the UUID - */ - public SCCConfig(URI urlIn, String usernameIn, String passwordIn, String uuidIn) { - this(urlIn, usernameIn, passwordIn, uuidIn, null, DEFAULT_LOGGING_DIR, false, null); - } - - /** - * Instantiates a new SCC config to read from SCC with default logging directory. - * @param urlIn the url - * @param usernameIn the username - * @param passwordIn the password - * @param uuidIn the UUID - * @param additionalHeadersIn additional headers for the request - */ - public SCCConfig(URI urlIn, String usernameIn, String passwordIn, String uuidIn, - Map additionalHeadersIn) { - this(urlIn, usernameIn, passwordIn, uuidIn, null, DEFAULT_LOGGING_DIR, false, additionalHeadersIn); - } - - /** - * Full constructor. - * @param urlIn the url - * @param usernameIn the username - * @param passwordIn the password - * @param uuidIn the UUID - * @param localResourcePathIn the local resource path - * @param loggingDirIn the logging dir - * @param skipOwnerIn skip owner setting for testing - */ - public SCCConfig(URI urlIn, String usernameIn, String passwordIn, String uuidIn, String localResourcePathIn, - String loggingDirIn, boolean skipOwnerIn) { - this(urlIn, usernameIn, passwordIn, uuidIn, localResourcePathIn, loggingDirIn, skipOwnerIn, null); - } /** * Full constructor. @@ -119,22 +57,22 @@ public SCCConfig(URI urlIn, String usernameIn, String passwordIn, String uuidIn, * @param usernameIn the username * @param passwordIn the password * @param uuidIn the UUID - * @param localResourcePathIn the local resource path * @param loggingDirIn the logging dir + * @param additionalCertsIn additional certificates to trust * @param skipOwnerIn skip owner setting for testing * @param additionalHeadersIn map of additional headers to set for the request */ public SCCConfig(URI urlIn, String usernameIn, String passwordIn, String uuidIn, - String localResourcePathIn, String loggingDirIn, boolean skipOwnerIn, - Map additionalHeadersIn) { + String loggingDirIn, boolean skipOwnerIn, + Map additionalHeadersIn, List additionalCertsIn) { url = urlIn; username = usernameIn; password = passwordIn; uuid = uuidIn; - localResourcePath = localResourcePathIn; loggingDir = loggingDirIn; skipOwner = skipOwnerIn; additionalHeaders = additionalHeadersIn; + additionalCerts = additionalCertsIn; } /** @@ -169,14 +107,6 @@ public String getUUID() { return uuid; } - /** - * Gets the local resource path. - * @return the local resource path - */ - public String getLocalResourcePath() { - return localResourcePath; - } - /** * Gets the logging dir. * @return the logging dir @@ -192,6 +122,10 @@ public boolean isSkipOwner() { return skipOwner; } + public List getAdditionalCerts() { + return additionalCerts; + } + /** * @return additional headers */ diff --git a/java/code/src/com/suse/scc/client/SCCConfigBuilder.java b/java/code/src/com/suse/scc/client/SCCConfigBuilder.java new file mode 100644 index 000000000000..75305ada22f4 --- /dev/null +++ b/java/code/src/com/suse/scc/client/SCCConfigBuilder.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2024--2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ +package com.suse.scc.client; + +import java.net.URI; +import java.net.URISyntaxException; +import java.security.cert.Certificate; +import java.util.List; +import java.util.Map; + +public class SCCConfigBuilder { + + /** Default SCC URL. */ + private static final URI DEFAULT_URL; + // Fairly complex (yet valid) initialization code for the constant + static { + URI temp = null; + try { + temp = new URI("https://scc.suse.com"); + } + catch (URISyntaxException e) { + // never happens + } + DEFAULT_URL = temp; + } + + private URI url = DEFAULT_URL; + private String username = null; + private String password = null; + private String uuid = null; + private String loggingDir = SCCConfig.DEFAULT_LOGGING_DIR; + private boolean skipOwner = false; + private Map additionalHeaders = null; + private List additionalCerts; + + /** + * Set the scc url + * @param urlIn scc url + * @return the builder + */ + public SCCConfigBuilder setUrl(URI urlIn) { + this.url = urlIn; + return this; + } + + /** + * Set the additional certificates to trust + * @param certificatesIn the certificates + * @return the builder + */ + public SCCConfigBuilder setCertificates(List certificatesIn) { + this.additionalCerts = certificatesIn; + return this; + } + + /** + * Set the SCC password + * @param usernameIn the SCC username + * @return the builder + */ + public SCCConfigBuilder setUsername(String usernameIn) { + this.username = usernameIn; + return this; + } + + /** + * Set the SCC password + * @param passwordIn the SCC password + * @return the builder + */ + public SCCConfigBuilder setPassword(String passwordIn) { + this.password = passwordIn; + return this; + } + + /** + * Set the uuid to identify this susemanager instance to SCC + * @param uuidIn the uuid + * @return the builder + */ + public SCCConfigBuilder setUuid(String uuidIn) { + this.uuid = uuidIn; + return this; + } + + /** + * Set the directory in which logs are written + * @param loggingDirIn the directory to write logs in + * @return the builder + */ + public SCCConfigBuilder setLoggingDir(String loggingDirIn) { + this.loggingDir = loggingDirIn; + return this; + } + + /** + * Option to skip file owner changes (only used during tests) + * @param skipOwnerIn flag to skip file owner changes + * @return the builder + */ + public SCCConfigBuilder setSkipOwner(boolean skipOwnerIn) { + this.skipOwner = skipOwnerIn; + return this; + } + + /** + * Set the additional http headers to use + * @param additionalHeadersIn map of headers + * @return the builder + */ + public SCCConfigBuilder setAdditionalHeaders(Map additionalHeadersIn) { + this.additionalHeaders = additionalHeadersIn; + return this; + } + + /** + * Create the SCC config from the builders current settings + * @return the resulting SCC config + */ + public SCCConfig createSCCConfig() { + return new SCCConfig(url, username, password, uuid, loggingDir, skipOwner, additionalHeaders, + additionalCerts); + } +} diff --git a/java/code/src/com/suse/scc/client/SCCFileClient.java b/java/code/src/com/suse/scc/client/SCCFileClient.java index 674e55a26312..b8e57cb64146 100644 --- a/java/code/src/com/suse/scc/client/SCCFileClient.java +++ b/java/code/src/com/suse/scc/client/SCCFileClient.java @@ -30,11 +30,11 @@ import com.google.gson.GsonBuilder; import java.io.BufferedReader; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Type; +import java.nio.file.Path; import java.util.List; /** @@ -42,15 +42,14 @@ */ public class SCCFileClient implements SCCClient { - /** The config object. */ - private final SCCConfig config; + private final Path localResourcePath; /** * Constructor for connecting to scc.suse.com. - * @param configIn the configuration object + * @param localResourcePathIn the local path from where to read scc data. */ - public SCCFileClient(SCCConfig configIn) { - config = configIn; + public SCCFileClient(Path localResourcePathIn) { + this.localResourcePath = localResourcePathIn; } /** @@ -160,7 +159,7 @@ private T readJSON(String filename, Type resultType) .create(); return (T) gson.fromJson( new BufferedReader(new InputStreamReader(new FileInputStream( - new File(config.getLocalResourcePath(), filename)))), + localResourcePath.resolve(filename).toFile()))), resultType); } } diff --git a/java/code/src/com/suse/scc/client/SCCWebClient.java b/java/code/src/com/suse/scc/client/SCCWebClient.java index fe0156719dac..0f76868c39af 100644 --- a/java/code/src/com/suse/scc/client/SCCWebClient.java +++ b/java/code/src/com/suse/scc/client/SCCWebClient.java @@ -45,10 +45,19 @@ import org.apache.logging.log4j.Logger; import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.net.NoRouteToHostException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.UserPrincipal; +import java.nio.file.attribute.UserPrincipalLookupService; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -114,7 +123,41 @@ private static class PaginatedResult { */ public SCCWebClient(SCCConfig configIn) { config = configIn; - httpClient = new HttpClientAdapter(); + httpClient = new HttpClientAdapter(configIn.getAdditionalCerts(), false); + } + + private T writeCache(T value, String name) { + Path credentialCache = Paths.get(config.getLoggingDir(), + config.getUsername().replaceAll("[^a-zA-Z0-9\\._]+", "_")); + try { + + UserPrincipal tomcatUser = null; + UserPrincipal rootUser = null; + if (!config.isSkipOwner()) { + FileSystem fileSystem = FileSystems.getDefault(); + UserPrincipalLookupService service = fileSystem.getUserPrincipalLookupService(); + tomcatUser = service.lookupPrincipalByName("tomcat"); + rootUser = service.lookupPrincipalByName("root"); + } + + Files.createDirectories(credentialCache); + if (!config.isSkipOwner() && Files.getOwner(credentialCache, LinkOption.NOFOLLOW_LINKS).equals(rootUser)) { + Files.setOwner(credentialCache, tomcatUser); + } + + Path jsonFilePath = credentialCache.resolve(name + ".json"); + try (BufferedWriter file = Files.newBufferedWriter(jsonFilePath)) { + gson.toJson(value, file); + if (!config.isSkipOwner() && Files.getOwner(jsonFilePath, LinkOption.NOFOLLOW_LINKS).equals(rootUser)) { + Files.setOwner(jsonFilePath, tomcatUser); + } + } + } + catch (IOException e) { + LOG.error(e); + throw new SCCClientException(e); + } + return value; } /** @@ -122,8 +165,9 @@ public SCCWebClient(SCCConfig configIn) { */ @Override public List listRepositories() throws SCCClientException { - return getList("/connect/organizations/repositories", + List list = getList("/connect/organizations/repositories", SCCRepositoryJson.class); + return writeCache(list, "organizations_repositories"); } /** @@ -131,8 +175,9 @@ public List listRepositories() throws SCCClientException { */ @Override public List listProducts() throws SCCClientException { - return getList( + List list = getList( "/connect/organizations/products/unscoped", SCCProductJson.class); + return writeCache(list, "organizations_products_unscoped"); } /** @@ -140,8 +185,9 @@ public List listProducts() throws SCCClientException { */ @Override public List listSubscriptions() throws SCCClientException { - return getList("/connect/organizations/subscriptions", + List list = getList("/connect/organizations/subscriptions", SCCSubscriptionJson.class); + return writeCache(list, "organizations_subscriptions"); } /** @@ -149,14 +195,16 @@ public List listSubscriptions() throws SCCClientException { */ @Override public List listOrders() throws SCCClientException { - return getList("/connect/organizations/orders", + List list = getList("/connect/organizations/orders", SCCOrderJson.class); + return writeCache(list, "organizations_orders"); } @Override public List productTree() throws SCCClientException { - return getList("/suma/product_tree.json", + List list = getList("/suma/product_tree.json", ProductTreeEntry.class); + return writeCache(list, "product_tree"); } /** @@ -168,7 +216,7 @@ public List productTree() throws SCCClientException { * @return object of type given by resultType * @throws SCCClientException if the request was not successful */ - private List getList(String endpoint, Type resultType) + private List getList(String endpoint, Class resultType) throws SCCClientException { PaginatedResult> firstPage = request(endpoint, SCCClientUtils.toListType(resultType), "GET"); diff --git a/java/code/src/com/suse/scc/client/test/SCCRequestFactoryTest.java b/java/code/src/com/suse/scc/client/test/SCCRequestFactoryTest.java index b464ae16905c..76a53aa600d6 100644 --- a/java/code/src/com/suse/scc/client/test/SCCRequestFactoryTest.java +++ b/java/code/src/com/suse/scc/client/test/SCCRequestFactoryTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCRequestFactory; import org.apache.http.client.methods.HttpGet; @@ -45,8 +46,14 @@ public class SCCRequestFactoryTest { */ @Test public void testInitRequest() throws Exception { - SCCConfig config = new SCCConfig(new URI(TEST_SCHEME + "://" + TEST_HOST), - "user", "pass", TEST_UUID, null, SCCConfig.DEFAULT_LOGGING_DIR, true); + SCCConfig config = new SCCConfigBuilder() + .setUrl(new URI(TEST_SCHEME + "://" + TEST_HOST)) + .setUsername("user") + .setPassword("pass") + .setUuid(TEST_UUID) + .setLoggingDir(SCCConfig.DEFAULT_LOGGING_DIR) + .setSkipOwner(true) + .createSCCConfig(); SCCRequestFactory factory = SCCRequestFactory.getInstance(); HttpRequestBase request = factory.initRequest("GET", TEST_PATH, config); assertInstanceOf(HttpGet.class, request); diff --git a/java/code/src/com/suse/scc/model/SCCRepositoryJson.java b/java/code/src/com/suse/scc/model/SCCRepositoryJson.java index 07894342167b..237cc7dd8863 100644 --- a/java/code/src/com/suse/scc/model/SCCRepositoryJson.java +++ b/java/code/src/com/suse/scc/model/SCCRepositoryJson.java @@ -185,8 +185,9 @@ public int hashCode() { @Override public String toString() { return new ToStringBuilder(this) - .append("sccId", getSCCId()) - .append("description", getDescription()) - .toString(); + .append("sccId", getSCCId()) + .append("name", getName()) + .append("description", getDescription()) + .toString(); } } diff --git a/java/code/src/com/suse/scc/test/SCCClientTest.java b/java/code/src/com/suse/scc/test/SCCClientTest.java index 819a0e931655..ebd42749edca 100644 --- a/java/code/src/com/suse/scc/test/SCCClientTest.java +++ b/java/code/src/com/suse/scc/test/SCCClientTest.java @@ -25,7 +25,6 @@ import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCClientException; -import com.suse.scc.client.SCCConfig; import com.suse.scc.client.SCCFileClient; import com.suse.scc.model.SCCProductJson; import com.suse.scc.model.SCCRepositoryJson; @@ -218,7 +217,7 @@ public void testListRepositoriesFromDirectory() throws Exception { "/com/suse/scc/test/connect/organizations/repositories.json"), new File(tmpDir.getAbsolutePath() + "/organizations_repositories.json")); try { - SCCClient scc = new SCCFileClient(new SCCConfig(tmpDir.getAbsolutePath())); + SCCClient scc = new SCCFileClient(tmpDir.toPath()); List repos = scc.listRepositories(); // Assertions diff --git a/java/code/src/com/suse/scc/test/SCCRequester.java b/java/code/src/com/suse/scc/test/SCCRequester.java index e141ed269869..4584f5e583bb 100644 --- a/java/code/src/com/suse/scc/test/SCCRequester.java +++ b/java/code/src/com/suse/scc/test/SCCRequester.java @@ -17,6 +17,7 @@ import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCClientException; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import org.apache.logging.log4j.LogManager; @@ -43,8 +44,13 @@ public abstract class SCCRequester implements Callable { * @param uri the server URI */ protected SCCRequester(URI uri) { - SCCConfig config = new SCCConfig(uri, "user", "password", null, null, - System.getProperty("java.io.tmpdir"), true); + SCCConfig config = new SCCConfigBuilder() + .setUrl(uri) + .setUsername("user") + .setPassword("password") + .setLoggingDir(System.getProperty("java.io.tmpdir")) + .setSkipOwner(true) + .createSCCConfig(); scc = new SCCWebClient(config); } diff --git a/java/code/src/com/suse/scc/test/SCCSystemRegistrationManagerTest.java b/java/code/src/com/suse/scc/test/SCCSystemRegistrationManagerTest.java index 53ff9775928b..d1b9f45e55c9 100644 --- a/java/code/src/com/suse/scc/test/SCCSystemRegistrationManagerTest.java +++ b/java/code/src/com/suse/scc/test/SCCSystemRegistrationManagerTest.java @@ -46,6 +46,7 @@ import com.suse.scc.SCCSystemRegistrationManager; import com.suse.scc.client.SCCClient; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.SCCOrganizationSystemsUpdateResponse; import com.suse.scc.model.SCCRegisterSystemJson; @@ -79,8 +80,13 @@ public void testSCCSystemRegistrationLifecycle() throws Exception { serverInfo.setCheckin(new Date(0)); // 1970-01-01 00:00:00 UTC testSystem.setServerInfo(serverInfo); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password) { @@ -160,8 +166,13 @@ public void sccSystemRegistrationLifecycleForPAYGInstance() throws Exception { testSystem.setServerInfo(serverInfo); testSystem.setPayg(true); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")); + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig); SCCSystemRegistrationManager sccSystemRegistrationManager = new SCCSystemRegistrationManager(sccWebClient); SCCCachingFactory.initNewSystemsToForward(); @@ -190,8 +201,13 @@ public void testUpdateSystems() throws Exception { serverInfo.setCheckin(new Date(0)); // 1970-01-01 00:00:00 UTC testSystem.setServerInfo(serverInfo); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password) { @@ -266,9 +282,13 @@ class TestSCCWebClient extends SCCWebClient { } } - - TestSCCWebClient sccWebClient = new TestSCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + TestSCCWebClient sccWebClient = new TestSCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password) { @@ -329,8 +349,13 @@ public void testVirtualInfoLibvirt() throws Exception { host.getGuests().stream() .forEach(vi -> vi.setType(VirtualInstanceFactory.getInstance().getVirtualInstanceType("qemu"))); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig) { @Override @@ -414,8 +439,13 @@ public void testVirtualInfoVMware() throws Exception { host.getGuests().stream() .forEach(vi -> vi.setType(VirtualInstanceFactory.getInstance().getVirtualInstanceType("vmware"))); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig) { @Override @@ -513,8 +543,13 @@ public void testVirtualInfoCloud() throws Exception { host.getGuests().stream() .forEach(vi -> vi.setType(VirtualInstanceFactory.getInstance().getVirtualInstanceType("aws_nitro"))); - SCCWebClient sccWebClient = new SCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + SCCWebClient sccWebClient = new SCCWebClient(sccConfig) { @Override diff --git a/java/code/src/com/suse/scc/test/SCCSystemRegistrationTest.java b/java/code/src/com/suse/scc/test/SCCSystemRegistrationTest.java index bf166d288662..43aaedf7c10d 100644 --- a/java/code/src/com/suse/scc/test/SCCSystemRegistrationTest.java +++ b/java/code/src/com/suse/scc/test/SCCSystemRegistrationTest.java @@ -31,6 +31,7 @@ import com.suse.scc.SCCSystemRegistrationManager; import com.suse.scc.client.SCCClientException; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.SCCOrganizationSystemsUpdateResponse; import com.suse.scc.model.SCCRegisterSystemJson; @@ -169,8 +170,13 @@ public void testSuccessSystemRegistrationWhenAllSystemsAreCreated() throws Excep public void testSuccessSystemRegistrationWhenAllSccRequestsFail() throws Exception { // setup this.setupSystems(); - TestSCCWebClient sccWebClient = new TestSCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + TestSCCWebClient sccWebClient = new TestSCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password @@ -215,9 +221,13 @@ public void testSuccessSystemRegistration() throws Exception { for (int i = 0; i < skipRegister; i++) { this.servers.get(i).setPayg(true); } - - TestSCCWebClient sccWebClient = new TestSCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + TestSCCWebClient sccWebClient = new TestSCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password @@ -235,7 +245,6 @@ public SCCOrganizationSystemsUpdateResponse createUpdateSystems( .toList() ); } - }; @@ -336,8 +345,13 @@ public int getCallCnt() { * Creates a Default {@link TestSCCWebClient} instance for testing purposes */ private TestSCCWebClient getDefaultTestSCCWebClient() throws URISyntaxException { - TestSCCWebClient sccWebClient = new TestSCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + return new TestSCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password @@ -351,9 +365,7 @@ public SCCOrganizationSystemsUpdateResponse createUpdateSystems( .toList() ); } - }; - return sccWebClient; } public SCCCredentials getCredentials() { diff --git a/java/code/src/com/suse/scc/test/registration/SCCSystemRegistrationCreateUpdateSystemsTest.java b/java/code/src/com/suse/scc/test/registration/SCCSystemRegistrationCreateUpdateSystemsTest.java index 1bede0331bed..efcc38d86aa0 100644 --- a/java/code/src/com/suse/scc/test/registration/SCCSystemRegistrationCreateUpdateSystemsTest.java +++ b/java/code/src/com/suse/scc/test/registration/SCCSystemRegistrationCreateUpdateSystemsTest.java @@ -18,6 +18,7 @@ import com.suse.scc.client.SCCClientException; import com.suse.scc.client.SCCConfig; +import com.suse.scc.client.SCCConfigBuilder; import com.suse.scc.client.SCCWebClient; import com.suse.scc.model.SCCOrganizationSystemsUpdateResponse; import com.suse.scc.model.SCCRegisterSystemJson; @@ -204,8 +205,13 @@ public int getCallCnt() { * @throws URISyntaxException */ public TestSCCWebClient getDefaultTestSCCWebClient() throws URISyntaxException { - TestSCCWebClient sccWebClient = new TestSCCWebClient(new SCCConfig( - new URI("https://localhost"), "username", "password", "uuid")) { + SCCConfig sccConfig = new SCCConfigBuilder() + .setUrl(new URI("https://localhost")) + .setUsername("username") + .setPassword("password") + .setUuid("uuid") + .createSCCConfig(); + return new TestSCCWebClient(sccConfig) { @Override public SCCOrganizationSystemsUpdateResponse createUpdateSystems( List systems, String username, String password @@ -220,7 +226,6 @@ public SCCOrganizationSystemsUpdateResponse createUpdateSystems( ); } }; - return sccWebClient; } } diff --git a/java/code/src/com/suse/utils/CertificateUtils.java b/java/code/src/com/suse/utils/CertificateUtils.java new file mode 100644 index 000000000000..de1536f97bf9 --- /dev/null +++ b/java/code/src/com/suse/utils/CertificateUtils.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.utils; + +import com.redhat.rhn.common.RhnRuntimeException; +import com.redhat.rhn.manager.satellite.SystemCommandThreadedExecutor; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.quartz.JobExecutionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateParsingException; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class CertificateUtils { + + public static final Path CERTS_PATH = Path.of("/etc/pki/trust/anchors/"); + + private static final Path LOCAL_TRUSTED_ROOT = CERTS_PATH.resolve("LOCAL-RHN-ORG-TRUSTED-SSL-CERT"); + + private static final Path GPG_PUBKEY = Path.of("/srv/susemanager/salt/gpg/mgr-gpg-pub.key"); + + private static final Path CUSTOMER_GPG_DIR = Path.of("/var/spacewalk/gpg"); + + private static final Path CUSTOMER_GPG_RING = CUSTOMER_GPG_DIR.resolve("customer-build-keys.gpg"); + + private static final Logger LOG = LogManager.getLogger(CertificateUtils.class); + + private CertificateUtils() { + // Prevent instantiation + } + + /** + * Loads the local trusted root certificate and returns it as PEM data + * @return a string representation of the root certificate, in PEM format + */ + public static String loadLocalTrustedRoot() throws IOException { + return loadTextFile(LOCAL_TRUSTED_ROOT); + } + + /** + * Loads the local GPG Key used for signing the metadata as ARMORED data. + * @return a string representation of the GPG key + * @throws IOException when reading the data from file fails + */ + public static String loadGpgKey() throws IOException { + return loadTextFile(GPG_PUBKEY); + } + + /** + * Load the specified file and return it Text data in a string + * @param path the path of the file to load + * @return a string representation of the file + * @throws IOException when reading the data from file fails + */ + public static String loadTextFile(Path path) throws IOException { + if (!Files.isReadable(path)) { + return null; + } + + return Files.readString(path); + } + + /** + * Parse the given PEM certificate. + * @param pemCertificate a string representing the PEM certificate. Might be empty or null + * @return the certificate + * @throws CertificateException when an error occurs while parsing the data + */ + public static Optional parse(String pemCertificate) throws CertificateException { + if (StringUtils.isEmpty(pemCertificate)) { + return Optional.empty(); + } + + try (InputStream inputStream = new ByteArrayInputStream(pemCertificate.getBytes(StandardCharsets.UTF_8))) { + return parse(inputStream); + } + catch (IOException ex) { + throw new CertificateParsingException("Unable to load certificate from byte array", ex); + } + } + + /** + * Parse a given PEM certificate + * @param inputStream the input stream containing the PEM certificate + * @return the certificate + * @throws CertificateException when an error occurs while parsing the data + */ + public static Optional parse(InputStream inputStream) throws CertificateException { + if (inputStream == null) { + return Optional.empty(); + } + + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + return Optional.of(certificateFactory.generateCertificate(inputStream)); + } + + /** + * Saves multiple root ca certificates, then updates trusted directory + * + * @param filenameToRootCaCertMap maps filename to root ca certificate actual content + * @throws JobExecutionException if there was an error + */ + public static void saveAndUpdateCertificates(Map filenameToRootCaCertMap) { + if ((null == filenameToRootCaCertMap) || filenameToRootCaCertMap.isEmpty()) { + return; // nothing to do + } + + saveCertificates(filenameToRootCaCertMap); + updateCertificates(); + } + + private static void saveCertificates(Map filenameToRootCaCertMap) { + if ((null == filenameToRootCaCertMap) || filenameToRootCaCertMap.isEmpty()) { + return; // nothing to do + } + + for (Map.Entry pair : filenameToRootCaCertMap.entrySet()) { + String fileName = pair.getKey(); + String rootCaCertContent = pair.getValue(); + + if (fileName.isEmpty()) { + LOG.warn("Skipping illegal empty certificate file name"); + continue; + } + + try { + if (rootCaCertContent.isEmpty()) { + Files.delete(getCertificateSafePath(fileName)); + LOG.info("CA certificate file: {} successfully removed", fileName); + } + else { + Files.writeString(getCertificateSafePath(fileName), rootCaCertContent, StandardCharsets.UTF_8); + LOG.info("CA certificate file: {} successfully written", fileName); + } + } + catch (IOException e) { + LOG.error("Error when {} CA certificate file [{}] {}", + rootCaCertContent.isEmpty() ? "removing" : "writing", fileName, e); + } + catch (IllegalArgumentException e) { + LOG.error("Illegal certificate file name [{}] {}", fileName, e); + } + } + } + + private static void updateCertificates() { + try { + //system command to check if a service to update the ca certificates is present + executeExtCmd(new String[]{"systemctl", "is-active", "--quiet", "ca-certificates.path"}); + } + catch (Exception e) { + LOG.debug("ca-certificates.path service is not active, we will call 'update-ca-certificates' tool"); + executeExtCmd(new String[]{"/usr/share/rhn/certs/update-ca-cert-trust.sh"}); + } + } + + /** + * gets a safe path to a certificate, given its filename + * + * @param fileName the certificate filename + * @return the path in which the certificate should reside + * @throws IllegalArgumentException when the certificate filename is not valid or there is an attack attempt + */ + public static Path getCertificateSafePath(String fileName) throws IllegalArgumentException { + if (null == fileName || fileName.isEmpty()) { + throw new IllegalArgumentException("File name cannot be null or empty"); + } + + if (!fileName.matches("[a-zA-Z0-9:._-]+")) { + throw new IllegalArgumentException("File name contains invalid characters"); + } + + Path filePath = CERTS_PATH.resolve(fileName).normalize(); + if (!filePath.startsWith(CERTS_PATH)) { + //Prevent unauthorized access through path traversal (CWE-22) + throw new IllegalArgumentException("Attempted path traversal attack detected"); + } + if (Files.isSymbolicLink(filePath)) { + throw new IllegalArgumentException("Refusing to delete/create symbolic link: " + filePath); + } + + return filePath; + } + + private static void executeExtCmd(String[] args) { + SystemCommandThreadedExecutor ce = new SystemCommandThreadedExecutor(LOG, true); + int exitCode = ce.execute(args); + + if (exitCode != 0) { + String msg = ce.getLastCommandErrorMessage(); + if (msg.isBlank()) { + msg = ce.getLastCommandOutput(); + } + if (msg.length() > 2300) { + msg = "... " + msg.substring(msg.length() - 2300); + } + throw new RhnRuntimeException("Command '%s' exited with error code %d%s".formatted( + Arrays.toString(args), + exitCode, + msg.isBlank() ? "" : ": " + msg) + ); + } + } + + /** + * Import the GPG Key in the customer keyring + * @param gpgKey the gpg key (armored text) + * @throws IOException if something goes wrong + */ + public static void importGpgKey(String gpgKey) throws IOException { + if (StringUtils.isBlank(gpgKey)) { + LOG.info("No GPG Key provided"); + return; + } + FileAttribute> fileAttributes = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-r-----")); + Path gpgTempFile = Files.createTempFile("susemanager-gpg-", ".tmp", fileAttributes); + try { + + Files.writeString(gpgTempFile, gpgKey, StandardCharsets.UTF_8); + if (!Files.exists(CUSTOMER_GPG_RING)) { + initializeGpgKeyring(); + } + String[] cmdAdd = {"gpg", "--no-default-keyring", "--import", "--import-options", "import-minimal", + "--keyring", CUSTOMER_GPG_RING.toString(), gpgTempFile.toString()}; + executeExtCmd(cmdAdd); + executeExtCmd(new String[]{"/usr/sbin/import-suma-build-keys"}); + } + finally { + Files.deleteIfExists(gpgTempFile); + } + } + + private static void initializeGpgKeyring() { + try { + executeExtCmd(new String[]{"mkdir", "-m", "700", "-p", CUSTOMER_GPG_DIR.toString()}); + executeExtCmd(new String[]{"gpg", "--no-default-keyring", "--keyring", CUSTOMER_GPG_RING.toString(), + "--fingerprint"}); + } + catch (Exception e) { + LOG.error("Failed to initialize the customer gpg keyring: {}", e.getMessage()); + throw e; + } + } +} diff --git a/java/code/src/com/suse/utils/Exceptions.java b/java/code/src/com/suse/utils/Exceptions.java index 4a9e61546af4..6fd7a5d0f4c4 100644 --- a/java/code/src/com/suse/utils/Exceptions.java +++ b/java/code/src/com/suse/utils/Exceptions.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 SUSE LLC + * Copyright (c) 2022--2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -7,14 +7,11 @@ * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 * along with this software; if not, see * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - * - * Red Hat trademarks are not licensed under GPLv2. No permission is - * granted to use or replicate Red Hat trademarks that are incorporated - * in this software or its documentation. */ package com.suse.utils; import java.util.Optional; +import java.util.function.Function; public final class Exceptions { @@ -84,30 +81,61 @@ public static Optional handleByReturning(Throwi /** * Executes an operation and wraps any exception into a runtime exception. * @param operation the operation to perform - * @param type of exception * @throws RuntimeException if an exception occurs during the operation. */ - public static void handleByWrapping(ThrowingRunnable operation) { + public static void handleByWrapping(ThrowingRunnable operation) { + handleByWrapping(() -> { + operation.run(); + return null; + }, ex -> new RuntimeException("Unable to execute operation", ex)); + } + + /** + * Executes an operation and wraps any exception using the provided wrapping function. + * @param operation the operation to perform + * @param exceptionWrappingFunction the function to wrap the exceptions that might be generated by the operation + * @param type of the wrapping exception + * @throws RuntimeException if an exception occurs during the operation. + */ + public static void handleByWrapping( + ThrowingRunnable operation, + Function exceptionWrappingFunction + ) throws W { handleByWrapping(() -> { operation.run(); return null; - }); + }, exceptionWrappingFunction); } /** * Executes an operation and wraps any exception into a runtime exception. * @param operation the operation to perform * @param the type of the return value of the operation - * @param type of exception * @return the result value of the operation * @throws RuntimeException if an exception occurs during the operation. */ - public static T handleByWrapping(ThrowingSupplier operation) { + public static T handleByWrapping(ThrowingSupplier operation) { + return handleByWrapping(operation, ex -> new RuntimeException("Unable to execute operation", ex)); + } + + /** + * Executes an operation and wraps any exception using the provided wrapping function. + * @param operation the operation to perform + * @param exceptionWrappingFunction the function to wrap the exceptions that might be generated by the operation + * @param the type of the return value of the operation + * @param type of the wrapping exception + * @return the result value of the operation + * @throws RuntimeException if an exception occurs during the operation. + */ + public static T handleByWrapping( + ThrowingSupplier operation, + Function exceptionWrappingFunction + ) throws W { try { return operation.get(); } catch (Exception ex) { - throw new RuntimeException("Unable to execute operation", ex); + throw exceptionWrappingFunction.apply(ex); } } } diff --git a/java/code/src/com/suse/utils/test/CertificateUtilsTest.java b/java/code/src/com/suse/utils/test/CertificateUtilsTest.java new file mode 100644 index 000000000000..7f0170faff4e --- /dev/null +++ b/java/code/src/com/suse/utils/test/CertificateUtilsTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + */ + +package com.suse.utils.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import com.suse.utils.CertificateUtils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class CertificateUtilsTest { + + @Test + public void ensureSafePathRejectsNullEmptyFilenames() throws IllegalArgumentException { + String errorMessage = "File name cannot be null or empty"; + assertThrowsExactly(IllegalArgumentException.class, + () -> CertificateUtils.getCertificateSafePath(null), errorMessage); + assertThrowsExactly(IllegalArgumentException.class, + () -> CertificateUtils.getCertificateSafePath(""), errorMessage); + } + + @ParameterizedTest + @ValueSource(strings = {"te$t1", "te%t2", ";test3", "te#t4", "te\\t5", "te\\u0003t6", "te\nt7", + "../test_1-8.txt", ".../test_1-9.txt", "..../test_1-10.txt", "test/../test_1-11.txt"}) + public void ensureSafePathRejectsInvalidChars(String badFilename) throws IllegalArgumentException { + String errorMessage = "File name contains invalid characters"; + assertThrowsExactly(IllegalArgumentException.class, + () -> CertificateUtils.getCertificateSafePath(badFilename), errorMessage); + } + + @Test + public void ensureSafePathRejectsTraversalAttempt() throws IllegalArgumentException { + String errorMessage = "Attempted path traversal attack detected"; + assertThrowsExactly(IllegalArgumentException.class, + () -> CertificateUtils.getCertificateSafePath(".."), errorMessage); + } + + @Test + public void ensureSafePathAcceptsValidCases() throws IllegalArgumentException { + assertEquals("/etc/pki/trust/anchors/test_1-12.txt", + CertificateUtils.getCertificateSafePath("test_1-12.txt").toString()); + assertEquals("/etc/pki/trust/anchors/test___1---13..txt", + CertificateUtils.getCertificateSafePath("test___1---13..txt").toString()); + //ipv4 + assertEquals("/etc/pki/trust/anchors/registration_server_10.1.2.245.pem", + CertificateUtils.getCertificateSafePath("registration_server_10.1.2.245.pem").toString()); + //ipv6 + assertEquals("/etc/pki/trust/anchors/registration_server_2001:db8:3333:4444:5555:6666:7777:8888.pem", + CertificateUtils.getCertificateSafePath( + "registration_server_2001:db8:3333:4444:5555:6666:7777:8888.pem").toString()); + assertEquals("/etc/pki/trust/anchors/registration_server_::.pem", + CertificateUtils.getCertificateSafePath("registration_server_::.pem").toString()); + assertEquals("/etc/pki/trust/anchors/registration_server_2001:db8::.pem", + CertificateUtils.getCertificateSafePath("registration_server_2001:db8::.pem").toString()); + assertEquals("/etc/pki/trust/anchors/registration_server_::1234:5678.pem", + CertificateUtils.getCertificateSafePath("registration_server_::1234:5678.pem").toString()); + } +} diff --git a/java/code/webapp/WEB-INF/web.xml b/java/code/webapp/WEB-INF/web.xml index a40c908e7426..da571f9015ad 100644 --- a/java/code/webapp/WEB-INF/web.xml +++ b/java/code/webapp/WEB-INF/web.xml @@ -167,12 +167,14 @@ AuthFilterSpark /manager/* /saltboot/* + /hub/* SparkFilter /manager/* /saltboot/* + /hub/* diff --git a/java/conf/rhn_java.conf b/java/conf/rhn_java.conf index 23e59320197b..c42d828fdb83 100644 --- a/java/conf/rhn_java.conf +++ b/java/conf/rhn_java.conf @@ -8,7 +8,7 @@ java.customer_service_email = java.development_environment = 0 # the version of API -java.apiversion = 27 +java.apiversion = 28 # lifetime for sandboxes, in days java.sandbox_lifetime = 3 diff --git a/java/manager-build.xml b/java/manager-build.xml index fd6456068de7..1f5915954082 100644 --- a/java/manager-build.xml +++ b/java/manager-build.xml @@ -189,6 +189,7 @@ @@ -213,6 +214,7 @@ nowarn="${nowarn}" encoding="utf-8" fork="yes" + release="17" memoryMaximumSize="512m" includeAntRuntime="false" classpathref="libjars" diff --git a/java/spacewalk-java.changes.carlo.issv3-taskomatic-job b/java/spacewalk-java.changes.carlo.issv3-taskomatic-job new file mode 100644 index 000000000000..2c45e8524c18 --- /dev/null +++ b/java/spacewalk-java.changes.carlo.issv3-taskomatic-job @@ -0,0 +1,2 @@ +- Create a task (root-ca-cert-update) in taskomatic to store + ca-certificates in the trusted certificate path diff --git a/java/spacewalk-java.changes.mackdk.issv3 b/java/spacewalk-java.changes.mackdk.issv3 new file mode 100644 index 000000000000..e2cac31ed86c --- /dev/null +++ b/java/spacewalk-java.changes.mackdk.issv3 @@ -0,0 +1,3 @@ +- Added API for registering ISS hub/peripherals +- Added basic API for token authentication +- Added entities to handle token authentication diff --git a/java/spacewalk-java.changes.mcalmer.issv3-disable-pages b/java/spacewalk-java.changes.mcalmer.issv3-disable-pages new file mode 100644 index 000000000000..3cc9d63da831 --- /dev/null +++ b/java/spacewalk-java.changes.mcalmer.issv3-disable-pages @@ -0,0 +1 @@ +- Bump API version to 28 while removing deprecated functions diff --git a/java/spacewalk-java.changes.mcalmer.issv3-info-apis b/java/spacewalk-java.changes.mcalmer.issv3-info-apis new file mode 100644 index 000000000000..fddd0dbd14b6 --- /dev/null +++ b/java/spacewalk-java.changes.mcalmer.issv3-info-apis @@ -0,0 +1 @@ +- Add API to configure report database access for the hub diff --git a/schema/spacewalk/common/data/rhnTaskoBunch.sql b/schema/spacewalk/common/data/rhnTaskoBunch.sql index 02bd415befe6..10899ee82598 100644 --- a/schema/spacewalk/common/data/rhnTaskoBunch.sql +++ b/schema/spacewalk/common/data/rhnTaskoBunch.sql @@ -141,4 +141,10 @@ VALUES (sequence_nextval('rhn_tasko_bunch_id_seq'), 'payg-dimension-computation- INSERT INTO rhnTaskoBunch (id, name, description, org_bunch) VALUES (sequence_nextval('rhn_tasko_bunch_id_seq'), 'oval-data-sync-bunch', 'Generate OVAL data required to increase the accuracy of CVE audit queries.', null); +INSERT INTO rhnTaskoBunch (id, name, description, org_bunch) +VALUES (sequence_nextval('rhn_tasko_bunch_id_seq'), 'root-ca-cert-update-bunch', 'Updates root ca certificates', null); + +INSERT INTO rhnTaskoBunch (id, name, description, org_bunch) +VALUES (sequence_nextval('rhn_tasko_bunch_id_seq'), 'custom-gpg-key-import-bunch', 'Import a customer GPG key into the keyring', null); + commit; diff --git a/schema/spacewalk/common/data/rhnTaskoTask.sql b/schema/spacewalk/common/data/rhnTaskoTask.sql index 68ab76fb68ad..bcd6b31aa181 100644 --- a/schema/spacewalk/common/data/rhnTaskoTask.sql +++ b/schema/spacewalk/common/data/rhnTaskoTask.sql @@ -149,4 +149,10 @@ VALUES (sequence_nextval('rhn_tasko_task_id_seq'), 'payg-dimension-computation', INSERT INTO rhnTaskoTask (id, name, class) VALUES (sequence_nextval('rhn_tasko_task_id_seq'), 'oval-data-sync', 'com.redhat.rhn.taskomatic.task.OVALDataSync'); +INSERT INTO rhnTaskoTask (id, name, class) +VALUES (sequence_nextval('rhn_tasko_task_id_seq'), 'root-ca-cert-update', 'com.redhat.rhn.taskomatic.task.RootCaCertUpdateTask'); + +INSERT INTO rhnTaskoTask (id, name, class) +VALUES (sequence_nextval('rhn_tasko_task_id_seq'), 'custom-gpg-key-import', 'com.redhat.rhn.taskomatic.task.GpgImportTask'); + commit; diff --git a/schema/spacewalk/common/data/rhnTaskoTemplate.sql b/schema/spacewalk/common/data/rhnTaskoTemplate.sql index 9e47c049352b..6654c98d38ae 100644 --- a/schema/spacewalk/common/data/rhnTaskoTemplate.sql +++ b/schema/spacewalk/common/data/rhnTaskoTemplate.sql @@ -329,4 +329,18 @@ INSERT INTO rhnTaskoTemplate (id, bunch_id, task_id, ordering, start_if) 0, null); +INSERT INTO rhnTaskoTemplate (id, bunch_id, task_id, ordering, start_if) + VALUES (sequence_nextval('rhn_tasko_template_id_seq'), + (SELECT id FROM rhnTaskoBunch WHERE name='root-ca-cert-update-bunch'), + (SELECT id FROM rhnTaskoTask WHERE name='root-ca-cert-update'), + 0, + null); + +INSERT INTO rhnTaskoTemplate (id, bunch_id, task_id, ordering, start_if) + VALUES (sequence_nextval('rhn_tasko_template_id_seq'), + (SELECT id FROM rhnTaskoBunch WHERE name='custom-gpg-key-import-bunch'), + (SELECT id FROM rhnTaskoTask WHERE name='custom-gpg-key-import'), + 0, + null); + commit; diff --git a/schema/spacewalk/common/tables/suseCredentials.sql b/schema/spacewalk/common/tables/suseCredentials.sql index d5517687e0fe..3604a2f6db85 100644 --- a/schema/spacewalk/common/tables/suseCredentials.sql +++ b/schema/spacewalk/common/tables/suseCredentials.sql @@ -23,7 +23,7 @@ CREATE TABLE suseCredentials ON DELETE CASCADE, type VARCHAR(128) DEFAULT ('scc') NOT NULL CONSTRAINT rhn_type_ck - CHECK (type IN ('scc', 'vhm', 'registrycreds', 'cloudrmt', 'reportcreds', 'rhui')), + CHECK (type IN ('scc', 'vhm', 'registrycreds', 'cloudrmt', 'reportcreds', 'rhui', 'hub_scc')), url VARCHAR(256), username VARCHAR(64), password VARCHAR(4096), @@ -56,6 +56,10 @@ ALTER TABLE susecredentials WHEN 'reportcreds' THEN username is not null and username <> '' and password is not null and password <> '' + WHEN 'hub_scc' THEN + username is not null and username <> '' + and password is not null and password <> '' + and url is not null and url <> '' END ); diff --git a/schema/spacewalk/common/tables/suseISSAccessToken.sql b/schema/spacewalk/common/tables/suseISSAccessToken.sql new file mode 100644 index 000000000000..750fc32a4c27 --- /dev/null +++ b/schema/spacewalk/common/tables/suseISSAccessToken.sql @@ -0,0 +1,28 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. +-- + +CREATE TABLE suseISSAccessToken +( + id BIGINT CONSTRAINT suse_isstoken_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + token VARCHAR(1024) NOT NULL, + type iss_access_token_type_t NOT NULL, + server_fqdn VARCHAR(512) NOT NULL, + valid BOOLEAN, + expiration_date TIMESTAMPTZ NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE UNIQUE INDEX suse_isstoken_server_fqdn_type_idx + ON suseISSAccessToken (server_fqdn, type); diff --git a/schema/spacewalk/common/tables/suseISSHub.sql b/schema/spacewalk/common/tables/suseISSHub.sql new file mode 100644 index 000000000000..160e167484b4 --- /dev/null +++ b/schema/spacewalk/common/tables/suseISSHub.sql @@ -0,0 +1,26 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +CREATE TABLE suseISSHub +( + id BIGINT CONSTRAINT suse_iss_hub_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + fqdn VARCHAR(253) NOT NULL + CONSTRAINT suse_iss_hub_fqdn_uq UNIQUE, + root_ca TEXT, + gpg_key TEXT, + mirror_creds_id NUMERIC NULL + CONSTRAINT suse_iss_hub_mirrcreds_fk + REFERENCES suseCredentials (id) ON DELETE SET NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); diff --git a/schema/spacewalk/common/tables/suseISSPeripheral.sql b/schema/spacewalk/common/tables/suseISSPeripheral.sql new file mode 100644 index 000000000000..75e45f629b66 --- /dev/null +++ b/schema/spacewalk/common/tables/suseISSPeripheral.sql @@ -0,0 +1,26 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. +-- + +CREATE TABLE suseISSPeripheral +( + id BIGINT CONSTRAINT suse_issper_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + fqdn VARCHAR(253) NOT NULL + CONSTRAINT suse_issper_fqdn_uq UNIQUE, + root_ca TEXT, + mirror_creds_id NUMERIC NULL + CONSTRAINT suse_issper_mirrcreds_fk + REFERENCES suseCredentials (id) ON DELETE SET NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); diff --git a/schema/spacewalk/common/tables/suseISSPeripheralChannels.sql b/schema/spacewalk/common/tables/suseISSPeripheralChannels.sql new file mode 100644 index 000000000000..3db3c208fd2d --- /dev/null +++ b/schema/spacewalk/common/tables/suseISSPeripheralChannels.sql @@ -0,0 +1,30 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. +-- + +CREATE TABLE suseISSPeripheralChannels +( + id BIGINT CONSTRAINT suse_issperchan_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + peripheral_id BIGINT NOT NULL + CONSTRAINT suse_issperchan_pid_fk + REFERENCES suseISSPeripheral(id) ON DELETE CASCADE, + channel_id NUMERIC NOT NULL + CONSTRAINT suse_issperchan_cid_fk + REFERENCES rhnChannel(id) ON DELETE CASCADE, + peripheral_org_id INTEGER NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE UNIQUE INDEX use_issperchan_pid_cid_uq +ON suseISSPeripheralChannels (peripheral_id, channel_id); diff --git a/schema/spacewalk/common/tables/tables.deps b/schema/spacewalk/common/tables/tables.deps index f0e99d4b6cab..b491291280e8 100644 --- a/schema/spacewalk/common/tables/tables.deps +++ b/schema/spacewalk/common/tables/tables.deps @@ -1,4 +1,5 @@ # +# Copyright (c) 2024 SUSE LLC # Copyright (c) 2008--2018 Red Hat, Inc. # # This software is licensed to you under the GNU General Public License, @@ -240,6 +241,10 @@ suseImageInfoChannel :: suseImageInfo rhnChannel suseImageInfoInstalledProduct :: suseInstalledProduct suseImageInfo suseImageProfile :: rhnRegTokenChannels web_customer suseImageStore suseImageStore :: suseCredentials web_customer suseImageStoreType +suseISSHub :: suseCredentials +suseISSPeripheral :: suseCredentials +suseISSPeripheralChannels :: suseISSPeripheral rhnChannel +suseISSAccessToken :: iss_access_token_type_t suseMaintenanceCalendar :: web_customer suseMaintenanceSchedule :: web_customer suseMaintenanceCalendar suseMgrServerInfo :: rhnServer rhnPackageEVR suseCredentials diff --git a/schema/spacewalk/postgres/class/iss_access_token_type_t.sql b/schema/spacewalk/postgres/class/iss_access_token_type_t.sql new file mode 100644 index 000000000000..c9804999f027 --- /dev/null +++ b/schema/spacewalk/postgres/class/iss_access_token_type_t.sql @@ -0,0 +1,15 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. +-- + +CREATE TYPE iss_access_token_type_t AS ENUM ( + 'issued', + 'consumed' +); diff --git a/schema/spacewalk/susemanager-schema.changes.carlo.issv3-taskomatic-job b/schema/spacewalk/susemanager-schema.changes.carlo.issv3-taskomatic-job new file mode 100644 index 000000000000..2ed7a454f56b --- /dev/null +++ b/schema/spacewalk/susemanager-schema.changes.carlo.issv3-taskomatic-job @@ -0,0 +1 @@ +- Add SQL scripts to create root-ca-cert-update task in taskomatic diff --git a/schema/spacewalk/susemanager-schema.changes.mackdk.issv3-auth b/schema/spacewalk/susemanager-schema.changes.mackdk.issv3-auth new file mode 100644 index 000000000000..72bc2e9c6c65 --- /dev/null +++ b/schema/spacewalk/susemanager-schema.changes.mackdk.issv3-auth @@ -0,0 +1 @@ +- Added tables to handle token authentication diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/001-issv3-add-tables.sql b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/001-issv3-add-tables.sql new file mode 100644 index 000000000000..3492917edcff --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/001-issv3-add-tables.sql @@ -0,0 +1,128 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +CREATE TABLE IF NOT EXISTS suseISSHub +( + id BIGINT CONSTRAINT suse_iss_hub_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + fqdn VARCHAR(253) NOT NULL + CONSTRAINT suse_iss_hub_fqdn_uq UNIQUE, + root_ca TEXT, + gpg_key TEXT, + mirror_creds_id NUMERIC NULL + CONSTRAINT suse_iss_hub_mirrcreds_fk + REFERENCES suseCredentials (id) ON DELETE SET NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE TABLE IF NOT EXISTS suseISSPeripheral +( + id BIGINT CONSTRAINT suse_issper_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + fqdn VARCHAR(253) NOT NULL + CONSTRAINT suse_issper_fqdn_uq UNIQUE, + root_ca TEXT, + mirror_creds_id NUMERIC NULL + CONSTRAINT suse_issper_mirrcreds_fk + REFERENCES suseCredentials (id) ON DELETE SET NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE TABLE IF NOT EXISTS suseISSPeripheralChannels +( + id BIGINT CONSTRAINT suse_issperchan_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + peripheral_id BIGINT NOT NULL + CONSTRAINT suse_issperchan_pid_fk + REFERENCES suseISSPeripheral(id) ON DELETE CASCADE, + channel_id NUMERIC NOT NULL + CONSTRAINT suse_issperchan_cid_fk + REFERENCES rhnChannel(id) ON DELETE CASCADE, + peripheral_org_id INTEGER NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS suse_issperchan_pid_cid_uq +ON suseISSPeripheralChannels (peripheral_id, channel_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'iss_access_token_type_t') THEN + CREATE TYPE iss_access_token_type_t AS ENUM ( + 'issued', + 'consumed' + ); + ELSE + RAISE NOTICE 'type "iss_access_token_type_t" already exists, skipping'; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS suseISSAccessToken +( + id BIGINT CONSTRAINT suse_isstoken_id_pk PRIMARY KEY + GENERATED ALWAYS AS IDENTITY, + token VARCHAR(1024) NOT NULL, + type iss_access_token_type_t NOT NULL, + server_fqdn VARCHAR(512) NOT NULL, + valid BOOLEAN, + expiration_date TIMESTAMPTZ NULL, + created TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL, + modified TIMESTAMPTZ + DEFAULT (current_timestamp) NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS suse_isstoken_server_fqdn_type_idx + ON suseISSAccessToken (server_fqdn, type); + +ALTER TABLE suseCredentials + DROP CONSTRAINT rhn_type_ck; + +ALTER TABLE suseCredentials + ADD CONSTRAINT rhn_type_ck + CHECK (type IN ('scc', 'vhm', 'registrycreds', 'cloudrmt', 'reportcreds', 'rhui', 'hub_scc')); + +ALTER TABLE susecredentials + DROP CONSTRAINT cred_type_check; + +ALTER TABLE susecredentials + ADD CONSTRAINT cred_type_check CHECK ( + CASE type + WHEN 'scc' THEN + username is not null and username <> '' + and password is not null and password <> '' + WHEN 'cloudrmt' THEN + username is not null and username <> '' + and password is not null and password <> '' + and url is not null and url <> '' + WHEN 'vhm' THEN + username is not null and username <> '' + and password is not null and password <> '' + WHEN 'registrycreds' THEN + username is not null and username <> '' + and password is not null and password <> '' + WHEN 'reportcreds' THEN + username is not null and username <> '' + and password is not null and password <> '' + WHEN 'hub_scc' THEN + username is not null and username <> '' + and password is not null and password <> '' + and url is not null and url <> '' + END + ); diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/002-issv3-rootcacertificates-taskomatic.sql b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/002-issv3-rootcacertificates-taskomatic.sql new file mode 100644 index 000000000000..a760fbc28d48 --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/002-issv3-rootcacertificates-taskomatic.sql @@ -0,0 +1,29 @@ +-- +-- Copyright (c) 2025 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + + +INSERT INTO rhnTaskoBunch (id, name, description, org_bunch) +SELECT sequence_nextval('rhn_tasko_bunch_id_seq'), 'root-ca-cert-update-bunch', 'Updates root ca certificates', null FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoBunch WHERE name = 'root-ca-cert-update-bunch'); + +INSERT INTO rhnTaskoTask (id, name, class) +SELECT sequence_nextval('rhn_tasko_task_id_seq'), 'root-ca-cert-update', 'com.redhat.rhn.taskomatic.task.RootCaCertUpdateTask' FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoTask WHERE name = 'root-ca-cert-update'); + + +INSERT INTO rhnTaskoTemplate (id, bunch_id, task_id, ordering, start_if) +SELECT sequence_nextval('rhn_tasko_template_id_seq'), + (SELECT id FROM rhnTaskoBunch WHERE name='root-ca-cert-update-bunch'), + (SELECT id FROM rhnTaskoTask WHERE name='root-ca-cert-update'), + 0, + null FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoTemplate + WHERE bunch_id = (SELECT id FROM rhnTaskoBunch WHERE name='root-ca-cert-update-bunch') + AND task_id = (SELECT id FROM rhnTaskoTask WHERE name='root-ca-cert-update')); diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/003-issv3-gpgkey-import-task.sql b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/003-issv3-gpgkey-import-task.sql new file mode 100644 index 000000000000..896d3ee56461 --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.1.3-to-susemanager-schema-5.1.4/003-issv3-gpgkey-import-task.sql @@ -0,0 +1,26 @@ +-- +-- Copyright (c) 2025 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +INSERT INTO rhnTaskoBunch (id, name, description, org_bunch) +SELECT sequence_nextval('rhn_tasko_bunch_id_seq'), 'custom-gpg-key-import-bunch', 'Import a customer GPG key into the keyring', null FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoBunch WHERE name = 'custom-gpg-key-import-bunch'); + +INSERT INTO rhnTaskoTask (id, name, class) +SELECT sequence_nextval('rhn_tasko_task_id_seq'), 'custom-gpg-key-import', 'com.redhat.rhn.taskomatic.task.GpgImportTask' FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoTask WHERE name = 'custom-gpg-key-import'); + +INSERT INTO rhnTaskoTemplate (id, bunch_id, task_id, ordering, start_if) +SELECT sequence_nextval('rhn_tasko_template_id_seq'), + (SELECT id FROM rhnTaskoBunch WHERE name='custom-gpg-key-import-bunch'), + (SELECT id FROM rhnTaskoTask WHERE name='custom-gpg-key-import'), + 0, null FROM dual +WHERE NOT EXISTS (SELECT 1 FROM rhnTaskoTemplate + WHERE bunch_id = (SELECT id FROM rhnTaskoBunch WHERE name='custom-gpg-key-import-bunch') + AND task_id = (SELECT id FROM rhnTaskoTask WHERE name='custom-gpg-key-import')); diff --git a/web/html/src/branding/css/base/components/dropdown-menu.scss b/web/html/src/branding/css/base/components/dropdown-menu.scss index 6c7e0e0586c3..b5f89e55cb95 100644 --- a/web/html/src/branding/css/base/components/dropdown-menu.scss +++ b/web/html/src/branding/css/base/components/dropdown-menu.scss @@ -9,7 +9,7 @@ a { color: inherit; - display: block; + display: inline-flex; padding: 3px 20px; font-weight: 400; line-height: 1.5; diff --git a/web/html/src/branding/css/base/theme.scss b/web/html/src/branding/css/base/theme.scss index f33ea61d64b2..8a65d2ca40eb 100644 --- a/web/html/src/branding/css/base/theme.scss +++ b/web/html/src/branding/css/base/theme.scss @@ -519,7 +519,7 @@ nav.navbar-pf { display: block; padding: 0 1px; a { - display: block; + display: inline-flex; border: 0px; color: $link-color; padding: 2px 20px; diff --git a/web/html/src/components/hub/AddTokenButton.tsx b/web/html/src/components/hub/AddTokenButton.tsx new file mode 100644 index 000000000000..59675111ffb0 --- /dev/null +++ b/web/html/src/components/hub/AddTokenButton.tsx @@ -0,0 +1,266 @@ +import React from "react"; + +import { Button, DropdownButton, LinkButton } from "components/buttons"; +import { Dialog } from "components/dialog/Dialog"; +import { TextField } from "components/fields"; +import { Form, Text } from "components/input"; +import { MessagesContainer, showInfoToastr } from "components/toastr"; +import Validation from "components/validation"; + +import Network from "utils/network"; + +import { CreateTokenRequest, TokenType } from "./types"; + +export enum AddTokenMethod { + Issue, + Store, + IssueAndStore, +} + +type Props = { + method: AddTokenMethod; + onCreated?: () => void; +}; + +type State = { + createRequest: CreateTokenRequest | undefined; + createRequestValid: boolean; + generatedToken: string | undefined; +}; + +export class AddTokenButton extends React.Component { + static defaultProps: Partial = { + method: AddTokenMethod.IssueAndStore, + onCreated: undefined, + }; + + public constructor(props: Props) { + super(props); + + this.state = { + createRequest: undefined, + createRequestValid: false, + generatedToken: undefined, + }; + } + + public render(): React.ReactNode { + return ( + <> + {this.renderButton()} + {this.renderCreationForm()} + {this.renderTokenModal()} + + ); + } + + private renderButton(): React.ReactNode { + switch (this.props.method) { + case AddTokenMethod.Issue: + return ( +