Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.itasocialacademy.oitassist.envvar.controller;

import com.itasocialacademy.oitassist.envvar.service.interfaces.EnvVariableService;
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/admin/environment-variables")
@Hidden
public class EnvVariableController {
private final SecurityFacade securityFacade;
private final EnvVariableService envVariableService;

@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public @NonNull Map<@NonNull String, @Nullable String> getMap() {
log.info("User with ID '{}' requested all environment variables",
securityFacade.getCurrentUserId().orElse(null));
return envVariableService.getenv();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.itasocialacademy.oitassist.envvar.dao.enums;

public enum AccessMode {
ALL,
WHITELIST,
BLACKLIST
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.itasocialacademy.oitassist.envvar.dao.response;

public record EnvVariableResponse(String key, String value) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@org.springframework.modulith.ApplicationModule(
displayName = "environment variables",
allowedDependencies = {"security :: SecurityFacade"})

package com.itasocialacademy.oitassist.envvar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.itasocialacademy.oitassist.envvar.properties;

import com.itasocialacademy.oitassist.envvar.dao.enums.AccessMode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.Set;

@Slf4j
@Validated
@ConfigurationProperties(prefix = "app.envvar")
public record EnvVariableProperties(@NotNull(message = "Access mode cannot be null") @NonNull AccessMode accessMode,
@NonNull Set<@NotNull(message = "whitelist key cannot be null") @NotBlank(
message = "whitelist key cannot be blank") String> whitelist,
@NonNull Set<@NotNull(message = "blacklist key cannot be null") @NotBlank(
message = "blacklist key cannot be blank") String> blacklist) {
public EnvVariableProperties(AccessMode accessMode, @Nullable Set<String> whitelist,
@Nullable Set<String> blacklist) {
this.accessMode = accessMode;
this.whitelist = whitelist == null ? Set.of() : Set.copyOf(whitelist);
this.blacklist = blacklist == null ? Set.of() : Set.copyOf(blacklist);

if (accessMode == AccessMode.ALL && (blacklist != null || whitelist != null)) {
throw new IllegalStateException("accessMode=ALL, but a blacklist/whitelist is configured. "
+ "Lists are ignored in this mode: use AccessMode.BLACKLIST or AccessMode.WHITELIST.");
}

if (accessMode == AccessMode.WHITELIST) {
if (whitelist == null) {
log.warn("Access mode is '{}' but whitelist is not configured: every key will be denied", accessMode);
}
if (blacklist != null) {
log.warn("Access mode is '{}' but blacklist is configured; it will be ignored", accessMode);
}
}

if (accessMode == AccessMode.BLACKLIST) {
if (blacklist == null) {
log.warn("Access mode is '{}' but blacklist is not configured: every key will be allowed", accessMode);
}
if (whitelist != null) {
log.warn("Access mode is '{}' but whitelist is configured; it will be ignored", accessMode);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.itasocialacademy.oitassist.envvar.provider;

import com.itasocialacademy.oitassist.envvar.provider.interfaces.EnvVariableProvider;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Component;
import java.util.Map;

@Component
public class SystemEnvVariableProvider implements EnvVariableProvider {
@Override
public @NonNull Map<@NonNull String, @Nullable String> getenv() {
return System.getenv();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.itasocialacademy.oitassist.envvar.provider.interfaces;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import java.util.Map;

public interface EnvVariableProvider {
@NonNull
Map<@NonNull String, @Nullable String> getenv();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.itasocialacademy.oitassist.envvar.service;

import com.itasocialacademy.oitassist.envvar.properties.EnvVariableProperties;
import com.itasocialacademy.oitassist.envvar.provider.interfaces.EnvVariableProvider;
import com.itasocialacademy.oitassist.envvar.service.interfaces.EnvVariableService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static com.itasocialacademy.oitassist.envvar.dao.enums.AccessMode.ALL;

@Slf4j
@Service
@RequiredArgsConstructor
public class EnvVariableServiceImpl implements EnvVariableService {
private final EnvVariableProvider envVariableProvider;
private final EnvVariableProperties envVariableProperties;

@Override
public @NonNull Map<@NonNull String, @Nullable String> getenv() {
Map<String, String> all = envVariableProvider.getenv();
if (envVariableProperties.accessMode() == ALL) {
return Collections.unmodifiableMap(new HashMap<>(all));
}

Map<String, String> result = new HashMap<>();
all.forEach((key, value) -> {
if (isAllowed(key)) {
result.put(key, value);
}
});
return Collections.unmodifiableMap(result);
}

private boolean isAllowed(@NonNull String key) {
return switch (envVariableProperties.accessMode()) {
case ALL -> true;
case WHITELIST -> envVariableProperties.whitelist().contains(key);
case BLACKLIST -> !envVariableProperties.blacklist().contains(key);
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.itasocialacademy.oitassist.envvar.service.interfaces;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import java.util.Map;

public interface EnvVariableService {
Map<@NonNull String, @Nullable String> getenv();
}
7 changes: 6 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,9 @@ app:
cron: ${APP_FILEMANAGER_CLEANUP_CRON:0 0 3 * * *}
news:
archiving:
cron: ${APP_NEWS_ARCHIVING_CRON:0 5 0 * * *}
cron: ${APP_NEWS_ARCHIVING_CRON:0 5 0 * * *}
envvar:
access-mode: blacklist
blacklist:
- JWT_SIGN_KEY
- JWT_ENCRYPTED_KEY
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.itasocialacademy.oitassist.envvar.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.itasocialacademy.oitassist.core.web.AppExceptionHttpStatusMapper;
import com.itasocialacademy.oitassist.envvar.service.interfaces.EnvVariableService;
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
import com.itasocialacademy.oitassist.security.jwt.CustomAuthenticationEntryPoint;
import com.itasocialacademy.oitassist.security.jwt.JwtFilter;
import io.swagger.v3.oas.annotations.Hidden;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.ObjectMapper;

@WebMvcTest(
controllers = EnvVariableController.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = JwtFilter.class))
@Import(EnvVariableControllerTest.SecurityTestConfiguration.class)
class EnvVariableControllerTest {

private static final String ENDPOINT = "/api/v1/admin/environment-variables";
private static final String PUBLIC_KEY = "APP_NAME";
private static final String PUBLIC_VALUE = "oit-assist";
private static final Long ADMIN_ID = 42L;

@Autowired
private MockMvc mockMvc;

@MockitoBean
private EnvVariableService envVariableService;

@MockitoBean
private SecurityFacade securityFacade;

@MockitoBean
private AppExceptionHttpStatusMapper appExceptionHttpStatusMapper;

@Test
@WithMockUser(roles = "ADMIN")
void getMap_ShouldReturnVariables_WhenCallerIsAdmin() throws Exception {
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(ADMIN_ID));
when(envVariableService.getenv()).thenReturn(Map.of(PUBLIC_KEY, PUBLIC_VALUE));

mockMvc.perform(get(ENDPOINT))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$." + PUBLIC_KEY).value(PUBLIC_VALUE));
}

@Test
@WithMockUser(roles = "ADMIN")
void getMap_ShouldReturnEmptyObject_WhenNoVariableIsAllowed() throws Exception {
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(ADMIN_ID));
when(envVariableService.getenv()).thenReturn(Map.of());

mockMvc.perform(get(ENDPOINT))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}

@Test
@WithMockUser(roles = "USER")
void getMap_ShouldReturnForbidden_WhenCallerIsAuthenticatedButNotAdmin() throws Exception {
mockMvc.perform(get(ENDPOINT))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("ACCESS_DENIED"))
.andExpect(jsonPath("$.status").value(403));

verifyNoInteractions(envVariableService);
}

@Test
void getMap_ShouldReturnUnauthorized_WhenCallerIsAnonymous() throws Exception {
mockMvc.perform(get(ENDPOINT))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED"))
.andExpect(jsonPath("$.status").value(401));

verifyNoInteractions(envVariableService);
}

@Test
void controller_ShouldBeExcludedFromApiDocumentation() {
assertThat(EnvVariableController.class.getAnnotation(Hidden.class))
.as("the endpoint must stay out of the generated OpenAPI document")
.isNotNull();
}

@TestConfiguration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
static class SecurityTestConfiguration {
@Bean
AuthenticationEntryPoint authenticationEntryPoint() {
return new CustomAuthenticationEntryPoint(new ObjectMapper());
}

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationEntryPoint entryPoint) {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorization -> authorization
.anyRequest()
.authenticated())
.exceptionHandling(exception -> exception.authenticationEntryPoint(entryPoint))
.build();
}
}
}
Loading
Loading