From 849fc5d472b3937e57d6e955eb152d4bd3d9ca58 Mon Sep 17 00:00:00 2001 From: Ranto Date: Tue, 7 Jul 2026 14:14:00 +0300 Subject: [PATCH 1/4] feat: add fees-only mode filter Add an opt-in HTTP filter that short-circuits the API to a minimal allow-list of URI prefixes (fees, students, whoami, ping, authentication, health, mpbs) when the FEES_ONLY property is true. The filter is wired before the existing bearer filter chain so it applies before authentication and authorization. Intended as an incident kill-switch, not as a long-term access control mechanism. --- .../rest/security/FeesOnlyFilter.java | 55 +++++++++++++++++++ .../endpoint/rest/security/SecurityConf.java | 9 ++- 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java new file mode 100644 index 000000000..c3e3b30a9 --- /dev/null +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java @@ -0,0 +1,55 @@ +package school.hei.haapi.endpoint.rest.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Restricts the API surface to a minimal set of prefixes when the application is started in + * "fees only" mode. Intended as a kill-switch for incidents: keep payments and the student + * read paths reachable while every other feature is short-circuited with HTTP 403. + * + *

Activation is controlled by the {@code FEES_ONLY} environment variable / property + * (default {@code false}). When the flag is off this filter is a no-op. + */ +@Component +@Slf4j +public class FeesOnlyFilter extends OncePerRequestFilter { + + @Value("${FEES_ONLY:false}") + private boolean feesOnly; + + private static final List ALLOWED_PREFIXES = + List.of("/fees", "/students", "/whoami", "/ping", "/authentication", "/health", "/mpbs"); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!feesOnly) { + filterChain.doFilter(request, response); + return; + } + + String uri = request.getRequestURI(); + boolean allowed = ALLOWED_PREFIXES.stream().anyMatch(uri::startsWith); + + if (!allowed) { + log.info("FEES_ONLY mode: blocked request to {}", uri); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"message\": \"This endpoint is disabled in FEES_ONLY mode\"}"); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java index fe45e20dd..ac1b9561d 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java @@ -47,6 +47,7 @@ public class SecurityConf { private final AbstractUserDetailsAuthenticationProvider authProvider; private final HandlerExceptionResolver exceptionResolver; private final CorRepository corRepository; + private final FeesOnlyFilter feesOnlyFilter; public SecurityConf( CasdoorAuthProvider authProvider, @@ -54,12 +55,14 @@ public SecurityConf( @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver, CourseAssignmentService courseAssignmentService, MonitoringStudentService monitoringStudentService, - CorRepository corRepository) { + CorRepository corRepository, + FeesOnlyFilter feesOnlyFilter) { this.authProvider = authProvider; this.exceptionResolver = exceptionResolver; this.courseAssignmentService = courseAssignmentService; this.monitoringStudentService = monitoringStudentService; this.corRepository = corRepository; + this.feesOnlyFilter = feesOnlyFilter; } @Bean @@ -92,7 +95,9 @@ req, res, null, forbiddenWithRemoteInfo(req)))) // authenticate .authenticationProvider(authProvider) - .addFilterBefore( + .addFilterBefore(feesOnlyFilter, AnonymousAuthenticationFilter.class) + + .addFilterBefore( bearerFilter( new OrRequestMatcher( antMatcher(GET, "/whoami"), From be7297d8e4be202973f3d3b8616612f2b68d45c7 Mon Sep 17 00:00:00 2001 From: Ranto Date: Wed, 8 Jul 2026 00:26:09 +0300 Subject: [PATCH 2/4] test: add unit tests for FeesOnlyFilter - Replace @Value with System.getenv for FEES_ONLY reading - Add package-private constructor FeesOnlyFilter(boolean) for testing - Create FeesOnlyFilterTest with 4 test cases covering allowed, blocked, and disabled scenarios --- .../rest/security/FeesOnlyFilter.java | 23 +++-- .../hei/haapi/unit/FeesOnlyFilterTest.java | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java index c3e3b30a9..359c0542b 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java @@ -7,30 +7,29 @@ import java.io.IOException; import java.util.List; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -/** - * Restricts the API surface to a minimal set of prefixes when the application is started in - * "fees only" mode. Intended as a kill-switch for incidents: keep payments and the student - * read paths reachable while every other feature is short-circuited with HTTP 403. - * - *

Activation is controlled by the {@code FEES_ONLY} environment variable / property - * (default {@code false}). When the flag is off this filter is a no-op. - */ + @Component @Slf4j public class FeesOnlyFilter extends OncePerRequestFilter { - @Value("${FEES_ONLY:false}") - private boolean feesOnly; + private final boolean feesOnly; + + public FeesOnlyFilter() { + this("true".equalsIgnoreCase(System.getenv("FEES_ONLY"))); + } + + public FeesOnlyFilter(boolean feesOnly) { + this.feesOnly = feesOnly; + } private static final List ALLOWED_PREFIXES = List.of("/fees", "/students", "/whoami", "/ping", "/authentication", "/health", "/mpbs"); @Override - protected void doFilterInternal( + public void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { diff --git a/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java b/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java new file mode 100644 index 000000000..338ddca3e --- /dev/null +++ b/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java @@ -0,0 +1,99 @@ +package school.hei.haapi.unit; + +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import school.hei.haapi.endpoint.rest.security.FeesOnlyFilter; + +@ExtendWith(MockitoExtension.class) +class FeesOnlyFilterTest { + + @Mock private FilterChain filterChain; + + @Test + void fees_only_inactive_all_requests_pass() throws ServletException, IOException { + FeesOnlyFilter filter = new FeesOnlyFilter(false); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/teachers"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertEquals(200, response.getStatus()); + verify(filterChain).doFilter(request, response); + } + + @Test + void fees_only_active_allows_configured_prefixes() throws ServletException, IOException { + FeesOnlyFilter filter = new FeesOnlyFilter(true); + + List allowedPrefixes = + List.of( + "/fees", + "/students", + "/whoami", + "/ping", + "/authentication", + "/health", + "/mpbs"); + + for (String prefix : allowedPrefixes) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI(prefix); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertEquals( + 200, response.getStatus(), "URI " + prefix + " should be allowed in FEES_ONLY mode"); + } + } + + @Test + void fees_only_active_blocks_other_uris() throws ServletException, IOException { + FeesOnlyFilter filter = new FeesOnlyFilter(true); + + List blockedUris = + List.of("/teachers", "/groups", "/events", "/courses", "/exams", "/unknown"); + + for (String uri : blockedUris) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI(uri); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertEquals(SC_FORBIDDEN, response.getStatus(), "URI " + uri + " should be blocked"); + assertEquals( + "{\"message\": \"This endpoint is disabled in FEES_ONLY mode\"}", + response.getContentAsString()); + } + } + + @Test + void fees_only_active_allows_subpaths_of_allowed_prefixes() + throws ServletException, IOException { + FeesOnlyFilter filter = new FeesOnlyFilter(true); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/students/student1_id/fees/fee1_id/payments"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertEquals(200, response.getStatus()); + verify(filterChain).doFilter(request, response); + } +} From b1d77089efd6ea5ea39743c5f340713faf31d60f Mon Sep 17 00:00:00 2001 From: Ranto Date: Wed, 8 Jul 2026 11:38:29 +0300 Subject: [PATCH 3/4] docs: document FEES_ONLY restricted mode in README --- README.md | 20 +++++++++++++++++++ .../endpoint/rest/security/SecurityConf.java | 13 ------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index acfd17517..d8a1fc8df 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,23 @@ and implements it in Java. [Releases](https://github.com/hei-school/hei-admin-api/releases) are published [here](https://gallery.ecr.aws/q6i6y5o4/hei-admin-api) as Docker images. Feel free to use them. We welcome [contributions](https://github.com/hei-school/hei-admin-api/blob/dev/CONTRIBUTING.md). + +## FEES_ONLY mode + +Set the environment variable `FEES_ONLY=true` to restrict the API to a read-only subset of endpoints. +In this mode, only requests targeting the following URI prefixes are allowed: + +| Prefix | Description | +|--------|-------------| +| `/fees` | Fee management | +| `/students` | Student management | +| `/whoami` | Current user identity | +| `/ping` | Liveness check | +| `/authentication` | Authentication | +| `/health` | Health checks | +| `/mpbs` | Mobile Payment by SMS | + +All other endpoints return **HTTP 403 Forbidden** with the message +`"This endpoint is disabled in FEES_ONLY mode"`. + +When `FEES_ONLY` is unset or `false`, the API behaves normally. diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java index ac1b9561d..6a26120f1 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java @@ -80,10 +80,6 @@ public SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception exceptionHandlingConfigurer -> exceptionHandlingConfigurer .authenticationEntryPoint( - // note(spring-exception) - // https://stackoverflow.com/questions/59417122/how-to-handle-usernamenotfoundexception-spring-security - // issues like when a user tries to access a resource - // without appropriate authentication elements (req, res, e) -> exceptionResolver.resolveException( req, res, null, forbiddenWithRemoteInfo(req))) @@ -1102,10 +1098,6 @@ req, res, null, forbiddenWithRemoteInfo(req)))) .requestMatchers("/**") .denyAll()) - // disable superfluous protections - // Eg if all clients are non-browser then no csrf - // https://docs.spring.io/spring-security/site/docs/3.2.0.CI-SNAPSHOT/reference/html/csrf.html, - // Sec 13.3 .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -1130,11 +1122,6 @@ private BearerAuthFilter bearerFilter(RequestMatcher requiresAuthenticationReque (httpServletRequest, httpServletResponse, authentication) -> {}); bearerFilter.setAuthenticationFailureHandler( (req, res, e) -> - // note(spring-exception) - // issues like when a user is not found(i.e. UsernameNotFoundException) - // or other exceptions thrown inside authentication provider. - // In fact, this handles other authentication exceptions that are - // not handled by AccessDeniedException and AuthenticationEntryPoint exceptionResolver.resolveException(req, res, null, forbiddenWithRemoteInfo(req))); return bearerFilter; } From f49bde243998a940bd6b862ec2b484475c4fbf63 Mon Sep 17 00:00:00 2001 From: Ranto Date: Thu, 9 Jul 2026 08:36:34 +0300 Subject: [PATCH 4/4] fix: fix format --- .../haapi/endpoint/rest/security/FeesOnlyFilter.java | 1 - .../haapi/endpoint/rest/security/SecurityConf.java | 6 ++---- .../school/hei/haapi/unit/FeesOnlyFilterTest.java | 12 ++---------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java index 359c0542b..0752b1683 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyFilter.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; - @Component @Slf4j public class FeesOnlyFilter extends OncePerRequestFilter { diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java index 6a26120f1..3dc42b6f9 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java @@ -91,9 +91,8 @@ req, res, null, forbiddenWithRemoteInfo(req)))) // authenticate .authenticationProvider(authProvider) - .addFilterBefore(feesOnlyFilter, AnonymousAuthenticationFilter.class) - - .addFilterBefore( + .addFilterBefore(feesOnlyFilter, AnonymousAuthenticationFilter.class) + .addFilterBefore( bearerFilter( new OrRequestMatcher( antMatcher(GET, "/whoami"), @@ -1097,7 +1096,6 @@ req, res, null, forbiddenWithRemoteInfo(req)))) .authenticated() .requestMatchers("/**") .denyAll()) - .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java b/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java index 338ddca3e..b5e12de4e 100644 --- a/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java +++ b/src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java @@ -40,14 +40,7 @@ void fees_only_active_allows_configured_prefixes() throws ServletException, IOEx FeesOnlyFilter filter = new FeesOnlyFilter(true); List allowedPrefixes = - List.of( - "/fees", - "/students", - "/whoami", - "/ping", - "/authentication", - "/health", - "/mpbs"); + List.of("/fees", "/students", "/whoami", "/ping", "/authentication", "/health", "/mpbs"); for (String prefix : allowedPrefixes) { MockHttpServletRequest request = new MockHttpServletRequest(); @@ -83,8 +76,7 @@ void fees_only_active_blocks_other_uris() throws ServletException, IOException { } @Test - void fees_only_active_allows_subpaths_of_allowed_prefixes() - throws ServletException, IOException { + void fees_only_active_allows_subpaths_of_allowed_prefixes() throws ServletException, IOException { FeesOnlyFilter filter = new FeesOnlyFilter(true); MockHttpServletRequest request = new MockHttpServletRequest();