From 68c1bf58d54cf909f5ee4724afa0fd9493f03a44 Mon Sep 17 00:00:00 2001 From: Tobias Harnickell Date: Fri, 21 Aug 2026 09:40:58 +0200 Subject: [PATCH] #4564 change db user password Signed-off-by: Tobias Harnickell --- .../cloudbeaver/model/WebConnectionInfo.java | 2 +- .../model/app/WebAppConfiguration.java | 4 + .../cloudbeaver/model/config/CBAppConfig.java | 11 + .../model/config/AdminServerConfig.java | 10 + .../CBServerConfigurationController.java | 5 + .../server/CBServerConfigurationMapper.java | 1 + .../bundles/io.cloudbeaver.server/plugin.xml | 3 + .../schema/service.core.graphqls | 15 ++ .../io/cloudbeaver/model/WebServerConfig.java | 5 + .../server/events/WSSecurityAuditEvent.java | 99 ++++++++ .../events/WSSecurityAuditEventHandler.java | 44 ++++ .../service/core/DBWServiceCore.java | 9 + .../service/core/WebServiceBindingCore.java | 7 + .../service/core/impl/WebServiceCore.java | 237 ++++++++++++++++++ .../schema/service.admin.graphqls | 2 + .../core-root/src/ServerConfigResource.ts | 4 + .../changeConnectionUserPassword.gql | 13 + .../fragments/ServerConfig/ServerConfig.gql | 1 + .../Form/ServerConfigurationSecurityForm.tsx | 10 + .../IServerConfigurationFormPartState.ts | 1 + .../ServerConfigurationFormPart.ts | 2 + .../plugin-administration/src/locales/en.ts | 2 + .../ACTION_CONNECTION_CHANGE_DB_PASSWORD.ts | 13 + .../ChangeDatabasePasswordDialog.tsx | 126 ++++++++++ .../ContextMenu/ConnectionMenuBootstrap.ts | 45 +++- .../ContextMenu/MENU_CONNECTION_SECURITY.ts | 12 + .../plugin-connections/src/locales/en.ts | 11 + webapp/yarn.lock | 4 +- 28 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEvent.java create mode 100644 server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEventHandler.java create mode 100644 webapp/packages/core-sdk/src/queries/connections/changeConnectionUserPassword.gql create mode 100644 webapp/packages/plugin-connections/src/ContextMenu/Actions/ACTION_CONNECTION_CHANGE_DB_PASSWORD.ts create mode 100644 webapp/packages/plugin-connections/src/ContextMenu/ChangeDatabasePasswordDialog/ChangeDatabasePasswordDialog.tsx create mode 100644 webapp/packages/plugin-connections/src/ContextMenu/MENU_CONNECTION_SECURITY.ts diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebConnectionInfo.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebConnectionInfo.java index 40cf606df08..a4fb6c666be 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebConnectionInfo.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebConnectionInfo.java @@ -78,7 +78,7 @@ public class WebConnectionInfo { private static final String FEATURE_RESTRICT_METADATA_EDIT = "restrictMetadataEdit"; private static final String TOOL_SESSION_MANAGER = "sessionManager"; - + private final WebSession session; private final DBPDataSourceContainer dataSourceContainer; private WebServerError connectError; diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAppConfiguration.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAppConfiguration.java index fefe5225f60..dbeefe0ce7c 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAppConfiguration.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAppConfiguration.java @@ -36,6 +36,10 @@ public interface WebAppConfiguration extends ServletAppConfiguration { boolean isAdminCredentialsSaveEnabled(); + default boolean isDbUserPasswordChangeEnabled() { + return false; + } + default String[] getDisabledBetaFeatures() { return new String[0]; } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/config/CBAppConfig.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/config/CBAppConfig.java index 5f73d3ebeee..07a05e3f22c 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/config/CBAppConfig.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/config/CBAppConfig.java @@ -51,6 +51,7 @@ public class CBAppConfig extends BaseWebAppConfiguration implements ServletAuthC private boolean forwardProxy; private boolean publicCredentialsSaveEnabled; private boolean adminCredentialsSaveEnabled; + private boolean dbUserPasswordChangeEnabled; private boolean linkExternalCredentialsWithUser; private boolean redirectOnFederatedAuth; @@ -80,6 +81,7 @@ public CBAppConfig() { this.supportsCustomConnections = true; this.publicCredentialsSaveEnabled = true; this.adminCredentialsSaveEnabled = true; + this.dbUserPasswordChangeEnabled = false; this.redirectOnFederatedAuth = false; this.enabledDrivers = new String[0]; this.disabledDrivers = new String[0]; @@ -104,6 +106,7 @@ public CBAppConfig(CBAppConfig src) { this.supportsCustomConnections = src.supportsCustomConnections; this.publicCredentialsSaveEnabled = src.publicCredentialsSaveEnabled; this.adminCredentialsSaveEnabled = src.adminCredentialsSaveEnabled; + this.dbUserPasswordChangeEnabled = src.dbUserPasswordChangeEnabled; this.redirectOnFederatedAuth = src.redirectOnFederatedAuth; this.enabledDrivers = src.enabledDrivers; this.disabledDrivers = src.disabledDrivers; @@ -154,6 +157,14 @@ public void setPublicCredentialsSaveEnabled(boolean publicCredentialsSaveEnabled this.publicCredentialsSaveEnabled = publicCredentialsSaveEnabled; } + public boolean isDbUserPasswordChangeEnabled() { + return dbUserPasswordChangeEnabled; + } + + public void setDbUserPasswordChangeEnabled(boolean dbUserPasswordChangeEnabled) { + this.dbUserPasswordChangeEnabled = dbUserPasswordChangeEnabled; + } + public boolean isAdminCredentialsSaveEnabled() { return adminCredentialsSaveEnabled; } diff --git a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/model/config/AdminServerConfig.java b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/model/config/AdminServerConfig.java index aa3b5fe9b58..bf9095f892c 100644 --- a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/model/config/AdminServerConfig.java +++ b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/model/config/AdminServerConfig.java @@ -44,6 +44,7 @@ public class AdminServerConfig { private final boolean customConnectionsEnabled; private final boolean publicCredentialsSaveEnabled; private final boolean adminCredentialsSaveEnabled; + private final boolean dbUserPasswordChangeEnabled; private final List enabledFeatures; private final List enabledAuthProviders; private final String[] enabledDrivers; @@ -75,6 +76,11 @@ public AdminServerConfig(@NotNull Map params) { "adminCredentialsSaveEnabled", appConfig.isAdminCredentialsSaveEnabled() ); + this.dbUserPasswordChangeEnabled = JSONUtils.getBoolean( + params, + "dbUserPasswordChangeEnabled", + appConfig.isDbUserPasswordChangeEnabled() + ); this.resourceManagerEnabled = JSONUtils.getBoolean(params, "resourceManagerEnabled", appConfig.isResourceManagerEnabled()); this.secretManagerEnabled = JSONUtils.getBoolean(params, "secretManagerEnabled", appConfig.isSecretManagerEnabled()); @@ -163,6 +169,10 @@ public boolean isAdminCredentialsSaveEnabled() { return adminCredentialsSaveEnabled; } + public boolean isDbUserPasswordChangeEnabled() { + return dbUserPasswordChangeEnabled; + } + public long getSessionExpireTime() { return sessionExpireTime; } diff --git a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationController.java b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationController.java index 82f57ded5ee..fa0d8378e58 100644 --- a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationController.java +++ b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationController.java @@ -413,6 +413,11 @@ protected Map collectConfigurationProperties( appConfigProperties, "adminCredentialsSaveEnabled", appConfig.isAdminCredentialsSaveEnabled()); + copyConfigValue( + oldAppConfig, + appConfigProperties, + "dbUserPasswordChangeEnabled", + appConfig.isDbUserPasswordChangeEnabled()); copyConfigValue( oldAppConfig, appConfigProperties, "enableReverseProxyAuth", appConfig.isEnabledReverseProxyAuth()); copyConfigValue( diff --git a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationMapper.java b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationMapper.java index 27b808d1d86..65c9aff3cf6 100644 --- a/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationMapper.java +++ b/server/bundles/io.cloudbeaver.server.ce/src/io/cloudbeaver/server/CBServerConfigurationMapper.java @@ -63,6 +63,7 @@ protected void populateConfigurations(@NotNull I input, @NotNull C serverConfig, appConfig.setSupportsCustomConnections(input.isCustomConnectionsEnabled()); appConfig.setPublicCredentialsSaveEnabled(input.isPublicCredentialsSaveEnabled()); appConfig.setAdminCredentialsSaveEnabled(input.isAdminCredentialsSaveEnabled()); + appConfig.setDbUserPasswordChangeEnabled(input.isDbUserPasswordChangeEnabled()); updateDisabledFeaturesConfig(appConfig, input.getEnabledFeatures()); // custom logic for enabling embedded drivers updateDisabledDriversConfig(appConfig, input.getDisabledDrivers()); diff --git a/server/bundles/io.cloudbeaver.server/plugin.xml b/server/bundles/io.cloudbeaver.server/plugin.xml index 3d6146b4528..dfb5a693acf 100644 --- a/server/bundles/io.cloudbeaver.server/plugin.xml +++ b/server/bundles/io.cloudbeaver.server/plugin.xml @@ -66,6 +66,9 @@ + + + diff --git a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls index d0079dfce91..cd0e52b5813 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls @@ -192,6 +192,9 @@ type ServerConfig { "Defines is it is possible to save global database credentials" adminCredentialsSaveEnabled: Boolean! + "Defines if the change-DB-password mutation is enabled server-wide" + dbUserPasswordChangeEnabled: Boolean! + "Defines if the server requires a license" licenseRequired: Boolean! "Defines if the server license is valid" @@ -902,6 +905,18 @@ extend type Mutation { "Test connection configuration. Returns remote server version" testConnection( config: ConnectionConfig!, projectId: ID): ConnectionInfo! + """ + Change the DB user password backing this connection. + Requires PERMISSION_PROJECT_DATASOURCES_EDIT on the connection's project. + DBWebException surfaces the driver's exception verbatim on DB rejection. + """ + changeConnectionUserPassword( + projectId: ID, + connectionId: ID!, + oldPassword: String!, + newPassword: String! + ): Boolean! + "Test network handler connectivity" testNetworkHandler(projectId: ID, connectionId: ID, config: NetworkHandlerConfigInput! ): NetworkEndpointInfo! diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java index efad31d7403..b8eb3a480ad 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java @@ -82,6 +82,11 @@ public boolean isAdminCredentialsSaveEnabled() { return application.getAppConfiguration().isAdminCredentialsSaveEnabled(); } + @Property + public boolean isDbUserPasswordChangeEnabled() { + return application.getAppConfiguration().isDbUserPasswordChangeEnabled(); + } + @Property public boolean isLicenseRequired() { return application.isLicenseRequired(); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEvent.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEvent.java new file mode 100644 index 00000000000..580f548aa78 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEvent.java @@ -0,0 +1,99 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.server.events; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.websocket.event.WSAbstractEvent; + +public class WSSecurityAuditEvent extends WSAbstractEvent { + public static final String TOPIC = "cb_security_audit"; + public static final String ID = "cb_security_audit_updated"; + + public enum Kind { + /** Fires before invoking the DBeaver core password-change handler. */ + ATTEMPTED, + /** Fires after handler success and credential-store persistence. */ + SUCCEEDED, + /** Fires on handler exception or credential-store persistence failure. */ + FAILED, + /** Fires on any of the pre-invocation gate rejections. */ + GATE_REJECTED + } + + @Nullable + private final String projectId; + @Nullable + private final String connectionId; + @Nullable + private final String driverId; + @NotNull + private final Kind kind; + @Nullable + private final String reasonCode; + @Nullable + private final String errorClass; + + public WSSecurityAuditEvent( + @Nullable String sessionId, + @Nullable String userId, + @Nullable String projectId, + @Nullable String connectionId, + @Nullable String driverId, + @NotNull Kind kind, + @Nullable String reasonCode, + @Nullable String errorClass + ) { + super(ID, TOPIC, sessionId, userId); + this.projectId = projectId; + this.connectionId = connectionId; + this.driverId = driverId; + this.kind = kind; + this.reasonCode = reasonCode; + this.errorClass = errorClass; + } + + @Nullable + public String getProjectId() { + return projectId; + } + + @Nullable + public String getConnectionId() { + return connectionId; + } + + @Nullable + public String getDriverId() { + return driverId; + } + + @NotNull + public Kind getKind() { + return kind; + } + + @Nullable + public String getReasonCode() { + return reasonCode; + } + + @Nullable + public String getErrorClass() { + return errorClass; + } +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEventHandler.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEventHandler.java new file mode 100644 index 00000000000..4df4b820bc1 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/events/WSSecurityAuditEventHandler.java @@ -0,0 +1,44 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.server.events; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.websocket.WSEventHandler; + +public class WSSecurityAuditEventHandler implements WSEventHandler { + private static final Log auditLog = Log.getLog(WSSecurityAuditEventHandler.class); + + @Override + public void handleEvent(@NotNull WSSecurityAuditEvent event) { + auditLog.info(String.format( + "topic=%s id=%s kind=%s reasonCode=%s userId=%s sessionId=%s " + + "projectId=%s connectionId=%s driverId=%s errorClass=%s timestamp=%d", + event.getTopicId(), + event.getId(), + event.getKind(), + event.getReasonCode(), + event.getUserId(), + event.getSessionId(), + event.getProjectId(), + event.getConnectionId(), + event.getDriverId(), + event.getErrorClass(), + event.getTimestamp() + )); + } +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java index 58a0b50afff..c20136b5e8c 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java @@ -170,6 +170,15 @@ WebConnectionInfo testConnection( @NotNull Map connectionConfig ) throws DBWebException; + @WebProjectAction(requireProjectPermissions = {RMConstants.PERMISSION_PROJECT_DATASOURCES_EDIT}) + boolean changeConnectionUserPassword( + @NotNull WebSession webSession, + @Nullable @WebObjectId String projectId, + @NotNull String connectionId, + @WebParameterSecure @NotNull String oldPassword, + @WebParameterSecure @NotNull String newPassword + ) throws DBWebException; + @WebProjectAction(requireProjectPermissions = {RMConstants.PERMISSION_PROJECT_DATASOURCES_EDIT}) WebNetworkEndpointInfo testNetworkHandler( @NotNull WebSession webSession, diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java index e5516cbd086..ba8a4cdcde7 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java @@ -150,6 +150,13 @@ public void bindWiring(DBWBindingContext model) throws DBWebException { .dataFetcher("testConnection", env -> getService(env).testConnection( getWebSession(env), getProjectReference(env), getArgumentVal(env, "config") )) + .dataFetcher("changeConnectionUserPassword", env -> getService(env).changeConnectionUserPassword( + getWebSession(env), + getProjectReference(env), + getArgumentVal(env, "connectionId"), + getArgumentVal(env, "oldPassword"), + getArgumentVal(env, "newPassword") + )) .dataFetcher("testNetworkHandler", env -> getService(env).testNetworkHandler( getWebSession(env), getProjectReference(env), diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java index e30d47c2ec7..bd3c2530e0a 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java @@ -26,6 +26,7 @@ import io.cloudbeaver.registry.WebSessionHandlerDescriptor; import io.cloudbeaver.server.WebAppUtils; import io.cloudbeaver.server.WebApplication; +import io.cloudbeaver.server.events.WSSecurityAuditEvent; import io.cloudbeaver.service.core.DBWServiceCore; import io.cloudbeaver.service.security.SMUtils; import io.cloudbeaver.utils.ServletAppUtils; @@ -36,10 +37,13 @@ import jakarta.servlet.http.HttpServletResponse; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import org.eclipse.core.runtime.IAdaptable; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.DBConstants; +import org.jkiss.dbeaver.model.DBPDataSource; import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.access.DBAUserPasswordManager; import org.jkiss.dbeaver.model.DBPDataSourceFolder; import org.jkiss.dbeaver.model.app.DBPDataSourceRegistry; import org.jkiss.dbeaver.model.app.DBPProject; @@ -84,6 +88,42 @@ public class WebServiceCore implements DBWServiceCore { private static final Log log = Log.getLog(WebServiceCore.class); + private static final int MAX_PASSWORD_LENGTH = 128; + // ASCII space (0x20) is the first printable character. Anything below is a control character. + private static final int FIRST_PRINTABLE_ASCII = 0x20; + + private static boolean containsBlockedPasswordChar(String password) { + for (int i = 0; i < password.length(); i++) { + if (password.charAt(i) < FIRST_PRINTABLE_ASCII) { + return true; + } + } + return false; + } + + private void emitPasswordChangeAudit( + @NotNull WebSession webSession, + @Nullable DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId, + @NotNull WSSecurityAuditEvent.Kind kind, + @Nullable String reasonCode, + @Nullable String errorClass + ) { + String driverId = container == null ? null : container.getDriver().getFullId(); + WSSecurityAuditEvent event = new WSSecurityAuditEvent( + webSession.getSessionId(), + webSession.getUserId(), + projectId, + connectionId, + driverId, + kind, + reasonCode, + errorClass + ); + ServletAppUtils.getServletApplication().getEventController().addEvent(event); + } + @Override public WebServerConfig getServerConfig(@Nullable WebSession webSession) { WebServerConfig webServerConfig = WebAppUtils.getWebApplication().getWebServerConfig(); @@ -641,6 +681,203 @@ private DataSourceDescriptor getDataSourceDescriptor( return testDataSource; } + @Override + public boolean changeConnectionUserPassword( + @NotNull WebSession webSession, + @Nullable String projectId, + @NotNull String connectionId, + @NotNull String oldPassword, + @NotNull String newPassword + ) throws DBWebException { + DBPDataSourceContainer container = null; + try { + requireServerFlagEnabled(webSession, projectId, connectionId); + container = resolveContainer(webSession, projectId, connectionId); + validateNewPassword(webSession, container, projectId, connectionId, newPassword); + ensureConnected(webSession, container, projectId, connectionId); + DBAUserPasswordManager manager = resolveUserPasswordManager(webSession, container, projectId, connectionId); + String userName = resolveUserName(webSession, container, projectId, connectionId); + applyPasswordChange(webSession, container, projectId, connectionId, manager, userName, oldPassword, newPassword); + persistNewPassword(webSession, container, projectId, connectionId, newPassword); + safeDisconnect(webSession, container); + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.SUCCEEDED, null, null); + return true; + } catch (DBWebException e) { + // Inner stages emit their own audits. Pass through so the Throwable branch below does not + // re-audit the same failure as UNEXPECTED_ERROR. + throw e; + } catch (Throwable t) { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.FAILED, "UNEXPECTED_ERROR", t.getClass().getName()); + throw new DBWebException("Password change failed", t); + } + } + + private void requireServerFlagEnabled( + @NotNull WebSession webSession, + @Nullable String projectId, + @NotNull String connectionId + ) throws DBWebException { + if (WebAppUtils.getWebApplication().getAppConfiguration().isDbUserPasswordChangeEnabled()) { + return; + } + emitPasswordChangeAudit(webSession, null, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "SERVER_FLAG_DISABLED", null); + throw new DBWebException("Password change is disabled by the administrator."); + } + + @NotNull + private DBPDataSourceContainer resolveContainer( + @NotNull WebSession webSession, + @Nullable String projectId, + @NotNull String connectionId + ) throws DBWebException { + DBPDataSourceContainer container = null; + String lookupErrorClass = null; + try { + container = WebDataSourceUtils.getLocalOrGlobalDataSource(webSession, projectId, connectionId); + } catch (DBWebException e) { + lookupErrorClass = e.getClass().getName(); + } + if (container != null) { + return container; + } + emitPasswordChangeAudit(webSession, null, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "CONNECTION_NOT_FOUND", lookupErrorClass); + throw new DBWebException("Connection not found."); + } + + private void validateNewPassword( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId, + @NotNull String newPassword + ) throws DBWebException { + if (newPassword.length() > MAX_PASSWORD_LENGTH) { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "PASSWORD_TOO_LONG", null); + throw new DBWebException( + "Password exceeds the maximum length of " + MAX_PASSWORD_LENGTH + " characters."); + } + if (containsBlockedPasswordChar(newPassword)) { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "INVALID_PASSWORD_CHARACTER", null); + throw new DBWebException("Password contains characters that are not allowed."); + } + } + + private void ensureConnected( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId + ) throws DBWebException { + if (container.isConnected()) { + return; + } + try { + container.connect(webSession.getProgressMonitor(), true, false); + } catch (Exception e) { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "CONNECT_FAILED", e.getClass().getName()); + throw new DBWebException("Cannot connect to database.", e); + } + } + + @NotNull + private DBAUserPasswordManager resolveUserPasswordManager( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId + ) throws DBWebException { + DBAUserPasswordManager manager = null; + DBPDataSource dataSource = container.getDataSource(); + if (dataSource instanceof IAdaptable adaptable) { + manager = adaptable.getAdapter(DBAUserPasswordManager.class); + } + if (manager != null) { + return manager; + } + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "DIALECT_UNSUPPORTED", null); + throw new DBWebException("This driver does not support password change from CloudBeaver."); + } + + @NotNull + private String resolveUserName( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId + ) throws DBWebException { + String userName = container.getConnectionConfiguration().getUserName(); + if (!CommonUtils.isEmpty(userName)) { + return userName; + } + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.GATE_REJECTED, "USERNAME_MISSING", null); + throw new DBWebException("Connection has no user name configured."); + } + + private void applyPasswordChange( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId, + @NotNull DBAUserPasswordManager manager, + @NotNull String userName, + @NotNull String oldPassword, + @NotNull String newPassword + ) throws DBWebException { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.ATTEMPTED, null, null); + try { + manager.changeUserPassword(webSession.getProgressMonitor(), userName, newPassword, oldPassword); + } catch (DBException e) { + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.FAILED, "HANDLER_ERROR", e.getClass().getName()); + throw new DBWebException("Password change failed", e); + } + } + + private void persistNewPassword( + @NotNull WebSession webSession, + @NotNull DBPDataSourceContainer container, + @Nullable String projectId, + @NotNull String connectionId, + @NotNull String newPassword + ) throws DBWebException { + String failureClass = null; + boolean persisted; + try { + container.getConnectionConfiguration().setUserPassword(newPassword); + container.getActualConnectionConfiguration().setUserPassword(newPassword); + persisted = container.isTemporary() || container.persistConfiguration(); + } catch (RuntimeException e) { + failureClass = e.getClass().getName(); + persisted = false; + } + if (persisted) { + return; + } + emitPasswordChangeAudit(webSession, container, projectId, connectionId, + WSSecurityAuditEvent.Kind.FAILED, "PERSIST_FAILED", failureClass); + throw new DBWebException( + "Database password was changed but CloudBeaver failed to persist the new credential. " + + "The connection will require re-entry of the new password."); + } + + private void safeDisconnect(@NotNull WebSession webSession, @NotNull DBPDataSourceContainer container) { + try { + container.disconnect(webSession.getProgressMonitor()); + } catch (DBException ignored) { + // Reconnect on next use is acceptable. + } + } + @Override public WebNetworkEndpointInfo testNetworkHandler( @NotNull WebSession webSession, diff --git a/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls b/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls index 7aabde1a441..aae73375e92 100644 --- a/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls +++ b/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls @@ -205,6 +205,8 @@ input ServerConfigInput { publicCredentialsSaveEnabled: Boolean "Whether saving credentials is allowed" adminCredentialsSaveEnabled: Boolean + "Whether the change-DB-password mutation is enabled server-wide" + dbUserPasswordChangeEnabled: Boolean "Whether the resource manager is enabled" resourceManagerEnabled: Boolean "Whether the secret manager is enabled" diff --git a/webapp/packages/core-root/src/ServerConfigResource.ts b/webapp/packages/core-root/src/ServerConfigResource.ts index 4431996f023..d521c74d800 100644 --- a/webapp/packages/core-root/src/ServerConfigResource.ts +++ b/webapp/packages/core-root/src/ServerConfigResource.ts @@ -73,6 +73,10 @@ export class ServerConfigResource extends CachedDataResource {translate('administration_configuration_wizard_configuration_security_public_credentials')} + + {translate('administration_configuration_wizard_configuration_security_db_user_password_change')} + ); diff --git a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/IServerConfigurationFormPartState.ts b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/IServerConfigurationFormPartState.ts index b0eec39205b..88eb62af222 100644 --- a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/IServerConfigurationFormPartState.ts +++ b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/IServerConfigurationFormPartState.ts @@ -19,6 +19,7 @@ const ServerConfigurationFormPartStateConfigSchema = schema.looseObject({ enabledAuthProviders: schema.array(schema.string()).optional(), enabledFeatures: schema.array(schema.string()).optional(), publicCredentialsSaveEnabled: schema.boolean().optional(), + dbUserPasswordChangeEnabled: schema.boolean().optional(), resourceManagerEnabled: schema.boolean().optional(), secretManagerEnabled: schema.boolean().optional(), serverName: schema.string().trim().optional(), diff --git a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts index 4a9522dbebc..4e4aa716240 100644 --- a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts +++ b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts @@ -35,6 +35,7 @@ function DEFAULT_STATE_GETTER(): IServerConfigurationFormPartState { enabledAuthProviders: [], enabledFeatures: [], publicCredentialsSaveEnabled: false, + dbUserPasswordChangeEnabled: false, resourceManagerEnabled: false, secretManagerEnabled: false, serverName: '', @@ -159,6 +160,7 @@ export class ServerConfigurationFormPart extends FormPart = observer( + function ChangeDatabasePasswordDialog({ payload, resolveDialog, rejectDialog }) { + const translate = useTranslate(); + const graphQLService = useService(GraphQLService); + const notificationService = useService(NotificationService); + + const state = useObservableRef( + (): State => ({ currentPassword: '', newPassword: '', repeatPassword: '', submitting: false }), + { + currentPassword: observable.ref, + newPassword: observable.ref, + repeatPassword: observable.ref, + submitting: observable.ref, + }, + false, + ); + + const form = useForm({ + async onSubmit() { + if (state.submitting) { + return; + } + state.submitting = true; + try { + await graphQLService.sdk.changeConnectionUserPassword({ + projectId: payload.projectId, + connectionId: payload.connectionId, + oldPassword: state.currentPassword, + newPassword: state.newPassword, + }); + notificationService.logSuccess({ + title: 'plugin_connections_change_db_password_success', + }); + resolveDialog(null); + } catch (exception) { + notificationService.logException(exception as Error, 'plugin_connections_change_db_password_failed'); + } finally { + state.submitting = false; + } + }, + }); + + const passwordValidation = usePasswordValidation(form); + const repeatValidation = useFormCustomInputValidation( + value => (isValuesEqual(value, state.newPassword, '') ? null : 'plugin_connections_change_db_password_mismatch'), + form, + ); + + return ( + + + +
+ + + {translate('plugin_connections_change_db_password_current')} + + + {translate('plugin_connections_change_db_password_new')} + + + {translate('plugin_connections_change_db_password_repeat')} + + +
+
+ + + + + +
+ ); + }, +); diff --git a/webapp/packages/plugin-connections/src/ContextMenu/ConnectionMenuBootstrap.ts b/webapp/packages/plugin-connections/src/ContextMenu/ConnectionMenuBootstrap.ts index b345def3ef1..cb7a38908e7 100644 --- a/webapp/packages/plugin-connections/src/ContextMenu/ConnectionMenuBootstrap.ts +++ b/webapp/packages/plugin-connections/src/ContextMenu/ConnectionMenuBootstrap.ts @@ -15,9 +15,10 @@ import { DATA_CONTEXT_CONNECTION, } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { LocalizationService } from '@cloudbeaver/core-localization'; import { NotificationService } from '@cloudbeaver/core-events'; -import { DATA_CONTEXT_NAV_NODE, EObjectFeature, NavTreeSettingsService } from '@cloudbeaver/core-navigation-tree'; +import { DATA_CONTEXT_NAV_NODE, EObjectFeature } from '@cloudbeaver/core-navigation-tree'; import { getCachedMapResourceLoaderState } from '@cloudbeaver/core-resource'; import { ServerConfigResource } from '@cloudbeaver/core-root'; import { getUniqueName } from '@cloudbeaver/core-utils'; @@ -26,10 +27,13 @@ import { MENU_APP_ACTIONS } from '@cloudbeaver/plugin-top-app-bar'; import { PublicConnectionFormService } from '../PublicConnectionForm/PublicConnectionFormService.js'; import { ACTION_CONNECTION_CHANGE_CREDENTIALS } from './Actions/ACTION_CONNECTION_CHANGE_CREDENTIALS.js'; +import { ACTION_CONNECTION_CHANGE_DB_PASSWORD } from './Actions/ACTION_CONNECTION_CHANGE_DB_PASSWORD.js'; import { ACTION_CONNECTION_CLONE } from './Actions/ACTION_CONNECTION_CLONE.js'; import { ACTION_CONNECTION_DISCONNECT } from './Actions/ACTION_CONNECTION_DISCONNECT.js'; import { ACTION_CONNECTION_DISCONNECT_ALL } from './Actions/ACTION_CONNECTION_DISCONNECT_ALL.js'; import { ACTION_CONNECTION_EDIT } from './Actions/ACTION_CONNECTION_EDIT.js'; +import { ChangeDatabasePasswordDialog } from './ChangeDatabasePasswordDialog/ChangeDatabasePasswordDialog.js'; +import { MENU_CONNECTION_SECURITY } from './MENU_CONNECTION_SECURITY.js'; import { MENU_CONNECTIONS } from './MENU_CONNECTIONS.js'; import { MENU_NAVIGATION_TREE_MANAGE } from '@cloudbeaver/plugin-navigation-tree'; @@ -44,7 +48,7 @@ import { MENU_NAVIGATION_TREE_MANAGE } from '@cloudbeaver/plugin-navigation-tree ConnectionsSettingsService, ServerConfigResource, LocalizationService, - NavTreeSettingsService, + CommonDialogService, ]) export class ConnectionMenuBootstrap extends Bootstrap { constructor( @@ -58,6 +62,7 @@ export class ConnectionMenuBootstrap extends Bootstrap { private readonly connectionsSettingsService: ConnectionsSettingsService, private readonly serverConfigResource: ServerConfigResource, private readonly localizationService: LocalizationService, + private readonly commonDialogService: CommonDialogService, ) { super(); } @@ -220,6 +225,42 @@ export class ConnectionMenuBootstrap extends Bootstrap { } }, }); + + // UX-only gate. Backend re-checks PERMISSION_PROJECT_DATASOURCES_EDIT on the mutation. + this.menuService.addCreator({ + root: true, + contexts: [DATA_CONTEXT_CONNECTION], + getItems: (context, items) => { + const connectionKey = context.get(DATA_CONTEXT_CONNECTION)!; + const connection = this.connectionInfoResource.get(connectionKey); + if (!this.serverConfigResource.dbUserPasswordChangeEnabled) return items; + if (!connection?.canEdit) return items; + return [...items, MENU_CONNECTION_SECURITY]; + }, + }); + + this.menuService.addCreator({ + menus: [MENU_CONNECTION_SECURITY], + getItems: (context, items) => [...items, ACTION_CONNECTION_CHANGE_DB_PASSWORD], + }); + + this.actionService.addHandler({ + id: 'connection-change-db-password-handler', + actions: [ACTION_CONNECTION_CHANGE_DB_PASSWORD], + contexts: [DATA_CONTEXT_CONNECTION], + isDisabled: context => { + const connectionKey = context.get(DATA_CONTEXT_CONNECTION); + return !connectionKey || !this.connectionInfoResource.get(connectionKey); + }, + handler: async context => { + const connectionKey = context.get(DATA_CONTEXT_CONNECTION); + if (!connectionKey) return; + await this.commonDialogService.open(ChangeDatabasePasswordDialog, { + projectId: connectionKey.projectId, + connectionId: connectionKey.connectionId, + }); + }, + }); } private addConnectionsMenuToTopAppBar() { diff --git a/webapp/packages/plugin-connections/src/ContextMenu/MENU_CONNECTION_SECURITY.ts b/webapp/packages/plugin-connections/src/ContextMenu/MENU_CONNECTION_SECURITY.ts new file mode 100644 index 00000000000..c728103707d --- /dev/null +++ b/webapp/packages/plugin-connections/src/ContextMenu/MENU_CONNECTION_SECURITY.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createMenu } from '@cloudbeaver/core-view'; + +export const MENU_CONNECTION_SECURITY = createMenu('connection-security', { + label: 'plugin_connections_menu_security', +}); diff --git a/webapp/packages/plugin-connections/src/locales/en.ts b/webapp/packages/plugin-connections/src/locales/en.ts index e23cdd1e5b5..df1a7c36097 100644 --- a/webapp/packages/plugin-connections/src/locales/en.ts +++ b/webapp/packages/plugin-connections/src/locales/en.ts @@ -1,4 +1,15 @@ export default [ + ['plugin_connections_menu_security', 'Security'], + ['plugin_connections_change_db_password_menu_title', 'Change database password'], + ['plugin_connections_change_db_password_dialog_title', 'Change database password'], + ['plugin_connections_change_db_password_current', 'Current password'], + ['plugin_connections_change_db_password_new', 'New password'], + ['plugin_connections_change_db_password_repeat', 'Repeat new password'], + ['plugin_connections_change_db_password_submit', 'Change'], + ['plugin_connections_change_db_password_success', 'Database password changed'], + ['plugin_connections_change_db_password_mismatch', 'New passwords do not match'], + ['plugin_connections_change_db_password_failed', 'Password change failed'], + ['plugin_connections_new_connection_dialog_title', 'New connection'], ['plugin_connections_connection_form_part_main', 'Main'], ['plugin_connections_connection_form_part_main_auth_model', 'Authentication model'], diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 8a0cd0f2c1b..52a261ab2d1 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -17326,11 +17326,11 @@ __metadata: "react-data-grid@file:./artifacts::locator=%40dbeaver%2Freact-data-grid%40workspace%3Acommon-react%2F%40dbeaver%2Freact-data-grid": version: 7.0.0-beta.59 - resolution: "react-data-grid@file:./artifacts#./artifacts::hash=9473b2&locator=%40dbeaver%2Freact-data-grid%40workspace%3Acommon-react%2F%40dbeaver%2Freact-data-grid" + resolution: "react-data-grid@file:./artifacts#./artifacts::hash=f90774&locator=%40dbeaver%2Freact-data-grid%40workspace%3Acommon-react%2F%40dbeaver%2Freact-data-grid" peerDependencies: react: ^19.2 react-dom: ^19.2 - checksum: 10c0/a9b4676d7710ff1972bf8043bbc002b039deab561ba5d443811318dc9a81fe2bc0b1637583531ce3be905326018a10101f435a7cf42dcda3be055ba684dfc1bc + checksum: 10c0/9f130ff2ea3a5b9f209be8ec02c96201f11aec03f1d5ea3eadaf170ba6630483795813a20bd39c6b9b6588ba30b76b001bef3167194a7b00304cd5ff605d5e43 languageName: node linkType: hard