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
36 changes: 32 additions & 4 deletions backend/src/main/java/ca/bc/gov/nrs/ilcr/BackendConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import lombok.NoArgsConstructor;

/**
* Application-wide legacy constants used for SQL parameter placeholders and sentinel values.
* Application-wide constants: SQL parameter placeholders, sentinel values, and shared
* security path lists.
*
* <p>The values in this class represent special token values used across the codebase to
* indicate an absent or unspecified value (for example when binding query parameters) and a
* placeholder client identifier used when no client is available.</p>
* <p>The token values in this class indicate an absent or unspecified value (for example when
* binding query parameters) and a placeholder client identifier used when no client is
* available. The path arrays centralize the request matchers referenced by the security
* configuration.</p>
*
* <p>This class is not instantiable and only exposes static constant values.</p>
*/
Expand All @@ -30,4 +32,30 @@ public class BackendConstants {
* none are available.</p>
*/
public static final String NOCLIENT = "NOCLIENT";

/**
* Paths permitted without authentication regardless of whether security is enabled.
*
* <p>Referenced by the security filter chain to allow the API root, health, info, and
* metrics endpoints.</p>
*/
public static final String[] PUBLIC_PATHS = {
"/api",
"/api/health",
"/api/health/**",
"/api/info",
"/api/prometheus"
};

/**
* Home-page option-list endpoints (Story 1.1). Pre-selection reads with no action gate and no
* {@code @PreAuthorize}; permitted even when security is enabled. The per-user mill-association
* filter arrives with the FAM auth story (AR4); until then these are open like the other
* pre-auth reads.
*/
public static final String[] HOME_PUBLIC_PATHS = {
"/api/v1/mills",
"/api/v1/reporting-years",
"/api/v1/mill-context"
};
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package ca.bc.gov.nrs.ilcr.configuration;

import ca.bc.gov.nrs.ilcr.BackendConstants;
import ca.bc.gov.nrs.ilcr.dto.base.Role;
import ca.bc.gov.nrs.ilcr.security.CognitoGroupsJwtAuthenticationConverter;
import ca.bc.gov.nrs.ilcr.security.LocalDevPrincipalFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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;
Expand All @@ -19,14 +21,6 @@
@EnableMethodSecurity
public class SecurityConfiguration {

private static final String[] PUBLIC_PATHS = {
"/api",
"/api/health",
"/api/health/**",
"/api/info",
"/api/prometheus"
};

@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
Expand Down Expand Up @@ -58,6 +52,7 @@ public SecurityFilterChain securityFilterChain(
jwt.jwtAuthenticationConverter(cognitoGroupsConverter)))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/health", "/api/health/**", "/api/info").permitAll()
.requestMatchers(HttpMethod.GET, BackendConstants.HOME_PUBLIC_PATHS).permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated());
} else {
Expand All @@ -69,7 +64,7 @@ public SecurityFilterChain securityFilterChain(
new LocalDevPrincipalFilter(localDevRole != null ? localDevRole : Role.SUBMITTER),
UsernamePasswordAuthenticationFilter.class);
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(PUBLIC_PATHS).permitAll()
.requestMatchers(BackendConstants.PUBLIC_PATHS).permitAll()
.anyRequest().permitAll());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ca.bc.gov.nrs.ilcr.exception;

import java.util.List;

/**
* One or more required selection fields are missing/blank/invalid (UC-SEC-001 S04/S05/S08). Carries
* the ordered field labels (screen order — e.g. {@code Mill} before {@code Reporting Year}) so
* {@link GlobalExceptionHandler} can resolve the verbatim legacy required-field text
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field and
* return ALL messages together on a single 400 (S08) — unlike a typed {@code @RequestParam}, which
* would fail on the first field only.
*/
public class FieldValuesRequiredException extends RuntimeException {

private final transient List<String> fieldLabels;

public FieldValuesRequiredException(List<String> fieldLabels) {
super("Required fields missing: " + String.join(", ", fieldLabels));
this.fieldLabels = List.copyOf(fieldLabels);
}

public List<String> getFieldLabels() {
return fieldLabels;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
@Slf4j
public class GlobalExceptionHandler {

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

private final MessageSource messageSource;

public GlobalExceptionHandler(MessageSource messageSource) {
Expand Down Expand Up @@ -216,6 +219,45 @@ public ResponseEntity<ProblemDetail> handleAccessDenied(
.body(problem);
}

/**
* Handles missing/blank/invalid required selection fields (UC-SEC-001 S04/S05/S08, Story 1.2).
* Resolves the verbatim legacy required-field template
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field —
* passing the field label as the {@code {0}} argument (parameterized keys MUST get an args array)
* — and returns ALL field messages together on one 400: {@code detail} joins the texts and the
* {@code messages} extension property carries each {@code {key, text}} pair (the pinned shape the
* frontend renders per field, mirroring {@code MessageInfo}).
*
* @param ex the exception carrying the ordered missing-field labels
* @param request the current HTTP request
* @return a {@link ProblemDetail} response with HTTP 400 status and a {@code messages} array
*/
@ExceptionHandler(FieldValuesRequiredException.class)
public ResponseEntity<ProblemDetail> handleFieldValuesRequired(
FieldValuesRequiredException ex, HttpServletRequest request) {
log.debug("Required selection fields missing: {}", ex.getFieldLabels());

var messages = ex.getFieldLabels().stream()
.map(label -> new FieldMessage(
REQUIRED_FIELD_KEY,
messageSource.getMessage(
REQUIRED_FIELD_KEY,
new Object[] {label},
REQUIRED_FIELD_KEY,
LocaleContextHolder.getLocale())))
.toList();

ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation Failed");
problem.setDetail(messages.stream().map(FieldMessage::text).collect(Collectors.joining("; ")));
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("messages", messages);

return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}

/**
* Handles a missing required request parameter (e.g. absent {@code millId}/{@code year}) and
* returns a 400 problem response. Without this handler these fall through to the generic 500
Expand Down Expand Up @@ -343,6 +385,15 @@ public ResponseEntity<ProblemDetail> handleGenericException(
.body(problem);
}

/**
* One resolved field-level message on a 400 {@code messages} array: the legacy bundle key plus
* its resolved verbatim text (mirrors {@code MessageInfo}; pinned in Story 1.2's wire contract).
*
* @param key the legacy bundle key (e.g. {@code javax.faces.component.UIInput.REQUIRED})
* @param text the resolved verbatim text (e.g. {@code Mill: Value is required.})
*/
public record FieldMessage(String key, String text) {}

/**
* Attempts to extract the most useful message from a DataIntegrityViolationException.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ca.bc.gov.nrs.ilcr.millcontext;

import ca.bc.gov.nrs.ilcr.millcontext.api.MillContextApi;
import ca.bc.gov.nrs.ilcr.millcontext.dto.MillSummary;
import ca.bc.gov.nrs.ilcr.millcontext.dto.ReportingYear;
import ca.bc.gov.nrs.ilcr.millcontext.dto.WorkingContext;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

/**
* Home-page option-list endpoints (Story 1.1). Delegates to {@link MillContextService} and never
* touches the repository directly (AD-1 layering). These are pre-selection reads with NO
* {@code @PreAuthorize} — there are no roles/authorization yet (see the story's Authorization note);
* the security filter chain permits the two paths even when {@code ilcr.security.enabled=true}.
*/
@RestController
@RequiredArgsConstructor
public class MillContextController implements MillContextApi {

private final MillContextService millContextService;

@Override
public ResponseEntity<List<MillSummary>> listMills() {
return ResponseEntity.ok(millContextService.listMills());
}

@Override
public ResponseEntity<List<ReportingYear>> listReportingYears() {
return ResponseEntity.ok(millContextService.listReportingYears());
}

@Override
public ResponseEntity<WorkingContext> getMillContext(String millId, String year) {
return ResponseEntity.ok(millContextService.resolveWorkingContext(millId, year));
}
}
Loading
Loading