diff --git a/changelog/unreleased/pr-25244.toml b/changelog/unreleased/pr-25244.toml new file mode 100644 index 000000000000..98eca2902f9e --- /dev/null +++ b/changelog/unreleased/pr-25244.toml @@ -0,0 +1,4 @@ +type = "fixed" +message = "Fix deserialization error when editing GreyNoise Quick IP Lookup data adapter." + +pulls = ["25244"] diff --git a/graylog2-server/src/main/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapter.java b/graylog2-server/src/main/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapter.java index 7ca9fd2ba75d..866b69fbc014 100644 --- a/graylog2-server/src/main/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapter.java +++ b/graylog2-server/src/main/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapter.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.auto.value.AutoValue; @@ -46,8 +47,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import jakarta.annotation.Nonnull; import jakarta.inject.Inject; - import jakarta.validation.constraints.NotEmpty; import java.io.IOException; @@ -236,10 +237,27 @@ public static abstract class Config implements LookupDataAdapterConfiguration { @NotEmpty public abstract EncryptedValue apiToken(); + public abstract Builder toBuilder(); + public static Builder builder() { return new AutoValue_GreyNoiseQuickIPDataAdapter_Config.Builder(); } + @Override + @JsonIgnore + public LookupDataAdapterConfiguration prepareConfigUpdate(@Nonnull LookupDataAdapterConfiguration newConfig) { + final Config newGreyNoiseConfig = (Config) newConfig; + EncryptedValue newApiToken = newGreyNoiseConfig.apiToken(); + + if (newApiToken.isKeepValue()) { + newApiToken = apiToken(); + } else if (newApiToken.isDeleteValue()) { + newApiToken = EncryptedValue.createUnset(); + } + + return newGreyNoiseConfig.toBuilder().apiToken(newApiToken).build(); + } + @AutoValue.Builder public abstract static class Builder { @JsonCreator diff --git a/graylog2-server/src/main/java/org/graylog2/plugin/lookup/LookupDataAdapterConfiguration.java b/graylog2-server/src/main/java/org/graylog2/plugin/lookup/LookupDataAdapterConfiguration.java index 5697e1a739f1..2cf2417db5f0 100644 --- a/graylog2-server/src/main/java/org/graylog2/plugin/lookup/LookupDataAdapterConfiguration.java +++ b/graylog2-server/src/main/java/org/graylog2/plugin/lookup/LookupDataAdapterConfiguration.java @@ -22,6 +22,8 @@ import com.google.common.collect.Multimap; import org.graylog2.lookup.adapters.LookupDataAdapterValidationContext; +import jakarta.annotation.Nonnull; + import java.util.Optional; @JsonTypeInfo( @@ -70,4 +72,22 @@ default Optional> validate(LookupDataAdapterValidationC default boolean isCloudCompatible() { return true; } + + /** + * Prepares the config for an update by merging encrypted values from the existing config. + *

+ * When a config contains {@link org.graylog2.security.encryption.EncryptedValue} fields, + * the client sends {@code {"keep_value": true}} to indicate the existing value should be preserved. + * Implementations must override this method to replace such sentinel values with the actual + * encrypted values from the existing (this) config. + *

+ * This method is called on the existing config with the new config as argument. + * + * @param newConfig the incoming config from the update request + * @return the merged config with encrypted values properly resolved + */ + @JsonIgnore + default LookupDataAdapterConfiguration prepareConfigUpdate(@Nonnull LookupDataAdapterConfiguration newConfig) { + return newConfig; + } } diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/lookup/LookupTableResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/lookup/LookupTableResource.java index c681fcb5ba6f..deff286585c5 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/lookup/LookupTableResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/lookup/LookupTableResource.java @@ -69,6 +69,7 @@ import org.graylog2.lookup.dto.LookupTableDto; import org.graylog2.plugin.lookup.LookupCache; import org.graylog2.plugin.lookup.LookupDataAdapter; +import org.graylog2.plugin.lookup.LookupDataAdapterConfiguration; import org.graylog2.plugin.lookup.LookupPreview; import org.graylog2.plugin.lookup.LookupResult; import org.graylog2.plugin.rest.ValidationResult; @@ -628,7 +629,15 @@ public DataAdapterApi updateAdapter(@ApiParam(name = "idOrName") @PathParam("idO @Valid @ApiParam DataAdapterApi toUpdate) { checkLookupAdapterId(idOrName, toUpdate); checkPermission(RestPermissions.LOOKUP_TABLES_EDIT, toUpdate.id()); - DataAdapterDto saved = dbDataAdapterService.saveAndPostEvent(toUpdate.toDto()); + + final DataAdapterDto existingDto = dbDataAdapterService.get(idOrName) + .orElseThrow(() -> new NotFoundException("Data adapter <" + idOrName + "> not found.")); + + final LookupDataAdapterConfiguration mergedConfig = + existingDto.config().prepareConfigUpdate(toUpdate.config()); + final DataAdapterDto dtoToSave = toUpdate.toDto().toBuilder().config(mergedConfig).build(); + + DataAdapterDto saved = dbDataAdapterService.saveAndPostEvent(dtoToSave); return DataAdapterApi.fromDto(saved); } diff --git a/graylog2-server/src/test/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapterConfigTest.java b/graylog2-server/src/test/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapterConfigTest.java new file mode 100644 index 000000000000..7ba93e240b6b --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/integrations/dataadapters/GreyNoiseQuickIPDataAdapterConfigTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.integrations.dataadapters; + +import org.graylog2.plugin.lookup.LookupDataAdapterConfiguration; +import org.graylog2.security.encryption.EncryptedValue; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GreyNoiseQuickIPDataAdapterConfigTest { + + private static final EncryptedValue EXISTING_TOKEN = EncryptedValue.builder() + .value("encrypted-secret") + .salt("test-salt") + .isKeepValue(false) + .isDeleteValue(false) + .build(); + + private GreyNoiseQuickIPDataAdapter.Config existingConfig() { + return GreyNoiseQuickIPDataAdapter.Config.builder() + .type(GreyNoiseQuickIPDataAdapter.NAME) + .apiToken(EXISTING_TOKEN) + .build(); + } + + @Test + void prepareConfigUpdatePreservesTokenWhenKeepValue() { + final GreyNoiseQuickIPDataAdapter.Config existing = existingConfig(); + final GreyNoiseQuickIPDataAdapter.Config incoming = GreyNoiseQuickIPDataAdapter.Config.builder() + .type(GreyNoiseQuickIPDataAdapter.NAME) + .apiToken(EncryptedValue.createWithKeepValue()) + .build(); + + final LookupDataAdapterConfiguration result = existing.prepareConfigUpdate(incoming); + + assertThat(result).isInstanceOf(GreyNoiseQuickIPDataAdapter.Config.class); + final GreyNoiseQuickIPDataAdapter.Config resultConfig = (GreyNoiseQuickIPDataAdapter.Config) result; + assertThat(resultConfig.apiToken()).isEqualTo(EXISTING_TOKEN); + assertThat(resultConfig.apiToken().isSet()).isTrue(); + } + + @Test + void prepareConfigUpdateClearsTokenWhenDeleteValue() { + final GreyNoiseQuickIPDataAdapter.Config existing = existingConfig(); + final GreyNoiseQuickIPDataAdapter.Config incoming = GreyNoiseQuickIPDataAdapter.Config.builder() + .type(GreyNoiseQuickIPDataAdapter.NAME) + .apiToken(EncryptedValue.createWithDeleteValue()) + .build(); + + final LookupDataAdapterConfiguration result = existing.prepareConfigUpdate(incoming); + + final GreyNoiseQuickIPDataAdapter.Config resultConfig = (GreyNoiseQuickIPDataAdapter.Config) result; + assertThat(resultConfig.apiToken().isSet()).isFalse(); + assertThat(resultConfig.apiToken().isKeepValue()).isFalse(); + assertThat(resultConfig.apiToken().isDeleteValue()).isFalse(); + } + + @Test + void prepareConfigUpdateUsesNewTokenWhenSetValue() { + final GreyNoiseQuickIPDataAdapter.Config existing = existingConfig(); + final EncryptedValue newToken = EncryptedValue.builder() + .value("new-encrypted-value") + .salt("new-salt") + .isKeepValue(false) + .isDeleteValue(false) + .build(); + final GreyNoiseQuickIPDataAdapter.Config incoming = GreyNoiseQuickIPDataAdapter.Config.builder() + .type(GreyNoiseQuickIPDataAdapter.NAME) + .apiToken(newToken) + .build(); + + final LookupDataAdapterConfiguration result = existing.prepareConfigUpdate(incoming); + + final GreyNoiseQuickIPDataAdapter.Config resultConfig = (GreyNoiseQuickIPDataAdapter.Config) result; + assertThat(resultConfig.apiToken()).isEqualTo(newToken); + } + + @Test + void defaultPrepareConfigUpdateReturnsNewConfig() { + // Verify the default interface method just passes through + final GreyNoiseQuickIPDataAdapter.Config config = existingConfig(); + final GreyNoiseQuickIPDataAdapter.Config incoming = GreyNoiseQuickIPDataAdapter.Config.builder() + .type(GreyNoiseQuickIPDataAdapter.NAME) + .apiToken(EncryptedValue.createUnset()) + .build(); + + // The override should still work, but let's verify the result is the incoming config when a new value is set + final LookupDataAdapterConfiguration result = config.prepareConfigUpdate(incoming); + final GreyNoiseQuickIPDataAdapter.Config resultConfig = (GreyNoiseQuickIPDataAdapter.Config) result; + assertThat(resultConfig.apiToken()).isEqualTo(EncryptedValue.createUnset()); + } +} diff --git a/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterForm.tsx b/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterForm.tsx index f3d033d0be0f..45aa873163b7 100644 --- a/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterForm.tsx +++ b/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterForm.tsx @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { Formik, Form } from 'formik'; @@ -79,10 +79,18 @@ const DataAdapterForm = ({ type, title, saved, onCancel, create = false, dataAda const { updateAdapter, updatingAdapter } = useUpdateAdapter(); const adapterPlugins = usePluginEntities('lookupTableAdapters'); - const plugin = React.useMemo(() => adapterPlugins.find((p) => p.type === type), [adapterPlugins, type]); + const plugin = adapterPlugins.find((p) => p.type === type); - const DocComponent = React.useMemo(() => plugin?.documentationComponent, [plugin]); - const pluginDisplayName = React.useMemo(() => plugin?.displayName || type, [plugin, type]); + const DocComponent = plugin?.documentationComponent; + const pluginDisplayName = plugin?.displayName || type; + + const initialValues = useMemo(() => { + if (plugin?.prepareConfig && dataAdapter?.config) { + return { ...dataAdapter, config: plugin.prepareConfig(dataAdapter.config) }; + } + + return dataAdapter; + }, [dataAdapter, plugin]); const handleSubmit = async (values: LookupTableAdapter) => { const promise = create ? createAdapter(values) : updateAdapter(values); @@ -100,16 +108,13 @@ const DataAdapterForm = ({ type, title, saved, onCancel, create = false, dataAda }); }; - const canModify = React.useMemo( - () => create || (!loadingScopePermissions && scopePermissions?.is_mutable), - [create, loadingScopePermissions, scopePermissions?.is_mutable], - ); + const canModify = create || (!loadingScopePermissions && scopePermissions?.is_mutable); return ( - <Formik initialValues={dataAdapter} onSubmit={handleSubmit} validateOnBlur={false} enableReinitialize> + <Formik initialValues={initialValues} onSubmit={handleSubmit} validateOnBlur={false} enableReinitialize> {({ isSubmitting, isValid }) => ( <FlexForm className="form form-horizontal"> <Row $gap="xl" style={{ flexGrow: 1 }}> diff --git a/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterTypeSelect.tsx b/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterTypeSelect.tsx index 2f77f24995ab..0ad491e8c1ba 100644 --- a/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterTypeSelect.tsx +++ b/graylog2-web-interface/src/components/lookup-tables/adapter-form/AdapterTypeSelect.tsx @@ -63,28 +63,16 @@ function AdapterTypeSelect({ adapterConfigType, onAdapterChange }: Props) { return []; }, [types, fetchingDataAdapterTypes, adapterPlugins]); - const _getCorrectUserpasswd = (config: LookupTableAdapter['config']) => { - if (config.user_passwd.is_set) return { is_set: true, keep_value: true }; - - return { set_value: '' }; - }; - const handleTypeSelect = React.useCallback( (adapterType: string) => { const defaultConfig = { ...types[adapterType].default_config }; - const isLDAP = defaultConfig.type === 'LDAP'; - - const configWithPassword = { - ...defaultConfig, - ...(isLDAP ? { user_passwd: _getCorrectUserpasswd(defaultConfig) } : {}), - }; onAdapterChange({ id: null, title: '', name: '', description: '', - config: configWithPassword, + config: defaultConfig, }); }, [onAdapterChange, types], diff --git a/graylog2-web-interface/src/components/lookup-tables/types.ts b/graylog2-web-interface/src/components/lookup-tables/types.ts index 38aca8dc2c8a..023853a6c38c 100644 --- a/graylog2-web-interface/src/components/lookup-tables/types.ts +++ b/graylog2-web-interface/src/components/lookup-tables/types.ts @@ -44,6 +44,7 @@ export interface DataAdapterPluginType { formComponent?: any; summaryComponent?: any; documentationComponent?: any; + prepareConfig?: (config: Record<string, unknown>) => Record<string, unknown>; } declare module 'graylog-web-plugin/plugin' { diff --git a/graylog2-web-interface/src/integrations/bindings.jsx b/graylog2-web-interface/src/integrations/bindings.jsx index e8b7c210a939..a9595b87e27d 100644 --- a/graylog2-web-interface/src/integrations/bindings.jsx +++ b/graylog2-web-interface/src/integrations/bindings.jsx @@ -32,6 +32,7 @@ import TeamsNotificationSummary from './event-notifications/event-notification-t import GreyNoiseAdapterFieldSet from './dataadapters/GreyNoiseAdapterFieldSet'; import GreyNoiseAdapterSummary from './dataadapters/GreyNoiseAdapterSummary'; import GreyNoiseAdapterDocumentation from './dataadapters/GreyNoiseAdapterDocumentation'; +import prepareGreyNoiseConfig from './dataadapters/prepareGreyNoiseConfig'; import TeamsNotificationV2Form from './event-notifications/event-notification-types/TeamsNotificationV2Form'; import TeamsNotificationV2Summary from './event-notifications/event-notification-types/TeamsNotificationV2Summary'; import TeamsNotificationV2Details from './event-notifications/event-notification-details/TeamsNotificationV2Details'; @@ -86,6 +87,7 @@ const bindings = { formComponent: GreyNoiseAdapterFieldSet, summaryComponent: GreyNoiseAdapterSummary, documentationComponent: GreyNoiseAdapterDocumentation, + prepareConfig: prepareGreyNoiseConfig, }, ], }; diff --git a/graylog2-web-interface/src/integrations/dataadapters/GreyNoiseAdapterFieldSet.tsx b/graylog2-web-interface/src/integrations/dataadapters/GreyNoiseAdapterFieldSet.tsx index 2b4dc90dfad2..f731d0f09269 100644 --- a/graylog2-web-interface/src/integrations/dataadapters/GreyNoiseAdapterFieldSet.tsx +++ b/graylog2-web-interface/src/integrations/dataadapters/GreyNoiseAdapterFieldSet.tsx @@ -14,44 +14,33 @@ * along with this program. If not, see * <http://www.mongodb.com/licensing/server-side-public-license>. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useState } from 'react'; import { Button, Input } from 'components/bootstrap'; import type { ValidationState } from 'components/common/types'; +import type { EncryptedFieldValue } from 'components/configurationforms/types'; type GreyNoiseAdapterFieldSetProps = { config: { api_token: any; }; - updateConfig: (...args: any[]) => void; + setFieldValue: (...args: any[]) => void; validationState: (...args: any[]) => ValidationState; validationMessage: (...args: any[]) => string; }; const GreyNoiseAdapterFieldSet = ({ config, - updateConfig, + setFieldValue, validationMessage, validationState, }: GreyNoiseAdapterFieldSetProps) => { - const isCreate = useRef(!config.api_token?.is_set); - const [showResetPasswordButton, setShowResetPasswordButton] = useState(config.api_token?.is_set === true); + const [isCreate] = useState(() => !config.api_token?.keep_value); + const [showResetPasswordButton, setShowResetPasswordButton] = useState(!!config.api_token?.keep_value); - const setUserPassword = useCallback( - (nextUserPassword) => { - updateConfig({ ...config, api_token: nextUserPassword }); - }, - [updateConfig, config], - ); - - useEffect(() => { - // Set a default value on `api_token` that the server can deserialize - if (config.api_token?.is_set !== undefined) { - // Keeping value is only helpful when editing, but since setting '' as value throws an error during - // validation, this at least avoids users seeing validation errors constantly. - setUserPassword({ keep_value: true }); - } - }, [setUserPassword, config.api_token]); + const setUserPassword = (nextUserPassword: EncryptedFieldValue<string>) => { + setFieldValue('config.api_token', nextUserPassword); + }; const handleUserPasswordChange = ({ target }) => { const typedPassword = target.value; @@ -89,7 +78,7 @@ const GreyNoiseAdapterFieldSet = ({ name="api_token" label="API Token" buttonAfter={ - !isCreate.current ? ( + !isCreate ? ( <Button type="button" onClick={toggleUserPasswordReset}> Undo Reset </Button> diff --git a/graylog2-web-interface/src/integrations/dataadapters/prepareGreyNoiseConfig.ts b/graylog2-web-interface/src/integrations/dataadapters/prepareGreyNoiseConfig.ts new file mode 100644 index 000000000000..eac9880c22f1 --- /dev/null +++ b/graylog2-web-interface/src/integrations/dataadapters/prepareGreyNoiseConfig.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * <http://www.mongodb.com/licensing/server-side-public-license>. + */ +export default function prepareGreyNoiseConfig(config: Record<string, unknown>): Record<string, unknown> { + const apiToken = config.api_token as { is_set?: boolean } | undefined; + + if (apiToken && 'is_set' in apiToken) { + return { + ...config, + api_token: apiToken.is_set ? { keep_value: true } : { set_value: '' }, + }; + } + + return config; +}