Skip to content

Commit cbc6abe

Browse files
Merge branch 'main' into feat/pipeline
2 parents 8fecf63 + d4831c4 commit cbc6abe

24 files changed

Lines changed: 1346 additions & 57 deletions

backend/src/main/java/ca/bc/gov/nrs/ilcr/BackendConstants.java

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import lombok.NoArgsConstructor;
55

66
/**
7-
* Application-wide legacy constants used for SQL parameter placeholders and sentinel values.
7+
* Application-wide constants: SQL parameter placeholders, sentinel values, and shared
8+
* security path lists.
89
*
9-
* <p>The values in this class represent special token values used across the codebase to
10-
* indicate an absent or unspecified value (for example when binding query parameters) and a
11-
* placeholder client identifier used when no client is available.</p>
10+
* <p>The token values in this class indicate an absent or unspecified value (for example when
11+
* binding query parameters) and a placeholder client identifier used when no client is
12+
* available. The path arrays centralize the request matchers referenced by the security
13+
* configuration.</p>
1214
*
1315
* <p>This class is not instantiable and only exposes static constant values.</p>
1416
*/
@@ -30,4 +32,30 @@ public class BackendConstants {
3032
* none are available.</p>
3133
*/
3234
public static final String NOCLIENT = "NOCLIENT";
35+
36+
/**
37+
* Paths permitted without authentication regardless of whether security is enabled.
38+
*
39+
* <p>Referenced by the security filter chain to allow the API root, health, info, and
40+
* metrics endpoints.</p>
41+
*/
42+
public static final String[] PUBLIC_PATHS = {
43+
"/api",
44+
"/api/health",
45+
"/api/health/**",
46+
"/api/info",
47+
"/api/prometheus"
48+
};
49+
50+
/**
51+
* Home-page option-list endpoints (Story 1.1). Pre-selection reads with no action gate and no
52+
* {@code @PreAuthorize}; permitted even when security is enabled. The per-user mill-association
53+
* filter arrives with the FAM auth story (AR4); until then these are open like the other
54+
* pre-auth reads.
55+
*/
56+
public static final String[] HOME_PUBLIC_PATHS = {
57+
"/api/v1/mills",
58+
"/api/v1/reporting-years",
59+
"/api/v1/mill-context"
60+
};
3361
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/SecurityConfiguration.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package ca.bc.gov.nrs.ilcr.configuration;
22

3+
import ca.bc.gov.nrs.ilcr.BackendConstants;
34
import ca.bc.gov.nrs.ilcr.dto.base.Role;
45
import ca.bc.gov.nrs.ilcr.security.CognitoGroupsJwtAuthenticationConverter;
56
import ca.bc.gov.nrs.ilcr.security.LocalDevPrincipalFilter;
67
import jakarta.servlet.http.HttpServletResponse;
78
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.context.annotation.Bean;
910
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.http.HttpMethod;
1012
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1113
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1214
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -19,14 +21,6 @@
1921
@EnableMethodSecurity
2022
public class SecurityConfiguration {
2123

22-
private static final String[] PUBLIC_PATHS = {
23-
"/api",
24-
"/api/health",
25-
"/api/health/**",
26-
"/api/info",
27-
"/api/prometheus"
28-
};
29-
3024
@Bean
3125
public SecurityFilterChain securityFilterChain(
3226
HttpSecurity http,
@@ -58,6 +52,7 @@ public SecurityFilterChain securityFilterChain(
5852
jwt.jwtAuthenticationConverter(cognitoGroupsConverter)))
5953
.authorizeHttpRequests(authorize -> authorize
6054
.requestMatchers("/api/health", "/api/health/**", "/api/info").permitAll()
55+
.requestMatchers(HttpMethod.GET, BackendConstants.HOME_PUBLIC_PATHS).permitAll()
6156
.requestMatchers("/api/**").authenticated()
6257
.anyRequest().authenticated());
6358
} else {
@@ -69,7 +64,7 @@ public SecurityFilterChain securityFilterChain(
6964
new LocalDevPrincipalFilter(localDevRole != null ? localDevRole : Role.SUBMITTER),
7065
UsernamePasswordAuthenticationFilter.class);
7166
http.authorizeHttpRequests(authorize -> authorize
72-
.requestMatchers(PUBLIC_PATHS).permitAll()
67+
.requestMatchers(BackendConstants.PUBLIC_PATHS).permitAll()
7368
.anyRequest().permitAll());
7469
}
7570

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package ca.bc.gov.nrs.ilcr.exception;
2+
3+
import java.util.List;
4+
5+
/**
6+
* One or more required selection fields are missing/blank/invalid (UC-SEC-001 S04/S05/S08). Carries
7+
* the ordered field labels (screen order — e.g. {@code Mill} before {@code Reporting Year}) so
8+
* {@link GlobalExceptionHandler} can resolve the verbatim legacy required-field text
9+
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field and
10+
* return ALL messages together on a single 400 (S08) — unlike a typed {@code @RequestParam}, which
11+
* would fail on the first field only.
12+
*/
13+
public class FieldValuesRequiredException extends RuntimeException {
14+
15+
private final transient List<String> fieldLabels;
16+
17+
public FieldValuesRequiredException(List<String> fieldLabels) {
18+
super("Required fields missing: " + String.join(", ", fieldLabels));
19+
this.fieldLabels = List.copyOf(fieldLabels);
20+
}
21+
22+
public List<String> getFieldLabels() {
23+
return fieldLabels;
24+
}
25+
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/exception/GlobalExceptionHandler.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
@Slf4j
3232
public class GlobalExceptionHandler {
3333

34+
/** Legacy JSF required-field bundle key, ported verbatim (Story 1.2, AD-8). */
35+
private static final String REQUIRED_FIELD_KEY = "javax.faces.component.UIInput.REQUIRED";
36+
3437
private final MessageSource messageSource;
3538

3639
public GlobalExceptionHandler(MessageSource messageSource) {
@@ -216,6 +219,45 @@ public ResponseEntity<ProblemDetail> handleAccessDenied(
216219
.body(problem);
217220
}
218221

222+
/**
223+
* Handles missing/blank/invalid required selection fields (UC-SEC-001 S04/S05/S08, Story 1.2).
224+
* Resolves the verbatim legacy required-field template
225+
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field —
226+
* passing the field label as the {@code {0}} argument (parameterized keys MUST get an args array)
227+
* — and returns ALL field messages together on one 400: {@code detail} joins the texts and the
228+
* {@code messages} extension property carries each {@code {key, text}} pair (the pinned shape the
229+
* frontend renders per field, mirroring {@code MessageInfo}).
230+
*
231+
* @param ex the exception carrying the ordered missing-field labels
232+
* @param request the current HTTP request
233+
* @return a {@link ProblemDetail} response with HTTP 400 status and a {@code messages} array
234+
*/
235+
@ExceptionHandler(FieldValuesRequiredException.class)
236+
public ResponseEntity<ProblemDetail> handleFieldValuesRequired(
237+
FieldValuesRequiredException ex, HttpServletRequest request) {
238+
log.debug("Required selection fields missing: {}", ex.getFieldLabels());
239+
240+
var messages = ex.getFieldLabels().stream()
241+
.map(label -> new FieldMessage(
242+
REQUIRED_FIELD_KEY,
243+
messageSource.getMessage(
244+
REQUIRED_FIELD_KEY,
245+
new Object[] {label},
246+
REQUIRED_FIELD_KEY,
247+
LocaleContextHolder.getLocale())))
248+
.toList();
249+
250+
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
251+
problem.setTitle("Validation Failed");
252+
problem.setDetail(messages.stream().map(FieldMessage::text).collect(Collectors.joining("; ")));
253+
problem.setInstance(URI.create(request.getRequestURI()));
254+
problem.setProperty("messages", messages);
255+
256+
return ResponseEntity.badRequest()
257+
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
258+
.body(problem);
259+
}
260+
219261
/**
220262
* Handles a missing required request parameter (e.g. absent {@code millId}/{@code year}) and
221263
* returns a 400 problem response. Without this handler these fall through to the generic 500
@@ -343,6 +385,15 @@ public ResponseEntity<ProblemDetail> handleGenericException(
343385
.body(problem);
344386
}
345387

388+
/**
389+
* One resolved field-level message on a 400 {@code messages} array: the legacy bundle key plus
390+
* its resolved verbatim text (mirrors {@code MessageInfo}; pinned in Story 1.2's wire contract).
391+
*
392+
* @param key the legacy bundle key (e.g. {@code javax.faces.component.UIInput.REQUIRED})
393+
* @param text the resolved verbatim text (e.g. {@code Mill: Value is required.})
394+
*/
395+
public record FieldMessage(String key, String text) {}
396+
346397
/**
347398
* Attempts to extract the most useful message from a DataIntegrityViolationException.
348399
*/
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package ca.bc.gov.nrs.ilcr.millcontext;
2+
3+
import ca.bc.gov.nrs.ilcr.millcontext.api.MillContextApi;
4+
import ca.bc.gov.nrs.ilcr.millcontext.dto.MillSummary;
5+
import ca.bc.gov.nrs.ilcr.millcontext.dto.ReportingYear;
6+
import ca.bc.gov.nrs.ilcr.millcontext.dto.WorkingContext;
7+
import java.util.List;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.RestController;
11+
12+
/**
13+
* Home-page option-list endpoints (Story 1.1). Delegates to {@link MillContextService} and never
14+
* touches the repository directly (AD-1 layering). These are pre-selection reads with NO
15+
* {@code @PreAuthorize} — there are no roles/authorization yet (see the story's Authorization note);
16+
* the security filter chain permits the two paths even when {@code ilcr.security.enabled=true}.
17+
*/
18+
@RestController
19+
@RequiredArgsConstructor
20+
public class MillContextController implements MillContextApi {
21+
22+
private final MillContextService millContextService;
23+
24+
@Override
25+
public ResponseEntity<List<MillSummary>> listMills() {
26+
return ResponseEntity.ok(millContextService.listMills());
27+
}
28+
29+
@Override
30+
public ResponseEntity<List<ReportingYear>> listReportingYears() {
31+
return ResponseEntity.ok(millContextService.listReportingYears());
32+
}
33+
34+
@Override
35+
public ResponseEntity<WorkingContext> getMillContext(String millId, String year) {
36+
return ResponseEntity.ok(millContextService.resolveWorkingContext(millId, year));
37+
}
38+
}

0 commit comments

Comments
 (0)