diff --git a/CHANGELOG.md b/CHANGELOG.md index 059ef1212..2d8b04471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# UTMStack 10.9.3 Release Notes +# UTMStack 10.9.5 Release Notes -- Enriched the TIMEZONES constant to include additional IANA zones for broader coverage. -- Support for additional syslog framing methods (RFC 5424 octet counting). +– Visual adjustments applied to the SOC AI Integration to ensure consistent behavior and user interaction. +– Updated the header component to improve version visibility and overall UI consistency. diff --git a/backend/pom.xml b/backend/pom.xml index c491a3e65..68cdcd6e5 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -273,7 +273,7 @@ org.springdoc springdoc-openapi-ui - 1.6.7 + 1.6.15 com.utmstack @@ -351,6 +351,11 @@ tika-core 2.9.1 + + commons-net + commons-net + 3.9.0 + diff --git a/backend/src/main/java/com/park/utmstack/config/Constants.java b/backend/src/main/java/com/park/utmstack/config/Constants.java index 08cad8e12..07b0bcf66 100644 --- a/backend/src/main/java/com/park/utmstack/config/Constants.java +++ b/backend/src/main/java/com/park/utmstack/config/Constants.java @@ -2,7 +2,9 @@ import com.park.utmstack.domain.index_pattern.enums.SystemIndexPattern; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; public final class Constants { @@ -128,6 +130,7 @@ public final class Constants { // Defines the index pattern for querying Elasticsearch statistics indexes. // ---------------------------------------------------------------------------------- public static final String STATISTICS_INDEX_PATTERN = "v11-statistics-*"; + public static final String API_ACCESS_LOGS = ".utmstack-api-logs"; // Logging public static final String TRACE_ID_KEY = "traceId"; @@ -140,6 +143,9 @@ public final class Constants { public static final String CAUSE_KEY = "cause"; public static final String LAYER_KEY = "layer"; + public static final String API_KEY_HEADER = "Utm-Api-Key"; + public static final List API_ENDPOINT_IGNORE = Collections.emptyList(); + private Constants() { } diff --git a/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java b/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java index 46a30dd02..e3030bf09 100644 --- a/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java +++ b/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java @@ -24,10 +24,13 @@ public OpenApiConfiguration(InfoEndpoint infoEndpoint) { public OpenAPI customOpenAPI() { final String securitySchemeBearer = "bearerAuth"; final String securitySchemeApiKey = "ApiKeyAuth"; + final String apiTitle = "UTMStack API"; String version = MapUtil.flattenToStringMap(infoEndpoint.info(), true).get("build.version"); return new OpenAPI() - .addSecurityItem(new SecurityRequirement().addList(securitySchemeBearer).addList(securitySchemeApiKey)) + .addSecurityItem(new SecurityRequirement() + .addList(securitySchemeBearer) + .addList(securitySchemeApiKey)) .components(new Components() .addSecuritySchemes(securitySchemeBearer, new SecurityScheme() @@ -36,7 +39,7 @@ public OpenAPI customOpenAPI() { .scheme("bearer") .bearerFormat("JWT")) .addSecuritySchemes(securitySchemeApiKey, new SecurityScheme() - .name("Utm-Internal-Key") + .name(Constants.API_KEY_HEADER) .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.HEADER))) .info(new Info().title(apiTitle).version(version)) diff --git a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java index 1aa8c037c..bf936fa57 100644 --- a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java +++ b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java @@ -1,10 +1,13 @@ package com.park.utmstack.config; import com.park.utmstack.security.AuthoritiesConstants; +import com.park.utmstack.security.api_key.ApiKeyConfigurer; +import com.park.utmstack.security.api_key.ApiKeyFilter; import com.park.utmstack.security.internalApiKey.InternalApiKeyConfigurer; import com.park.utmstack.security.internalApiKey.InternalApiKeyProvider; import com.park.utmstack.security.jwt.JWTConfigurer; import com.park.utmstack.security.jwt.TokenProvider; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.BeanInitializationException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,6 +32,7 @@ import javax.servlet.http.HttpServletResponse; @Configuration +@RequiredArgsConstructor @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) @Import(SecurityProblemSupport.class) @@ -39,17 +43,7 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private final TokenProvider tokenProvider; private final CorsFilter corsFilter; private final InternalApiKeyProvider internalApiKeyProvider; - - public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerBuilder, - UserDetailsService userDetailsService, - TokenProvider tokenProvider, - CorsFilter corsFilter, InternalApiKeyProvider internalApiKeyProvider) { - this.authenticationManagerBuilder = authenticationManagerBuilder; - this.userDetailsService = userDetailsService; - this.tokenProvider = tokenProvider; - this.corsFilter = corsFilter; - this.internalApiKeyProvider = internalApiKeyProvider; - } + private final ApiKeyFilter apiKeyFilter; @PostConstruct public void init() { @@ -122,7 +116,10 @@ public void configure(HttpSecurity http) throws Exception { .and() .apply(securityConfigurerAdapterForJwt()) .and() - .apply(securityConfigurerAdapterForInternalApiKey()); + .apply(securityConfigurerAdapterForInternalApiKey()) + .and() + .apply(securityConfigurerAdapterForApiKey()) ; + } @@ -133,4 +130,9 @@ private JWTConfigurer securityConfigurerAdapterForJwt() { private InternalApiKeyConfigurer securityConfigurerAdapterForInternalApiKey() { return new InternalApiKeyConfigurer(internalApiKeyProvider); } + + private ApiKeyConfigurer securityConfigurerAdapterForApiKey() { + return new ApiKeyConfigurer(apiKeyFilter); + } + } diff --git a/backend/src/main/java/com/park/utmstack/domain/User.java b/backend/src/main/java/com/park/utmstack/domain/User.java index a9432af28..46e3a6c93 100644 --- a/backend/src/main/java/com/park/utmstack/domain/User.java +++ b/backend/src/main/java/com/park/utmstack/domain/User.java @@ -90,7 +90,7 @@ public class User extends AbstractAuditingEntity implements Serializable { private Boolean defaultPassword; @JsonIgnore - @ManyToMany + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "jhi_user_authority", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")}, inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "name")}) @BatchSize(size = 20) diff --git a/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java new file mode 100644 index 000000000..4986a7b58 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java @@ -0,0 +1,44 @@ +package com.park.utmstack.domain.api_keys; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "api_keys") +public class ApiKey implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String apiKey; + + @Column + private String allowedIp; + + @Column(nullable = false) + private Instant createdAt; + + private Instant generatedAt; + + @Column + private Instant expiresAt; +} diff --git a/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java new file mode 100644 index 000000000..54e0c8d15 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java @@ -0,0 +1,49 @@ +package com.park.utmstack.domain.api_keys; + +import com.park.utmstack.service.dto.auditable.AuditableDTO; +import lombok.*; + +import java.util.HashMap; +import java.util.Map; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyUsageLog implements AuditableDTO { + + private String id; + private String apiKeyId; + private String apiKeyName; + private String userId; + private String timestamp; + private String endpoint; + private String address; + private String errorMessage; + private String queryParams; + private String payload; + private String userAgent; + private String httpMethod; + private String statusCode; + + @Override + public Map toAuditMap() { + Map map = new HashMap<>(); + + map.put("id", id); + map.put("api_key_id", apiKeyId); + map.put("api_key_name", apiKeyName); + map.put("user_id", userId); + map.put("timestamp", timestamp != null ? timestamp : null); + map.put("endpoint", endpoint); + map.put("address", address); + map.put("error_message", errorMessage); + map.put("query_params", queryParams); + map.put("user_agent", userAgent); + map.put("http_method", httpMethod); + map.put("status_code", statusCode); + + return map; + } +} diff --git a/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java b/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java index 9df92c191..eca45ec19 100644 --- a/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java +++ b/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java @@ -42,5 +42,9 @@ public enum ApplicationEventType { ERROR, WARNING, INFO, - MODULE_ACTIVATION_ATTEMPT, MODULE_ACTIVATION_SUCCESS, UNDEFINED + MODULE_ACTIVATION_ATTEMPT, + MODULE_ACTIVATION_SUCCESS, + API_KEY_ACCESS_SUCCESS, + API_KEY_ACCESS_FAILURE, + UNDEFINED } diff --git a/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java b/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java new file mode 100644 index 000000000..444f22576 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java @@ -0,0 +1,139 @@ +package com.park.utmstack.loggin.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.domain.api_keys.ApiKeyUsageLog; +import com.park.utmstack.domain.application_events.enums.ApplicationEventType; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.application_events.ApplicationEventService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +import static org.postgresql.PGProperty.APPLICATION_NAME; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ApiKeyUsageLoggingService { + + private final ApiKeyService apiKeyService; + private final ApplicationEventService applicationEventService; + private static final String LOG_USAGE_FLAG = "LOG_USAGE_DONE"; + + public void logUsage(ContentCachingRequestWrapper request, + ContentCachingResponseWrapper response, + ApiKey apiKey, + String ipAddress, + String message) { + + if (Boolean.TRUE.equals(request.getAttribute(LOG_USAGE_FLAG))) { + return; + } + + try { + String payload = extractPayload(request); + String errorText = extractErrorText(response); + int status = safeStatus(response); + + ApiKeyUsageLog usage = buildUsageLog(apiKey, ipAddress, request, status, errorText, payload); + + apiKeyService.logUsage(usage); + + ApplicationEventType eventType = (status >= 400) + ? ApplicationEventType.API_KEY_ACCESS_FAILURE + : ApplicationEventType.API_KEY_ACCESS_SUCCESS; + + String eventMessage = (status >= 400) + ? "API key access failure" + : "API key access"; + + applicationEventService.createEvent(eventMessage, eventType, usage.toAuditMap()); + + } catch (Exception e) { + log.error("Error while logging API key usage: {}", e.getMessage(), e); + } finally { + request.setAttribute(LOG_USAGE_FLAG, Boolean.TRUE); + } + } + + private int safeStatus(HttpServletResponse response) { + try { + return response.getStatus(); + } catch (Exception e) { + return 0; + } + } + + private String extractPayload(ContentCachingRequestWrapper request) { + try { + if (!"GET".equalsIgnoreCase(request.getMethod()) && !"DELETE".equalsIgnoreCase(request.getMethod())) { + byte[] content = request.getContentAsByteArray(); + return content.length > 0 ? new String(content, StandardCharsets.UTF_8) : null; + } + } catch (Exception ex) { + log.error("Error extracting payload: {}", ex.getMessage()); + } + return null; + } + + private String extractErrorText(ContentCachingResponseWrapper response) { + int statusCode = response.getStatus(); + if (statusCode >= 400) { + byte[] content = response.getContentAsByteArray(); + String responseError = content.length > 0 ? new String(content, StandardCharsets.UTF_8) : null; + String errorHeader = response.getHeader("X-" + APPLICATION_NAME + "-error"); + return StringUtils.hasText(responseError) ? responseError : errorHeader; + } + return null; + } + + private ApiKeyUsageLog buildUsageLog(ApiKey apiKey, + String ipAddress, + HttpServletRequest request, + int status, + String errorText, + String payload) { + + String id = UUID.randomUUID().toString(); + String apiKeyId = apiKey != null && apiKey.getId() != null ? apiKey.getId().toString() : null; + String apiKeyName = apiKey != null ? apiKey.getName() : null; + String userId = apiKey != null && apiKey.getUserId() != null ? apiKey.getUserId().toString() : null; + String timestamp = Instant.now().toString(); + String endpoint = request != null ? request.getRequestURI() : null; + String queryParams = request != null ? request.getQueryString() : null; + String userAgent = request != null ? request.getHeader("User-Agent") : null; + String httpMethod = request != null ? request.getMethod() : null; + String statusCode = String.valueOf(status); + + String safePayload = null; + if (payload != null) { + int PAYLOAD_MAX_LENGTH = 2000; + safePayload = payload.length() > PAYLOAD_MAX_LENGTH ? payload.substring(0, PAYLOAD_MAX_LENGTH) : payload; + } + + return ApiKeyUsageLog.builder() + .id(id) + .apiKeyId(apiKeyId) + .apiKeyName(apiKeyName) + .userId(userId) + .timestamp(timestamp) + .endpoint(endpoint) + .address(ipAddress) + .errorMessage(errorText) + .queryParams(queryParams) + .payload(safePayload) + .userAgent(userAgent) + .httpMethod(httpMethod) + .statusCode(statusCode) + .build(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java b/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java new file mode 100644 index 000000000..ece32eba9 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java @@ -0,0 +1,29 @@ +package com.park.utmstack.repository.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import javax.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + + Optional findByIdAndUserId(Long id, Long userId); + + Page findByUserId(Long userId, Pageable pageable); + + @Cacheable(cacheNames = "apikey", key = "#root.args[0]") + Optional findOneByApiKey(@NotNull String apiKey); + + Optional findByNameAndUserId(@NotNull String name, Long userId); + + List findAllByExpiresAtAfterAndExpiresAtLessThanEqual(Instant now, Instant fiveDaysFromNow); +} diff --git a/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java new file mode 100644 index 000000000..871260236 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java @@ -0,0 +1,20 @@ +package com.park.utmstack.security.api_key; + +import com.park.utmstack.security.jwt.JWTFilter; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; + +public class ApiKeyConfigurer extends SecurityConfigurerAdapter { + + private final ApiKeyFilter apiKeyFilter; + + public ApiKeyConfigurer(ApiKeyFilter apiKeyFilter) { + this.apiKeyFilter = apiKeyFilter; + } + + @Override + public void configure(HttpSecurity http) throws Exception { + http.addFilterAfter(apiKeyFilter, JWTFilter.class); + } +} diff --git a/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java new file mode 100644 index 000000000..cb3624567 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java @@ -0,0 +1,186 @@ +package com.park.utmstack.security.api_key; + + +import com.park.utmstack.config.Constants; +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.loggin.api_key.ApiKeyUsageLoggingService; +import com.park.utmstack.repository.UserRepository; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.application_events.ApplicationEventService; +import com.park.utmstack.util.exceptions.ApiKeyInvalidAccessException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.net.util.SubnetUtils; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static com.park.utmstack.config.Constants.API_ENDPOINT_IGNORE; + +@Slf4j +@Component +@AllArgsConstructor +public class ApiKeyFilter extends OncePerRequestFilter { + + private static final String LOG_USAGE_FLAG = "LOG_USAGE_DONE"; + private static final Pattern CIDR_PATTERN = Pattern.compile( + "^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)/(\\d|[1-2]\\d|3[0-2])$" + ); + + + private final UserRepository userRepository; + private final ApiKeyService apiKeyService; + private final ConcurrentMap invalidApiKeyBlackList = new ConcurrentHashMap<>(); + private final ConcurrentMap cidrCache = new ConcurrentHashMap<>(); + private final ApiKeyUsageLoggingService apiKeyUsageLoggingService; + + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + if (API_ENDPOINT_IGNORE.contains(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + if (request.getAttribute(LOG_USAGE_FLAG) != null) { + filterChain.doFilter(request, response); + return; + } + + String apiKey = request.getHeader(Constants.API_KEY_HEADER); + + if (!StringUtils.hasText(apiKey)) { + filterChain.doFilter(request, response); + return; + } + + String ipAddress = request.getRemoteAddr(); + var key = getApiKey(apiKey); + + var wrappedRequest = new ContentCachingRequestWrapper(request); + var wrappedResponse = new ContentCachingResponseWrapper(response); + + UsernamePasswordAuthenticationToken authentication; + + try { + authentication = getAuthentication(key, ipAddress); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(wrappedRequest)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (ApiKeyInvalidAccessException e) { + apiKeyUsageLoggingService.logUsage(wrappedRequest, wrappedResponse, key, ipAddress, e.getMessage()); + throw e; + } + + filterChain.doFilter(wrappedRequest, wrappedResponse); + wrappedResponse.copyBodyToResponse(); + + apiKeyUsageLoggingService.logUsage(wrappedRequest, wrappedResponse, key, ipAddress, null); + } + + private ApiKey getApiKey(String apiKey) { + if (invalidApiKeyBlackList.containsKey(apiKey)) { + log.warn("Access attempt with invalid API key (cached)"); + throw new ApiKeyInvalidAccessException("Invalid API key"); + } + + return apiKeyService.findOneByApiKey(apiKey) + .orElseGet(() -> { + invalidApiKeyBlackList.put(apiKey, Boolean.TRUE); + log.warn("Access attempt with invalid API key (not found in DB)"); + throw new ApiKeyInvalidAccessException("Invalid API key"); + }); + } + + public UsernamePasswordAuthenticationToken getAuthentication(ApiKey apiKey, String remoteIpAddress) { + Objects.requireNonNull(apiKey, "API key must not be null"); + Objects.requireNonNull(remoteIpAddress, "Remote IP address must not be null"); + + if (!allowAccessToRemoteIp(apiKey.getAllowedIp(), remoteIpAddress)) { + log.warn("Access denied: IP [{}] not allowed for API key [{}]", remoteIpAddress, apiKey.getApiKey()); + throw new ApiKeyInvalidAccessException( + "Invalid IP address: " + remoteIpAddress + ". If you recognize this IP, add it to allowed IP list." + ); + } + + if (apiKey.getExpiresAt() != null && !apiKey.getExpiresAt().isAfter(Instant.now())) { + log.warn("Access denied: API key [{}] expired at {}", apiKey.getApiKey(), apiKey.getExpiresAt()); + throw new ApiKeyInvalidAccessException("API key expired at " + apiKey.getExpiresAt()); + } + + var userEntityOpt = userRepository.findById(apiKey.getUserId()); + if (userEntityOpt.isEmpty()) { + log.warn("Access denied: User [{}] not found for API key [{}]", apiKey.getUserId(), apiKey.getApiKey()); + throw new ApiKeyInvalidAccessException("User not found for API key"); + } + + var userEntity = userEntityOpt.get(); + + if (!userEntity.getActivated()) { + log.warn("Access denied: User [{}] not activated", userEntity.getLogin()); + throw new ApiKeyInvalidAccessException("User not activated"); + } + + List authorities = userEntity.getAuthorities().stream() + .map(auth -> new SimpleGrantedAuthority(auth.getName())) + .collect(Collectors.toList()); + + User principal = new User(userEntity.getLogin(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, apiKey.getApiKey(), authorities); + } + + public boolean allowAccessToRemoteIp(String allowedIpList, String remoteIp) { + if (allowedIpList == null || allowedIpList.trim().isEmpty()) { + return true; + } + String[] whitelistIps = allowedIpList.split(","); + for (String ip : whitelistIps) { + String allowed = ip.trim(); + if (allowed.isEmpty()) { + continue; + } + if (CIDR_PATTERN.matcher(allowed).matches()) { + try { + SubnetUtils subnetUtils = cidrCache.computeIfAbsent(allowed, key -> { + SubnetUtils su = new SubnetUtils(key); + su.setInclusiveHostCount(true); + return su; + }); + if (subnetUtils.getInfo().isInRange(remoteIp)) { + return true; + } + } catch (IllegalArgumentException e) { + log.error("Invalid CIDR notation: {}", allowed); + } + } else if (allowed.equals(remoteIp)) { + return true; + } + } + return false; + } +} diff --git a/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java b/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java new file mode 100644 index 000000000..8a73a748d --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java @@ -0,0 +1,171 @@ +package com.park.utmstack.service.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.domain.api_keys.ApiKeyUsageLog; +import com.park.utmstack.repository.api_key.ApiKeyRepository; +import com.park.utmstack.service.UserService; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import com.park.utmstack.service.dto.api_key.ApiKeyUpsertDTO; +import com.park.utmstack.service.elasticsearch.OpensearchClientBuilder; +import com.park.utmstack.service.mapper.ApiKeyMapper; +import com.park.utmstack.util.exceptions.ApiKeyExistException; +import com.park.utmstack.util.exceptions.ApiKeyNotFoundException; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import static com.park.utmstack.config.Constants.API_ACCESS_LOGS; + +@Service +@AllArgsConstructor +public class ApiKeyService { + + private static final String CLASSNAME = "ApiKeyService"; + private final Logger log = LoggerFactory.getLogger(ApiKeyService.class); + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyMapper apiKeyMapper; + private final OpensearchClientBuilder client; + + + public ApiKeyResponseDTO createApiKey(Long userId,ApiKeyUpsertDTO dto) { + final String ctx = CLASSNAME + ".createApiKey"; + try { + apiKeyRepository.findByNameAndUserId(dto.getName(), userId) + .ifPresent(apiKey -> { + throw new ApiKeyExistException("Api key already exists"); + }); + + var apiKey = ApiKey.builder() + .userId(userId) + .name(dto.getName()) + .expiresAt(dto.getExpiresAt()) + .allowedIp(String.join(",", dto.getAllowedIp())) + .createdAt(Instant.now()) + .generatedAt(Instant.now()) + .apiKey(generateRandomKey()) + .build(); + + return apiKeyMapper.toDto(apiKeyRepository.save(apiKey)); + } catch (Exception e) { + throw new ApiKeyExistException(ctx + ": " + e.getMessage()); + } + } + + public String generateApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".generateApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + + Instant now = Instant.now(); + Instant originalCreated = apiKey.getGeneratedAt() != null ? apiKey.getGeneratedAt() : apiKey.getCreatedAt(); + Instant originalExpires = apiKey.getExpiresAt(); + + Duration duration; + if (originalCreated != null && originalExpires != null && !originalExpires.isBefore(originalCreated)) { + duration = Duration.between(originalCreated, originalExpires); + } else { + duration = Duration.ofDays(7); + } + + String plainKey = generateRandomKey(); + apiKey.setApiKey(plainKey); + apiKey.setGeneratedAt(Instant.now()); + apiKey.setExpiresAt(now.plus(duration)); + apiKeyRepository.save(apiKey); + return plainKey; + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public ApiKeyResponseDTO updateApiKey(Long userId, Long apiKeyId, ApiKeyUpsertDTO dto) { + final String ctx = CLASSNAME + ".updateApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + apiKey.setName(dto.getName()); + if (dto.getAllowedIp() != null) { + apiKey.setAllowedIp(String.join(",", dto.getAllowedIp())); + } else { + apiKey.setAllowedIp(null); + } + apiKey.setExpiresAt(dto.getExpiresAt()); + ApiKey updated = apiKeyRepository.save(apiKey); + return apiKeyMapper.toDto(updated); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public ApiKeyResponseDTO getApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".getApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + return apiKeyMapper.toDto(apiKey); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public Page listApiKeys(Long userId, Pageable pageable) { + final String ctx = CLASSNAME + ".listApiKeys"; + try { + return apiKeyRepository.findByUserId(userId, pageable).map(apiKeyMapper::toDto); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + + public void deleteApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".deleteApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + apiKeyRepository.delete(apiKey); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + private String generateRandomKey() { + final String ctx = CLASSNAME + ".generateRandomKey"; + try { + SecureRandom random = new SecureRandom(); + byte[] keyBytes = new byte[32]; + random.nextBytes(keyBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + @Async + public void logUsage(ApiKeyUsageLog apiKeyUsageLog) { + final String ctx = CLASSNAME + ".logUsage"; + try { + client.getClient().index(API_ACCESS_LOGS, apiKeyUsageLog); + } catch (Exception e) { + log.error(ctx + ": {}", e.getMessage()); + } + } + + public Optional findOneByApiKey(String apiKey) { + return apiKeyRepository.findOneByApiKey(apiKey); + } + +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java new file mode 100644 index 000000000..abfa4d02d --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java @@ -0,0 +1,36 @@ +package com.park.utmstack.service.dto.api_key; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyResponseDTO { + + @Schema(description = "Unique identifier of the API key") + private Long id; + + @Schema(description = "User-friendly API key name") + private String name; + + @Schema(description = "Allowed IP address or IP range in CIDR notation (e.g., '192.168.1.100' or '192.168.1.0/24')") + private List allowedIp; + + @Schema(description = "API key creation timestamp") + private Instant createdAt; + + @Schema(description = "API key expiration timestamp (if applicable)") + private Instant expiresAt; + + @Schema(description = "Generated At") + private Instant generatedAt; +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java new file mode 100644 index 000000000..6345f2032 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java @@ -0,0 +1,28 @@ +package com.park.utmstack.service.dto.api_key; + +import com.park.utmstack.validation.api_key.ValidIPOrCIDR; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyUpsertDTO { + @NotNull + @Schema(description = "API Key name", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "Allowed IP address or IP range in CIDR notation (e.g., '192.168.1.100' or '192.168.1.0/24'). If null, no IP restrictions are applied.") + private List<@ValidIPOrCIDR String> allowedIp; + + @Schema(description = "Expiration timestamp of the API key") + private Instant expiresAt; +} diff --git a/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java b/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java new file mode 100644 index 000000000..0439c563b --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java @@ -0,0 +1,31 @@ +package com.park.utmstack.service.mapper; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import org.mapstruct.Mapper; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public class ApiKeyMapper { + + public ApiKeyResponseDTO toDto(ApiKey apiKey){ + return ApiKeyResponseDTO.builder() + .id(apiKey.getId()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .expiresAt(apiKey.getExpiresAt()) + .allowedIp( + Optional.ofNullable(apiKey.getAllowedIp()) + .map(s -> Arrays.stream(s.split(",")) + .map(String::trim) + .filter(str -> !str.isEmpty()) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()) + ) + .build(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java new file mode 100644 index 000000000..20577f508 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class ApiKeyExistException extends RuntimeException { + public ApiKeyExistException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java new file mode 100644 index 000000000..c5a13ead4 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java @@ -0,0 +1,9 @@ +package com.park.utmstack.util.exceptions; + +import org.springframework.security.core.AuthenticationException; + +public class ApiKeyInvalidAccessException extends AuthenticationException { + public ApiKeyInvalidAccessException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java new file mode 100644 index 000000000..173d8f442 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class ApiKeyNotFoundException extends RuntimeException { + public ApiKeyNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java new file mode 100644 index 000000000..55dfe9593 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java @@ -0,0 +1,23 @@ +package com.park.utmstack.validation.api_key; + + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = ValidIPOrCIDRValidator.class) +@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE}) +@Retention(RUNTIME) +public @interface ValidIPOrCIDR { + String message() default "Invalid IP address or CIDR notation"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java new file mode 100644 index 000000000..8324094f8 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java @@ -0,0 +1,37 @@ +package com.park.utmstack.validation.api_key; + + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class ValidIPOrCIDRValidator implements ConstraintValidator { + + private static final Pattern IPV4_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)$" + ); + + private static final Pattern IPV4_CIDR_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)/(\\d|[1-2]\\d|3[0-2])$" + ); + private static final Pattern IPV6_PATTERN = Pattern.compile( + "^(?:[\\da-fA-F]{1,4}:){7}[\\da-fA-F]{1,4}$" + ); + + private static final Pattern IPV6_CIDR_PATTERN = Pattern.compile( + "^(?:[\\da-fA-F]{1,4}:){7}[\\da-fA-F]{1,4}/(\\d|[1-9]\\d|1[01]\\d|12[0-8])$" + ); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // Allow null or empty values; use @NotNull/@NotEmpty to enforce non-null if needed. + if (value == null || value.trim().isEmpty()) { + return true; + } + String trimmed = value.trim(); + if (IPV4_PATTERN.matcher(trimmed).matches() || IPV4_CIDR_PATTERN.matcher(trimmed).matches()) { + return true; + } + return IPV6_PATTERN.matcher(trimmed).matches() || IPV6_CIDR_PATTERN.matcher(trimmed).matches(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java b/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java new file mode 100644 index 000000000..aa6b2052a --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java @@ -0,0 +1,184 @@ +package com.park.utmstack.web.rest.api_key; + + +import com.park.utmstack.domain.chart_builder.types.query.FilterType; +import com.park.utmstack.security.AuthoritiesConstants; +import com.park.utmstack.service.UserService; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import com.park.utmstack.service.dto.api_key.ApiKeyUpsertDTO; +import com.park.utmstack.service.elasticsearch.ElasticsearchService; +import com.park.utmstack.util.UtilPagination; +import com.park.utmstack.web.rest.elasticsearch.ElasticsearchResource; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.core.search.HitsMetadata; +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import tech.jhipster.web.util.PaginationUtil; + +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/api-keys") +@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.USER + "\")") +@Slf4j +@AllArgsConstructor +@Hidden +public class ApiKeyResource { + + private final ApiKeyService apiKeyService; + private final ElasticsearchService elasticsearchService; + private final UserService userService; + + @Operation(summary = "Create API key", + description = "Creates a new API key record using the provided settings. The plain text key is not generated at creation.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "API key created successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "409", description = "API key already exists", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PostMapping + public ResponseEntity createApiKey(@RequestBody ApiKeyUpsertDTO dto) { + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.createApiKey(userId, dto); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO); + } + + @Operation(summary = "Generate a new API key", + description = "Generates (or renews) a new random API key for the specified API key record. The plain text key is returned only once.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key generated successfully", + content = @Content(schema = @Schema(type = "string"))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PostMapping("/{id}/generate") + public ResponseEntity generateApiKey(@PathVariable("id") Long apiKeyId) { + Long userId = userService.getCurrentUserLogin().getId(); + String plainKey = apiKeyService.generateApiKey(userId, apiKeyId); + return ResponseEntity.ok(plainKey); + } + + @Operation(summary = "Retrieve API key", + description = "Retrieves the API key details for the specified API key record.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key retrieved successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @GetMapping("/{id}") + public ResponseEntity getApiKey(@PathVariable("id") Long apiKeyId) { + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.getApiKey(userId, apiKeyId); + return ResponseEntity.ok(responseDTO); + } + + @Operation(summary = "List API keys", + description = "Retrieves the API key list.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key retrieved successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @GetMapping + public ResponseEntity> listApiKeys(@ParameterObject Pageable pageable) { + Long userId = userService.getCurrentUserLogin().getId(); + Page page = apiKeyService.listApiKeys(userId,pageable); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + + return ResponseEntity.ok().headers(headers).body(page.getContent()); + } + + @Operation(summary = "Update API key", + description = "Updates mutable fields (name, allowed IPs, expiration) for the specified API key record.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key updated successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PutMapping("/{id}") + public ResponseEntity updateApiKey(@PathVariable("id") Long apiKeyId, + @RequestBody ApiKeyUpsertDTO dto) { + + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.updateApiKey(userId, apiKeyId, dto); + return ResponseEntity.ok(responseDTO); + + } + + @Operation(summary = "Delete API key", + description = "Deletes the specified API key record for the authenticated user.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "API key deleted successfully", content = @Content), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @DeleteMapping("/{id}") + public ResponseEntity deleteApiKey(@PathVariable("id") Long apiKeyId) { + + Long userId = userService.getCurrentUserLogin().getId(); + apiKeyService.deleteApiKey(userId, apiKeyId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/usage") + public ResponseEntity> search(@RequestBody(required = false) List filters, + @RequestParam Integer top, @RequestParam String indexPattern, + @RequestParam(required = false, defaultValue = "false") boolean includeChildren, + Pageable pageable) { + + SearchResponse searchResponse = elasticsearchService.search(filters, top, indexPattern, + pageable, Map.class); + + if (Objects.isNull(searchResponse) || Objects.isNull(searchResponse.hits()) || searchResponse.hits().total().value() == 0) + return ResponseEntity.ok(Collections.emptyList()); + + HitsMetadata hits = searchResponse.hits(); + HttpHeaders headers = UtilPagination.generatePaginationHttpHeaders(Math.min(hits.total().value(), top), + pageable.getPageNumber(), pageable.getPageSize(), "/api/elasticsearch/search"); + + return ResponseEntity.ok().headers(headers).body(hits.hits().stream() + .map(Hit::source).collect(Collectors.toList())); + + } +} diff --git a/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml b/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml new file mode 100644 index 000000000..e73010f6a --- /dev/null +++ b/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/config/liquibase/master.xml b/backend/src/main/resources/config/liquibase/master.xml index f6ce831a6..17b6c5c3b 100644 --- a/backend/src/main/resources/config/liquibase/master.xml +++ b/backend/src/main/resources/config/liquibase/master.xml @@ -113,5 +113,7 @@ + + diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.html b/frontend/src/app/app-management/api-keys/api-keys.component.html new file mode 100644 index 000000000..e2a5158ff --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.html @@ -0,0 +1,151 @@ +
+
+
+
+ API Keys +
+ + The API key is a simple encrypted string that identifies you in the application. With this key, you can access the REST API. + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Allowed IPs + + Expires At + + Created At + ACTIONS
{{ key.name }}{{ key.allowedIp?.join(', ') || '—' }} + + + + + + + + + + + + + {{ key.expiresAt ? (key.expiresAt | date:'dd/MM/yy HH:mm':'UTC') : '—' }} + {{ key.createdAt | date:'dd/MM/yy HH:mm' :'UTC' }} + + + +
+ +
+
+ + +
+
+
+ +
+
+ + + + +
+ +
+
+
+ + + + + +
+
+ Copy it now because it will be shown only once! +
+
+ {{ maskSecrets(generatedApiKey) }} + +
+
+ + {{ 'Keep this key safeKeep this key safe' }} +
+
+
+ +
+
diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.scss b/frontend/src/app/app-management/api-keys/api-keys.component.scss new file mode 100644 index 000000000..b3751c83a --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.scss @@ -0,0 +1,7 @@ +:host{ + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.ts b/frontend/src/app/app-management/api-keys/api-keys.component.ts new file mode 100644 index 000000000..82d9e3450 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.ts @@ -0,0 +1,232 @@ +import {Component, OnInit, TemplateRef, ViewChild} from '@angular/core'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import * as moment from 'moment'; +import {UtmToastService} from '../../shared/alert/utm-toast.service'; +import { + ModalConfirmationComponent +} from '../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; +import {ITEMS_PER_PAGE} from '../../shared/constants/pagination.constants'; +import {SortEvent} from '../../shared/directives/sortable/type/sort-event'; +import {ApiKeyModalComponent} from './shared/components/api-key-modal/api-key-modal.component'; +import {ApiKeyResponse} from './shared/models/ApiKeyResponse'; +import {ApiKeysService} from './shared/service/api-keys.service'; + +@Component({ + selector: 'app-api-keys', + templateUrl: './api-keys.component.html', + styleUrls: ['./api-keys.component.scss'] +}) +export class ApiKeysComponent implements OnInit { + + generating: string[] = []; + noData = false; + apiKeys: ApiKeyResponse[] = []; + loading = false; + generatedApiKey = ''; + @ViewChild('generatedModal') generatedModal!: TemplateRef; + generatedModalRef!: NgbModalRef; + copied = false; + readonly itemsPerPage = ITEMS_PER_PAGE; + totalItems = 0; + page = 0; + size = this.itemsPerPage; + + request = { + sort: 'createdAt,desc', + page: this.page, + size: this.size + }; + + constructor( private toastService: UtmToastService, + private apiKeyService: ApiKeysService, + private modalService: NgbModal + ) {} + + ngOnInit(): void { + this.loadKeys(); + } + + loadKeys(): void { + this.loading = true; + this.apiKeyService.list(this.request).subscribe({ + next: (res) => { + this.totalItems = Number(res.headers.get('X-Total-Count')); + this.apiKeys = res.body || []; + this.noData = this.apiKeys.length === 0; + this.loading = false; + }, + error: () => { + this.loading = false; + this.apiKeys = []; + } + }); + } + + copyToClipboard(): void { + if (!this.generatedApiKey) { return; } + + if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) { + (navigator as any).clipboard.writeText(this.generatedApiKey) + .then(() => this.copied = true) + .catch(err => { + console.error('Error al copiar con clipboard API', err); + this.fallbackCopy(this.generatedApiKey); + }); + } else { + this.fallbackCopy(this.generatedApiKey); + } + } + + private fallbackCopy(text: string): void { + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '0'; + textarea.style.opacity = '0'; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (successful) { + this.showCopiedFeedback(); + } else { + console.warn('Fallback copy failed'); + } + } catch (err) { + console.error('Error en fallback copy', err); + } + } + + private showCopiedFeedback(): void { + this.copied = true; + setTimeout(() => this.copied = false, 2000); + } + + openCreateModal(): void { + const modalRef = this.modalService.open(ApiKeyModalComponent, { centered: true }); + + modalRef.result.then((key: ApiKeyResponse) => { + if (key) { + this.generateKey(key); + } + }); + } + + editKey(key: ApiKeyResponse): void { + const modalRef = this.modalService.open(ApiKeyModalComponent, {centered: true}); + modalRef.componentInstance.apiKey = key; + + modalRef.result.then((key: ApiKeyResponse) => { + if (key) { + this.generateKey(key); + } + }); + } + + deleteKey(apiKey: ApiKeyResponse): void { + const modalRef = this.modalService.open(ModalConfirmationComponent, {centered: true}); + modalRef.componentInstance.header = `Delete API Key: ${apiKey.name}`; + modalRef.componentInstance.message = 'Are you sure you want to delete this API key?'; + modalRef.componentInstance.confirmBtnType = 'delete'; + modalRef.componentInstance.type = 'danger'; + modalRef.componentInstance.confirmBtnText = 'Delete'; + modalRef.componentInstance.confirmBtnIcon = 'icon-cross-circle'; + + modalRef.result.then(reason => { + if (reason === 'ok') { + this.delete(apiKey); + } + }); + } + + delete(apiKey: ApiKeyResponse): void { + this.apiKeyService.delete(apiKey.id).subscribe({ + next: () => { + this.toastService.showSuccess('API key deleted successfully.'); + this.loadKeys(); + }, + error: (err) => { + this.toastService.showError('Error', 'An error occurred while deleting the API key.'); + throw err; + } + }); + } + + getDaysUntilExpire(expiresAt: string): number { + if (!expiresAt) { + return -1; + } + + const today = moment().startOf('day'); + const expireDate = moment(expiresAt).startOf('day'); + return expireDate.diff(today, 'days'); + } + + onSortBy($event: SortEvent) { + this.request.sort = $event.column + ',' + $event.direction; + this.loadKeys(); + } + + maskSecrets(str: string): string { + if (!str || str.length <= 10) { + return str; + } + const prefix = str.substring(0, 10); + const maskLength = str.length - 30; + const maskedPart = '*'.repeat(maskLength); + return prefix + maskedPart; + } + + generateKey(apiKey: ApiKeyResponse): void { + this.generating.push(apiKey.id); + this.apiKeyService.generateApiKey(apiKey.id).subscribe(response => { + this.generatedApiKey = response.body ? response.body : ""; + this.generatedModalRef = this.modalService.open(this.generatedModal, {centered: true}); + const index = this.generating.indexOf(apiKey.id); + if (index > -1) { + this.generating.splice(index, 1); + } + this.loadKeys(); + }); + } + + isApiKeyExpired(expiresAt?: string | null ): boolean { + if (!expiresAt) { + return false; + } + const expirationTime = new Date(expiresAt).getTime(); + return expirationTime < Date.now(); + } + + close() { + this.generatedModalRef.close(); + this.copied = false; + this.generatedApiKey = ''; + } + + loadPage($event: number) { + this.page = $event - 1; + this.request = { + ...this.request, + page: this.page + }; + this.loadKeys(); + } + + onItemsPerPageChange($event: number) { + this.request = { + ...this.request, + size: $event, + page: 0 + }; + this.page = 0; + this.loadKeys(); + } +} diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html new file mode 100644 index 000000000..7e1399bbb --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html @@ -0,0 +1,127 @@ + + +
+ +
+ {{ errorMsg }} +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ +
+ {{ ipInputError }} +
+ +
    +
  • + +
    +
    + + {{ ip.value }} + {{ getIpType(ip.value) }} +
    +
    + +
    +
    +
  • +
+
+ + +
+ +
+ + + +
+
+ + + diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss new file mode 100644 index 000000000..e31427b61 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss @@ -0,0 +1,12 @@ +.disabled-rounded-start { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} +.disabled-rounded-end { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.mt-4 { + margin-top: 9rem !important; +} diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts new file mode 100644 index 000000000..8e83c56b6 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts @@ -0,0 +1,139 @@ +import {HttpErrorResponse} from '@angular/common/http'; +import {Component, Input, OnInit} from '@angular/core'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import {UtmToastService} from '../../../../../shared/alert/utm-toast.service'; +import {ApiKeyResponse} from '../../models/ApiKeyResponse'; +import { ApiKeysService } from '../../service/api-keys.service'; +import {IpFormsValidators} from "../../../../../shared/util/custom-form-validators"; + +@Component({ + selector: 'app-api-key-modal', + templateUrl: './api-key-modal.component.html', + styleUrls: ['./api-key-modal.component.scss'] +}) +export class ApiKeyModalComponent implements OnInit { + + @Input() apiKey: ApiKeyResponse = null; + + apiKeyForm: FormGroup; + ipInput = ''; + loading = false; + errorMsg = ''; + isSaving: string | string[] | Set | { [p: string]: any }; + minDate = { year: new Date().getFullYear(), month: new Date().getMonth() + 1, day: new Date().getDate() }; + ipInputError = ''; + + constructor( public activeModal: NgbActiveModal, + private apiKeyService: ApiKeysService, + private fb: FormBuilder, + private toastService: UtmToastService) { + } + + ngOnInit(): void { + + const expiresAtDate = this.apiKey && this.apiKey.expiresAt ? new Date(this.apiKey.expiresAt) : null; + const expiresAtNgbDate = expiresAtDate ? { + year: expiresAtDate.getUTCFullYear(), + month: expiresAtDate.getUTCMonth() + 1, + day: expiresAtDate.getUTCDate() + } : null; + + this.apiKeyForm = this.fb.group({ + name: [ this.apiKey ? this.apiKey.name : '', Validators.required], + allowedIp: this.fb.array(this.apiKey ? this.apiKey.allowedIp : []), + expiresAt: [expiresAtNgbDate, Validators.required], + }); + } + + get allowedIp(): FormArray { + return this.apiKeyForm.get('allowedIp') as FormArray; + } + + addIp(): void { + const trimmedIp = this.ipInput.trim(); + + if (!trimmedIp) { + this.ipInputError = 'Please enter an IP address or CIDR'; // Error is assigned + return; + } + + const tempControl = this.fb.control(trimmedIp, [IpFormsValidators.ipOrCidr()]); + + if (tempControl.invalid) { + if (tempControl.hasError('invalidIp')) { + this.ipInputError = 'Invalid IP address format'; + } else if (tempControl.hasError('invalidCidr')) { + this.ipInputError = 'Invalid CIDR format'; + } + return; + } + + const isDuplicate = this.allowedIp.controls.some( + control => control.value === trimmedIp + ); + + if (isDuplicate) { + this.ipInputError = 'This IP is already added'; + return; + } + + this.allowedIp.push(this.fb.control(trimmedIp, [IpFormsValidators.ipOrCidr()])); + this.ipInput = ''; + this.ipInputError = ''; + } + + removeIp(index: number): void { + this.allowedIp.removeAt(index); + } + + create(): void { + this.errorMsg = ''; + + if (this.apiKeyForm.invalid) { + this.errorMsg = 'Name is required.'; + return; + } + + this.loading = true; + + const rawDate = this.apiKeyForm.get('expiresAt').value; + let formattedDate = rawDate; + + if (rawDate && typeof rawDate === 'object') { + formattedDate = `${rawDate.year}-${String(rawDate.month).padStart(2, '0')}-${String(rawDate.day).padStart(2, '0')}T00:00:00.000Z`; + } + + const payload = { + ...this.apiKeyForm.value, + expiresAt: formattedDate, + }; + + const save = this.apiKey ? this.apiKeyService.update(this.apiKey.id, payload) : + this.apiKeyService.create(payload); + + save.subscribe({ + next: (response) => { + this.loading = false; + this.activeModal.close(response.body as ApiKeyResponse); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + if (err.status === 409) { + this.toastService.showError('Error', 'An API key with this name already exists.'); + } else if (err.status === 500) { + this.toastService.showError('Error', 'Server error occurred while creating the API key.'); + } + } + }); + } + + getIpType(value: string): string { + if (!value) { return ''; } + if (value.includes('/')) { + return value.includes(':') ? 'IPv6 CIDR' : 'IPv4 CIDR'; + } + return value.includes(':') ? 'IPv6' : 'IPv4'; + } +} + diff --git a/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts new file mode 100644 index 000000000..3f6b890d7 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts @@ -0,0 +1,8 @@ +export interface ApiKeyResponse { + id: string; + name: string; + allowedIp: string[]; + createdAt: string; + expiresAt?: string; + generatedAt?: string; +} diff --git a/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts new file mode 100644 index 000000000..7ae630a43 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts @@ -0,0 +1,6 @@ +export interface ApiKeyUpsert { + id: string; + name: string; + allowedIp?: string[]; + expiresAt?: Date; +} diff --git a/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts b/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts new file mode 100644 index 000000000..e239d5e4d --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { SERVER_API_URL } from '../../../../app.constants'; +import { ApiKeyResponse } from '../models/ApiKeyResponse'; +import { ApiKeyUpsert } from '../models/ApiKeyUpsert'; +import {createRequestOption} from "../../../../shared/util/request-util"; + +/** + * Service for managing API keys + */ +@Injectable({ + providedIn: 'root' +}) +export class ApiKeysService { + public resourceUrl = SERVER_API_URL + 'api/api-keys'; + + constructor(private http: HttpClient) {} + + /** + * Create a new API key + */ + create(dto: ApiKeyUpsert): Observable> { + return this.http.post( + this.resourceUrl, + dto, + { observe: 'response' } + ); + } + + /** + * Generate (or renew) a plain API key for the given id + * Returns the plain text key (only once) + */ + generate(id: string): Observable> { + return this.http.post( + `${this.resourceUrl}/${id}/generate`, + {}, + { observe: 'response', responseType: 'text' } + ); + } + + /** + * Get API key by id + */ + get(id: string): Observable> { + return this.http.get( + `${this.resourceUrl}/${id}`, + { observe: 'response' } + ); + } + + /** + * List all API keys (with optional pagination) + */ + list(params?: any): Observable> { + const httpParams = createRequestOption(params); + return this.http.get( + this.resourceUrl, + { observe: 'response', params: httpParams }, + ); + } + + /** + * Update an existing API key + */ + update(id: string, dto: ApiKeyUpsert): Observable> { + return this.http.put( + `${this.resourceUrl}/${id}`, + dto, + { observe: 'response' } + ); + } + + /** + * Delete API key + */ + delete(id: string): Observable> { + return this.http.delete( + `${this.resourceUrl}/${id}`, + { observe: 'response' } + ); + } + + generateApiKey(apiKeyId: string): Observable> { + return this.http.post(`${this.resourceUrl}/${apiKeyId}/generate`, null, { + observe: 'response', + responseType: 'text' + }); + } + + /** + * Search API key usage in Elasticsearch + */ + usage(params: { + filters?: any[]; + top: number; + indexPattern: string; + includeChildren?: boolean; + page?: number; + size?: number; + }): Observable { + return this.http.get( + `${this.resourceUrl}/usage`, + { + params: { + top: params.top.toString(), + indexPattern: params.indexPattern, + includeChildren: params.includeChildren.toString() || 'false', + page: params.page.toString() || '0', + size: params.size.toString() || '10' + } + } + ); + } +} + diff --git a/frontend/src/app/app-management/app-management-routing.module.ts b/frontend/src/app/app-management/app-management-routing.module.ts index 73af5c968..5747a2235 100644 --- a/frontend/src/app/app-management/app-management-routing.module.ts +++ b/frontend/src/app/app-management/app-management-routing.module.ts @@ -15,6 +15,7 @@ import {IndexPatternListComponent} from './index-pattern/index-pattern-list/inde import {MenuComponent} from './menu/menu.component'; import {RolloverConfigComponent} from './rollover-config/rollover-config.component'; import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; +import {ApiKeysComponent} from "./api-keys/api-keys.component"; const routes: Routes = [ {path: '', redirectTo: 'settings', pathMatch: 'full'}, @@ -114,7 +115,16 @@ const routes: Routes = [ data: { authorities: [ADMIN_ROLE] }, - }], + }, + { + path: 'api-keys', + component: ApiKeysComponent, + canActivate: [UserRouteAccessService], + data: { + authorities: [ADMIN_ROLE] + }, + } + ], }, ]; diff --git a/frontend/src/app/app-management/app-management.module.ts b/frontend/src/app/app-management/app-management.module.ts index 6e26285a0..79ce3a029 100644 --- a/frontend/src/app/app-management/app-management.module.ts +++ b/frontend/src/app/app-management/app-management.module.ts @@ -11,6 +11,8 @@ import {ComplianceManagementModule} from '../compliance/compliance-management/co import {NavBehavior} from '../shared/behaviors/nav.behavior'; import {VersionUpdateBehavior} from '../shared/behaviors/version-update.behavior'; import {UtmSharedModule} from '../shared/utm-shared.module'; +import {ApiKeysComponent} from './api-keys/api-keys.component'; +import {ApiKeyModalComponent} from './api-keys/shared/components/api-key-modal/api-key-modal.component'; import {AppConfigComponent} from './app-config/app-config.component'; import {AppLogsComponent} from './app-logs/app-logs.component'; import {AppManagementRoutingModule} from './app-management-routing.module'; @@ -46,6 +48,8 @@ import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; @NgModule({ declarations: [ + ApiKeysComponent, + ApiKeyModalComponent, AppManagementComponent, AppManagementSidebarComponent, IndexPatternHelpComponent, @@ -81,6 +85,7 @@ import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; HealthDetailComponent, MenuDeleteDialogComponent, TokenActivateComponent, + ApiKeyModalComponent, IndexDeleteComponent], imports: [ CommonModule, diff --git a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html index 5d39a9d66..60e12d3be 100644 --- a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html +++ b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html @@ -115,6 +115,16 @@ + + +   + API Keys + + + change.groupId === group.id); + const keys = configs.map(config => ({ + ...config, + confOptions: config.confOptions ? JSON.stringify(config.confOptions) : null + })); this.utmModuleGroupConfService.update({ moduleId: group.moduleId, - keys: configs + keys }).subscribe(response => { this.savingConfig = false; this.pendingChanges = false; diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index cb3107398..5e8c44ae7 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -7,7 +7,7 @@