Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9130b73
docs(policy): add OpenSpec change proposal for Policy Enforcement Point
Filip-sz-informed Aug 24, 2026
39b139b
feat(policy): add OPA connection properties
Filip-sz-informed Aug 24, 2026
23fdf36
feat(policy): add PDP decision request/response DTOs
Filip-sz-informed Aug 24, 2026
a6b6bd5
feat(policy): add RestClient bean for the OPA PDP
Filip-sz-informed Aug 24, 2026
c819499
feat(policy): add PolicyDecisionClient invoking the PDP
Filip-sz-informed Aug 24, 2026
c4bea68
feat(policy): add PolicyEnforcementInterceptor
Filip-sz-informed Aug 24, 2026
557d82b
feat(policy): add decision audit logging to the PEP
Filip-sz-informed Aug 24, 2026
c1f8c96
feat(policy): register PolicyEnforcementInterceptor for configuration…
Filip-sz-informed Aug 24, 2026
a346ecd
test(policy): add integration tests for configuration API policy enfo…
Filip-sz-informed Aug 24, 2026
e4b6171
test(policy): verify full test suite and validate change
Filip-sz-informed Aug 24, 2026
7754b20
refactor(policy): simplify PEP code after review
Filip-sz-informed Aug 24, 2026
43d8a01
fix(policy): resolve CI failures from spotless format and httpcore5 CVE
Filip-sz-informed Aug 24, 2026
5f8210f
chore(openspec): stop tracking openspec workspace, keep local-only
Filip-sz-informed Aug 24, 2026
33280be
fix(policy): fail fast when opa.protected-paths is missing
Filip-sz-informed Aug 26, 2026
fca9829
fix(policy): don't protect every path when protected-paths is empty
Filip-sz-informed Aug 26, 2026
fe00bf4
fix(policy): warn when the OPA endpoint is not using TLS
Filip-sz-informed Aug 26, 2026
00cdb0d
fix(policy): populate organisation in PolicyInput sent to OPA
Filip-sz-informed Aug 26, 2026
fc85e72
test(policy): use @BeforeEach instead of manual setUp() calls
Filip-sz-informed Aug 26, 2026
123dcfe
fix(docker): upgrade Alpine OS packages to resolve openssl HIGH CVE
Filip-sz-informed Aug 26, 2026
ab44a6b
fix(policy): avoid superlinear regex in correlation id extraction
Filip-sz-informed Aug 27, 2026
ecc17fc
test(policy): chain assertions on same subject in serialization test
Filip-sz-informed Aug 27, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ build/

### application local file
**src/main/resources/application-local.yml

### local-only openspec workspace
/openspec/
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ COPY --from=build /build/target/${JAR_NAME}.jar /app/app.jar

RUN chown app:app /app/app.jar

RUN apk add --no-cache curl
RUN apk update && apk upgrade --no-cache && apk add --no-cache curl

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add cause of security check failure


# Use non-root user from here on
USER app:app
Expand Down
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<springdoc-openapi-starter-webmvc-ui.version>2.8.13</springdoc-openapi-starter-webmvc-ui.version>
<bouncycastle.version>1.84</bouncycastle.version>
<spring-security.version>6.5.9</spring-security.version>
<httpcore5.version>5.4.3</httpcore5.version>
<sonar.coverage.exclusions>**/config/**,
**/dto/**,
**/entity/**,
Expand All @@ -72,6 +73,16 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to bump version because of security

<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
<version>${httpcore5.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5-h2</artifactId>
<version>${httpcore5.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,9 @@
import java.security.cert.X509Certificate;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal;
import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType;
import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider;

Expand Down Expand Up @@ -78,17 +73,13 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
return false;
}

RequestRejectionSupport.setOrganisationId(request, cert.getOrganisationId());
log.debug("Certificate validation successful for client {} on {}", clientId, request.getRequestURI());
return true;
}

private String extractClientId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof EnhancedPrincipal principal)) {
return null;
}
String clientId = principal.clientId();
return (clientId == null || clientId.isEmpty()) ? null : clientId;
return RequestRejectionSupport.extractClientId();
}

private String validateSerialNumber(HttpServletRequest request, OrganisationCertificateDTO cert, String clientId) {
Expand All @@ -110,10 +101,7 @@ private String validateSerialNumber(HttpServletRequest request, OrganisationCert
}

private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ErrorResponse errorResponse =
new ErrorResponse(status, message, UUID.randomUUID().toString());
objectMapper.writeValue(response.getWriter(), errorResponse);
RequestRejectionSupport.writeError(
response, objectMapper, status, message, UUID.randomUUID().toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: Apache-2.0
* © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
* attributed to the Department for Business and Trade (UK) as the governing entity.
*/

package uk.gov.dbt.ndtp.ia.node.management.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
@EnableConfigurationProperties(OpaProperties.class)
@Slf4j
public class OpaClientConfig {

@Bean
public RestClient opaRestClient(OpaProperties properties) {
if (!properties.url().startsWith("https://")) {
log.warn(
"OPA URL {} is not using TLS. This service enforces mTLS for all "
+ "service-to-service traffic - set OPA_URL to an https:// endpoint "
+ "in any non-local environment.",
properties.url());
}

SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) properties.connectTimeout().toMillis());
requestFactory.setReadTimeout((int) properties.readTimeout().toMillis());

return RestClient.builder()
.baseUrl(properties.url())
.requestFactory(requestFactory)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: Apache-2.0
* © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
* attributed to the Department for Business and Trade (UK) as the governing entity.
*/

package uk.gov.dbt.ndtp.ia.node.management.config;

import jakarta.validation.constraints.NotEmpty;
import java.time.Duration;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
* Connection settings for the OPA Policy Decision Point (PDP), and the set of API path
* patterns the Policy Enforcement Point protects.
*
* @param url base URL of the OPA server (e.g. http://localhost:8181)
* @param decisionPath path appended to the base URL for the decision query (e.g. /v1/data/management_node/allow)
* @param connectTimeout maximum time to wait to establish a connection
* @param readTimeout maximum time to wait for a response
* @param protectedPaths Spring MVC path patterns (e.g. /api/v1/configuration/**) that the PEP intercepts.
* Required and non-empty: Spring's {@code MappedInterceptor} treats an empty include-pattern list
* as "match every path" rather than "match nothing", so this must never be silently absent.
*/
@ConfigurationProperties(prefix = "application.opa")
@Validated
public record OpaProperties(
String url,
String decisionPath,
Duration connectTimeout,
Duration readTimeout,
@NotEmpty List<String> protectedPaths) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: Apache-2.0
* © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
* attributed to the Department for Business and Trade (UK) as the governing entity.
*/

package uk.gov.dbt.ndtp.ia.node.management.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecision;
import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecisionClient;
import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyInput;

/**
* Policy Enforcement Point: intercepts requests to policy-aware APIs, enriches them
* with identity and resource attributes, and enforces the PDP (OPA) allow/deny decision.
* Runs after authentication has already populated the {@link SecurityContextHolder}.
*/
@Component
@Slf4j
public class PolicyEnforcementInterceptor implements HandlerInterceptor {

private final PolicyDecisionClient policyDecisionClient;
private final ObjectMapper objectMapper;

public PolicyEnforcementInterceptor(PolicyDecisionClient policyDecisionClient, ObjectMapper objectMapper) {
this.policyDecisionClient = policyDecisionClient;
this.objectMapper = objectMapper;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String correlationId = UUID.randomUUID().toString();

String clientId = RequestRejectionSupport.extractClientId();
if (clientId == null) {
log.warn("No client ID found for policy-aware request to {}", request.getRequestURI());
RequestRejectionSupport.writeError(
response, objectMapper, HttpServletResponse.SC_FORBIDDEN, "Client ID required", correlationId);
return false;
}

String resource = request.getRequestURI();
String action = request.getMethod();
String organisation = RequestRejectionSupport.getOrganisationId(request);
PolicyInput input = new PolicyInput(clientId, organisation, resource, action);

PolicyDecision decision = policyDecisionClient.evaluate(input);

if (decision == PolicyDecision.ALLOW) {
log.info(
"Policy decision ALLOW clientId={} resource={} action={} correlationId={}",
clientId,
resource,
action,
correlationId);
return true;
}

log.warn(
"Policy decision DENY clientId={} resource={} action={} correlationId={}",
clientId,
resource,
action,
correlationId);
RequestRejectionSupport.writeError(
response, objectMapper, HttpServletResponse.SC_FORBIDDEN, "Access denied by policy", correlationId);
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* SPDX-License-Identifier: Apache-2.0
* © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
* attributed to the Department for Business and Trade (UK) as the governing entity.
*/

package uk.gov.dbt.ndtp.ia.node.management.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse;
import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal;

/**
* Shared request-rejection behaviour for {@code HandlerInterceptor}s that gate access
* on the authenticated client: resolving the client id from the security context and
* writing a JSON {@link ErrorResponse} for a rejected request.
*/
final class RequestRejectionSupport {

private static final String ORGANISATION_ID_ATTRIBUTE = "ndtp.organisationId";

private RequestRejectionSupport() {}

static void setOrganisationId(HttpServletRequest request, Long organisationId) {
request.setAttribute(ORGANISATION_ID_ATTRIBUTE, organisationId);
}

static String getOrganisationId(HttpServletRequest request) {
Object value = request.getAttribute(ORGANISATION_ID_ATTRIBUTE);
return value == null ? null : String.valueOf(value);
}

static String extractClientId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof EnhancedPrincipal principal)) {
return null;
}
String clientId = principal.clientId();
return (clientId == null || clientId.isEmpty()) ? null : clientId;
}

static void writeError(
HttpServletResponse response, ObjectMapper objectMapper, int status, String message, String errorId)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ErrorResponse errorResponse = new ErrorResponse(status, message, errorId);
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

package uk.gov.dbt.ndtp.ia.node.management.config;

import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
Expand All @@ -14,15 +15,27 @@
public class WebConfig implements WebMvcConfigurer {

private final CertificateValidationInterceptor certificateValidationInterceptor;
private final PolicyEnforcementInterceptor policyEnforcementInterceptor;
private final OpaProperties opaProperties;

public WebConfig(CertificateValidationInterceptor certificateValidationInterceptor) {
public WebConfig(
CertificateValidationInterceptor certificateValidationInterceptor,
PolicyEnforcementInterceptor policyEnforcementInterceptor,
OpaProperties opaProperties) {
this.certificateValidationInterceptor = certificateValidationInterceptor;
this.policyEnforcementInterceptor = policyEnforcementInterceptor;
this.opaProperties = opaProperties;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(certificateValidationInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/v1/certificate/**");

List<String> protectedPaths = opaProperties.protectedPaths();
if (!protectedPaths.isEmpty()) {
registry.addInterceptor(policyEnforcementInterceptor).addPathPatterns(protectedPaths);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* SPDX-License-Identifier: Apache-2.0
* © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
* attributed to the Department for Business and Trade (UK) as the governing entity.
*/

package uk.gov.dbt.ndtp.ia.node.management.service.providers.policy;

/** Outcome of a PDP (OPA) policy evaluation. */
public enum PolicyDecision {
ALLOW,
DENY
}
Loading
Loading