Skip to content
Open
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
7 changes: 7 additions & 0 deletions docker/opa/policy.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package management_node

default allow = true

# Product discovery (ProductDiscoveryService) evaluates one decision per candidate product,
# with resource "product:{id}" and action "discover" - see PolicyInput. A real discovery
# policy belongs here once authored (see docs/POLICY_ENFORCEMENT_TESTING.md).
5 changes: 5 additions & 0 deletions docs/AUTHENTICATION_REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ Notes:
- Bootstrap Certificate API: The onboarding service account may request bootstrap certificate packages when its token contains the role `request_bootstrap_certificate`. The request body contains the target `organisationId` and a CSR. If no certificate record exists for the organisation, one is created automatically. This role is typically assigned only to the website backend service account, not to individual federator clients.
- Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:request_bootstrap_certificate')")` on `POST /api/v1/certificate/bootstrap`.

- Product Discovery API: Clients may discover the products they are authorised to see when their token contains the role `discover_products`. Even with the role, results are further filtered per-product by the PDP (see `docs/POLICY_ENFORCEMENT_TESTING.md`) - the role only gates access to the endpoint itself.
- Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:discover_products')")` on `POST /api/v1/product/discovery`.

## How this maps to Keycloak

- In Keycloak, roles are typically assigned to a client (here conceptually the `management-node` client) and appear in tokens under `resource_access["management-node"].roles`.
Expand All @@ -91,6 +94,7 @@ Notes:
- `sign_certificate`
- `access_public_certificates`
- `request_bootstrap_certificate`
- `discover_products`
- Assign configuration roles to the appropriate Producer or Consumer Federator clients or service accounts.
- Assign certificate roles (`create_keys`, `sign_certificate`, `access_public_certificates`) to federator service accounts that manage their own certificates.
- Assign `request_bootstrap_certificate` only to the website/onboarding backend service account.
Expand Down Expand Up @@ -123,4 +127,5 @@ curl -k 'https://localhost:8090/api/v1/configuration/producer' \
- CSR Signing API requires role: `sign_certificate`.
- Intermediate Certificate API requires role: `access_public_certificates`.
- Bootstrap Certificate API requires role: `request_bootstrap_certificate`.
- Product Discovery API requires role: `discover_products` (plus per-product PDP authorisation).
- Swagger/OpenAPI: Use Swagger UI at `/swagger-ui.html` to explore and test with a valid token.
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
/**
* 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.
* writing a JSON {@link ErrorResponse} for a rejected request. {@link #getOrganisationId}
* is also read by controllers (e.g. product discovery) that need the organisation
* {@link CertificateValidationInterceptor} resolved for the current request.
*/
final class RequestRejectionSupport {
public final class RequestRejectionSupport {

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

Expand All @@ -31,7 +33,7 @@ static void setOrganisationId(HttpServletRequest request, Long organisationId) {
request.setAttribute(ORGANISATION_ID_ATTRIBUTE, organisationId);
}

static String getOrganisationId(HttpServletRequest request) {
public static String getOrganisationId(HttpServletRequest request) {
Object value = request.getAttribute(ORGANISATION_ID_ATTRIBUTE);
return value == null ? null : String.valueOf(value);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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.controller.v1;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import uk.gov.dbt.ndtp.ia.node.management.config.RequestRejectionSupport;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryRequestDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal;
import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductDiscoveryService;

@RestController
@RequestMapping("/api/v1/product")
@Slf4j
@Tag(
name = "Product Discovery",
description = "Policy-aware discovery of data products the requester is authorised to see.")
public class ProductDiscoveryController {

private final ProductDiscoveryService productDiscoveryService;

public ProductDiscoveryController(ProductDiscoveryService productDiscoveryService) {
this.productDiscoveryService = productDiscoveryService;
}

@PostMapping("/discovery")
@PreAuthorize("hasAuthority('ROLE_management-node:discover_products')")
@Operation(
summary = "Discover authorised products",
description = "Returns only the products the authenticated requester is authorised to discover, "
+ "narrowed by the supplied search criteria. Never returns products denied by policy, "
+ "even if they match the search criteria.",
security = {@SecurityRequirement(name = "bearerAuth")})
@ApiResponse(
responseCode = "200",
description = "Discovery response returned (possibly with an empty product list)",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductDiscoveryResponseDTO.class)))
@ApiResponse(responseCode = "400", description = "Invalid request body")
@ApiResponse(responseCode = "401", description = "Unauthorized")
@ApiResponse(responseCode = "403", description = "Forbidden")
@ApiResponse(responseCode = "500", description = "Internal server error")
public ProductDiscoveryResponseDTO discoverProducts(
@Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal,
HttpServletRequest request,
@Valid @RequestBody(required = false) ProductDiscoveryRequestDTO criteria) {
ProductDiscoveryRequestDTO effectiveCriteria = criteria != null
? criteria
: ProductDiscoveryRequestDTO.builder().build();
String organisation = RequestRejectionSupport.getOrganisationId(request);

log.info(
"Product discovery request clientId={} organisation={} name={} topic={} type={}",
principal.clientId(),
organisation,
effectiveCriteria.name(),
effectiveCriteria.topic(),
effectiveCriteria.type());

return productDiscoveryService.discover(
principal.clientId(),
organisation,
effectiveCriteria.name(),
effectiveCriteria.topic(),
effectiveCriteria.type());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
Expand Down Expand Up @@ -136,6 +138,56 @@ public ResponseEntity<ErrorResponse> handlePkiException(PkiException ex, WebRequ
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}

/**
* Handles {@code @Valid} request body validation failures (e.g. field size/blank
* constraints) with a 400, rather than falling through to the 500 handler below.
*
* @param ex the exception
* @param request the current request
* @return a ResponseEntity with a 400 error message
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
MethodArgumentNotValidException ex, WebRequest request) {

String errorId = generateErrorId();
log.debug(
"Request validation failed, error_id={}, path={}: {}",
errorId,
request.getDescription(false),
ex.getMessage());

ErrorResponse errorResponse =
new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request: " + ex.getMessage(), errorId);

return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

/**
* Handles malformed/unreadable request bodies (e.g. invalid JSON, wrong field types)
* with a 400, rather than falling through to the 500 handler below.
*
* @param ex the exception
* @param request the current request
* @return a ResponseEntity with a 400 error message
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException(
HttpMessageNotReadableException ex, WebRequest request) {

String errorId = generateErrorId();
log.debug(
"Malformed request body, error_id={}, path={}: {}",
errorId,
request.getDescription(false),
ex.getMessage());

ErrorResponse errorResponse =
new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request body", errorId);

return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

/**
* Handles RuntimeException.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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.model.dto;

import jakarta.validation.constraints.Size;
import lombok.Builder;

/**
* Search criteria for {@code POST /v1/product/discovery}. All fields are optional; an
* empty/absent field means "no filter" on that attribute. Filters only narrow the set of
* products the requester is authorised to discover - they cannot widen it.
*/
@Builder
public record ProductDiscoveryRequestDTO(
@Size(max = 50) String name, @Size(max = 150) String topic, @Size(max = 255) String type) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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.model.dto;

import java.util.ArrayList;
import java.util.List;
import lombok.Builder;

/**
* Response for {@code POST /v1/product/discovery}: the products the requester is authorised
* to discover, after policy filtering and search criteria are both applied. Empty (never
* null) when no products are authorised or none match the search criteria.
*/
@Builder
public record ProductDiscoveryResponseDTO(List<ProductDTO> products) {
public ProductDiscoveryResponseDTO {
products = products != null ? products : new ArrayList<>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;

import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product;

Expand Down Expand Up @@ -41,4 +43,54 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
*/
@Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + " WHERE o.producer.id IN :producers")
List<Product> findByProducerIds(List<Long> producers);

/**
* Discovery candidate query: products across all organisations matching the optional
* search filters (case-insensitive contains on name/topic, exact match on type name).
* A {@code null} filter matches everything for that attribute. Not organisation-scoped -
* policy (the PDP), not org membership, decides visibility for discovery. Uses a LEFT
* JOIN on productType (unlike the other queries here) since type is optional and a
* product without one must still be a candidate when no type filter is supplied.
*
* @param name optional case-insensitive contains filter on product name
* @param topic optional case-insensitive contains filter on product topic
* @param type optional case-insensitive exact filter on product type name
* @param pageable bounds the candidate set size (e.g. {@code PageRequest.of(0, maxCandidates)})
* @return candidate products matching the filters, bounded by {@code pageable}
*/
default List<Product> findDiscoveryCandidates(String name, String topic, String type, Pageable pageable) {
return findDiscoveryCandidatesByPattern(containsPattern(name), containsPattern(topic), type, pageable);
}

/**
* Backing query for {@link #findDiscoveryCandidates}. Takes pre-built, LIKE-escaped
* {@code %pattern%} strings (see {@link #containsPattern}) rather than raw filter values,
* so the LIKE wildcards {@code %}/{@code _} in caller-supplied input are matched
* literally, not interpreted as wildcards.
*/
@Query("SELECT p FROM Product p "
+ "LEFT JOIN FETCH p.productType t "
+ "WHERE (:namePattern IS NULL OR LOWER(p.name) LIKE LOWER(:namePattern) ESCAPE '\\') "
+ "AND (:topicPattern IS NULL OR LOWER(p.topic) LIKE LOWER(:topicPattern) ESCAPE '\\') "
+ "AND (:type IS NULL OR LOWER(t.name) = LOWER(:type)) "
+ "ORDER BY p.id")
List<Product> findDiscoveryCandidatesByPattern(
@Param("namePattern") String namePattern,
@Param("topicPattern") String topicPattern,
@Param("type") String type,
Pageable pageable);

/**
* Builds a {@code %value%} LIKE pattern with the LIKE metacharacters {@code \}, {@code %}
* and {@code _} in {@code value} escaped (backslash-escaped, matching the query's
* {@code ESCAPE '\'} clause), so a search value containing them is matched literally
* instead of as wildcards.
*/
private static String containsPattern(String value) {
if (value == null) {
return null;
}
String escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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.data;

import java.util.List;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO;

/**
* Runs product discovery: queries candidate products matching the requester's search
* criteria, then applies per-candidate PDP authorisation, keeping only the products the
* requester is authorised to discover.
*/
public interface ProductDiscoveryService {

/**
* Queries discovery candidates matching the given search criteria, then evaluates one
* PDP decision per candidate, keeping only the ALLOWed ones.
*
* @param clientId identity of the calling client
* @param organisation organisation the client belongs to, if known
* @param name optional case-insensitive contains filter on product name
* @param topic optional case-insensitive contains filter on product topic
* @param type optional case-insensitive exact filter on product type name
* @return the products the requester is authorised to discover, matching the criteria
*/
ProductDiscoveryResponseDTO discover(String clientId, String organisation, String name, String topic, String type);

/**
* Evaluates one PDP decision per candidate product and returns only the ALLOWed ones. A
* candidate is excluded (not the whole request failed) if the PDP denies it or the PDP
* call itself fails, so a partial PDP outage degrades results rather than the request.
*
* @param clientId identity of the calling client
* @param organisation organisation the client belongs to, if known
* @param candidates discovery candidate products to authorise
* @return the subset of candidates the PDP allows for this requester
*/
List<ProductDTO> filterAuthorised(String clientId, String organisation, List<ProductDTO> candidates);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,17 @@ public interface ProductService {
* @return a list of DataProviderDTO objects corresponding to the given producer IDs
*/
List<ProductDTO> getProductsByProducerIds(List<Long> producerIds);

/**
* Retrieves discovery candidate products across all organisations matching the optional
* search filters, bounded by the configured max-candidate limit. This is the pre-policy
* candidate set for {@code POST /v1/product/discovery}; authorisation is applied
* separately, per candidate, by the PDP.
*
* @param name optional case-insensitive contains filter on product name
* @param topic optional case-insensitive contains filter on product topic
* @param type optional case-insensitive exact filter on product type name
* @return candidate products matching the filters, bounded by the max-candidate limit
*/
List<ProductDTO> findDiscoveryCandidates(String name, String topic, String type);
}
Loading
Loading