Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b02ab70
feat(database): add attribute schema tables and soft-delete triggers
Filip-sz-informed Sep 4, 2026
354467d
test(persistency): add Testcontainers Postgres base for repository tests
Filip-sz-informed Sep 4, 2026
b5f8661
feat(persistency): add AttributeScope entity and repository
Filip-sz-informed Sep 4, 2026
15ef690
feat(persistency): add AttributeDefinition entity and repository
Filip-sz-informed Sep 4, 2026
7ed8515
feat(persistency): add AttributeDefinitionScope entity and repository
Filip-sz-informed Sep 4, 2026
36eb44f
feat(persistency): add AttributeValue entity and repository
Filip-sz-informed Sep 4, 2026
2cd05b0
test(persistency): cover soft-delete triggers for attribute_value
Filip-sz-informed Sep 4, 2026
8ad5a33
docs(schema): document policy attribute schema tables
Filip-sz-informed Sep 4, 2026
5c4809f
fix(persistency): start the shared Postgres container eagerly, not vi…
Filip-sz-informed Sep 4, 2026
ed6b230
refactor(persistency): extract shared audit columns into AttributeAud…
Filip-sz-informed Sep 4, 2026
2c0e145
chore(deps): bump bundled Tomcat to 10.1.59 to resolve CRITICAL CVEs
Filip-sz-informed Sep 4, 2026
1cbff6e
feat(filter): add closed filter DSL for dynamic config filtering
Filip-sz-informed Sep 4, 2026
3bd49ef
feat(filter): add producer/consumer resource attribute registry
Filip-sz-informed Sep 4, 2026
d00ae3c
feat(filter): add SpecificationPredicateCompiler for producer/consume…
Filip-sz-informed Sep 4, 2026
e4a7c7f
feat(config): wire Specification filtering through repository and ser…
Filip-sz-informed Sep 4, 2026
ea68b44
feat(config): wire optional filter query parameter into configuration…
Filip-sz-informed Sep 4, 2026
e37586a
test(config): add end-to-end filtering coverage against real Postgres
Filip-sz-informed Sep 4, 2026
b6b8e9a
refactor(filter): drop unused FilterNode.Literal and tidy compiler ge…
Filip-sz-informed Sep 4, 2026
6084620
fix(config): fix code-review findings in dynamic-config-filtering
Filip-sz-informed Sep 4, 2026
99c1e8a
fix(config): use compareTo for BigDecimal zero-validity check
Filip-sz-informed Sep 4, 2026
f2365c6
fix(config): resolve 20 SonarCloud code smells on PR #69
Filip-sz-informed Sep 4, 2026
d836ed7
test(config): fix field-shadowing smell and close compiler coverage gaps
Filip-sz-informed Sep 4, 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
148 changes: 148 additions & 0 deletions docs/DATABASE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ erDiagram
CONSUMER ||--o{ PRODUCT_CONSUMER : consumes
PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has
PRODUCT_TYPE ||--o{ PRODUCT : categorizes
ATTRIBUTE_DEFINITION ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via"
ATTRIBUTE_SCOPE ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via"
ATTRIBUTE_DEFINITION_SCOPE ||--o{ ATTRIBUTE_VALUE : has

ORGANISATION {
BIGSERIAL id PK
Expand Down Expand Up @@ -116,6 +119,53 @@ erDiagram
TIMESTAMP event_time
VARCHAR performed_by
}
ATTRIBUTE_SCOPE {
BIGSERIAL id PK
VARCHAR code
VARCHAR table_name
VARCHAR description
}
ATTRIBUTE_DEFINITION {
BIGSERIAL id PK
VARCHAR namespace
VARCHAR name
VARCHAR display_name
TEXT description
VARCHAR data_type
BOOLEAN multi_valued
JSONB allowed_values
VARCHAR validation_pattern
JSONB classification
BOOLEAN sensitive
BOOLEAN is_deleted
TIMESTAMP created_at
VARCHAR created_by
TIMESTAMP updated_at
VARCHAR updated_by
}
ATTRIBUTE_DEFINITION_SCOPE {
BIGSERIAL id PK
BIGINT attribute_definition_id FK
BIGINT attribute_scope_id FK
BOOLEAN required
JSONB default_value
BOOLEAN is_deleted
TIMESTAMP created_at
VARCHAR created_by
TIMESTAMP updated_at
VARCHAR updated_by
}
ATTRIBUTE_VALUE {
BIGSERIAL id PK
BIGINT attribute_definition_scope_id FK
BIGINT entity_id
JSONB value
BOOLEAN is_deleted
TIMESTAMP created_at
VARCHAR created_by
TIMESTAMP updated_at
VARCHAR updated_by
}
```

---
Expand Down Expand Up @@ -284,6 +334,104 @@ Usage:

---

### attribute_scope
Which core entity types may carry dynamic policy attributes, and the table `attribute_value.entity_id` resolves against for that scope.

Columns:
- `id` BIGSERIAL, primary key
- `code` VARCHAR(50), not null — unique scope identifier (e.g. `PRODUCT`)
- `table_name` VARCHAR(150), not null — the table `attribute_value.entity_id` is a row id in, for this scope
- `description` VARCHAR(500), nullable

Constraints:
- UNIQUE on `code` (`uq_attribute_scope__code`)

Usage:
- Seeded by migration with one row per core entity type: `ORGANISATION` (`organisation`), `CONSUMER` (`consumer`), `PRODUCER` (`producer`), `PRODUCT` (`product`), `SUBSCRIPTION` (`product_consumer`).
- Referenced by `attribute_definition_scope` to say which scopes an attribute definition applies to.

---

### attribute_definition
Vocabulary of policy attributes: name, type, and validation metadata, independent of which scope(s) it applies to.

Columns:
- `id` BIGSERIAL, primary key
- `namespace` VARCHAR(150), not null
- `name` VARCHAR(150), not null
- `display_name` VARCHAR(255), nullable
- `description` TEXT, not null
- `data_type` VARCHAR(50), not null
- `multi_valued` BOOLEAN, not null, default FALSE
- `allowed_values` JSONB, nullable
- `validation_pattern` VARCHAR(500), nullable
- `classification` JSONB, nullable
- `sensitive` BOOLEAN, not null, default FALSE
- `is_deleted` BOOLEAN, not null, default FALSE
- `created_at` TIMESTAMP, not null, default `now()`
- `created_by` VARCHAR(255), not null
- `updated_at` TIMESTAMP, nullable
- `updated_by` VARCHAR(255), nullable

Constraints:
- UNIQUE on (`namespace`, `name`) (`uq_attribute_definition__namespace_name`)

Usage:
- Defines the shape of a policy attribute (e.g. data type, whether it can hold multiple values, allowed values, sensitivity) independently of where it can be attached.

---

### attribute_definition_scope
Which scopes an `attribute_definition` is valid on, whether required there, and its default value.

Columns:
- `id` BIGSERIAL, primary key
- `attribute_definition_id` BIGINT, not null, foreign key → `attribute_definition(id)`
- `attribute_scope_id` BIGINT, not null, foreign key → `attribute_scope(id)`
- `required` BOOLEAN, not null, default FALSE
- `default_value` JSONB, nullable
- `is_deleted` BOOLEAN, not null, default FALSE
- `created_at` TIMESTAMP, not null, default `now()`
- `created_by` VARCHAR(255), not null
- `updated_at` TIMESTAMP, nullable
- `updated_by` VARCHAR(255), nullable

Constraints:
- UNIQUE on (`attribute_definition_id`, `attribute_scope_id`) (`uq_attribute_definition_scope__definition_scope`)
- Index on `attribute_definition_id` (`idx_attribute_definition_scope__attribute_definition_id`)
- Index on `attribute_scope_id` (`idx_attribute_definition_scope__attribute_scope_id`)

Usage:
- Binds a definition to one or more scopes, controlling per-scope requiredness and default.

---

### attribute_value
Actual policy attribute values recorded against a specific entity.

Columns:
- `id` BIGSERIAL, primary key
- `attribute_definition_scope_id` BIGINT, not null, foreign key → `attribute_definition_scope(id)`
- `entity_id` BIGINT, not null — polymorphic reference: the primary key of the row in the table named by the value's `attribute_scope.table_name`. Not a declared foreign key, since the target table varies by scope.
- `value` JSONB, not null
- `is_deleted` BOOLEAN, not null, default FALSE
- `created_at` TIMESTAMP, not null, default `now()`
- `created_by` VARCHAR(255), not null
- `updated_at` TIMESTAMP, nullable
- `updated_by` VARCHAR(255), nullable

Constraints:
- Index on `entity_id` (`idx_attribute_value__entity_id`)
- Partial UNIQUE index on (`attribute_definition_scope_id`, `entity_id`, `value`) WHERE `is_deleted = FALSE` (`uq_attr_value_live`) — an idempotency guard against persisting an exact-duplicate live value; it does not by itself enforce "one live value per entity" for single-valued attributes (that check spans `attribute_definition.multi_valued` and is left to the service layer that writes these rows)

Soft-delete triggers:
- `trg_organisation_attribute_value_soft_delete`, `trg_consumer_attribute_value_soft_delete`, `trg_producer_attribute_value_soft_delete`, `trg_product_attribute_value_soft_delete`, `trg_product_consumer_attribute_value_soft_delete` — one `AFTER DELETE` trigger per owning table (`organisation`, `consumer`, `producer`, `product`, `product_consumer`), all calling the shared function `fn_attribute_value_soft_delete_on_entity_delete()`. When a row in one of those tables is deleted, every live (`is_deleted = FALSE`) `attribute_value` row scoped to that table and entity id is set `is_deleted = TRUE` rather than deleted or left orphaned.

Usage:
- Stores the actual attribute values used to build the OPA data bundle for policy decisions, keyed by which entity (organisation, consumer, producer, product, or subscription) they describe.

---

## Migration Notes
- Schema is versioned and applied with Flyway on application startup.
- Foreign keys enforce referential integrity among core entities.
Expand Down
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
<bouncycastle.version>1.84</bouncycastle.version>
<spring-security.version>6.5.9</spring-security.version>
<httpcore5.version>5.4.3</httpcore5.version>
<tomcat.version>10.1.59</tomcat.version>
<sonar.coverage.exclusions>**/config/**,
**/dto/**,
**/entity/**,
Expand Down Expand Up @@ -197,6 +198,16 @@
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import uk.gov.dbt.ndtp.ia.node.management.filter.FilterNode;
import uk.gov.dbt.ndtp.ia.node.management.filter.FilterRequestParser;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerConfigDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO;
import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal;
Expand All @@ -33,9 +35,12 @@
public class ConfigurationController {

private final ConfigurationProvider configurationProvider;
private final FilterRequestParser filterRequestParser;

public ConfigurationController(ConfigurationProvider configurationProvider) {
public ConfigurationController(
ConfigurationProvider configurationProvider, FilterRequestParser filterRequestParser) {
this.configurationProvider = configurationProvider;
this.filterRequestParser = filterRequestParser;
}

@GetMapping("/producer")
Expand All @@ -61,10 +66,18 @@ public ProducerConfigDTO getProducerConfigurations(
@Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal,
@Parameter(name = "producer_id", description = "Optional Producer identifier to filter configuration")
@RequestParam(value = "producer_id", required = false)
Long producerId) {
Long producerId,
@Parameter(
name = "filter",
description =
"Optional JSON-encoded filter (FilterNode: a Comparison or a Group of them) narrowing"
+ " the returned producers, evaluated by the database alongside producer_id")
@RequestParam(value = "filter", required = false)
String filter) {
log.info("Preparing Federator Producer Config for producer {}", producerId);
Optional<FilterNode> filterNode = filterRequestParser.parse(filter);
return configurationProvider.getProducerConfigByClientId(
principal.clientId(), producerId != null ? Optional.of(producerId) : Optional.empty());
principal.clientId(), producerId != null ? Optional.of(producerId) : Optional.empty(), filterNode);
}

@GetMapping("/consumer")
Expand All @@ -90,10 +103,18 @@ public ConsumerConfigDTO getConsumerConfigurations(
@Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal,
@Parameter(name = "consumer_id", description = "Optional Consumer identifier to filter configuration")
@RequestParam(value = "consumer_id", required = false)
Long consumerId) {
Long consumerId,
@Parameter(
name = "filter",
description =
"Optional JSON-encoded filter (FilterNode: a Comparison or a Group of them) narrowing"
+ " the returned consumers, evaluated by the database alongside consumer_id")
@RequestParam(value = "filter", required = false)
String filter) {
log.info("Preparing Consumer Config for client Id {} and Consumer {}", principal.clientId(), consumerId);

Optional<FilterNode> filterNode = filterRequestParser.parse(filter);
return configurationProvider.getConsumerConfigByClientId(
principal.clientId(), consumerId != null ? Optional.of(consumerId) : Optional.empty());
principal.clientId(), consumerId != null ? Optional.of(consumerId) : Optional.empty(), filterNode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException;
import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse;
import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException;
import uk.gov.dbt.ndtp.ia.node.management.filter.FilterCompilationException;

/**
* Global exception handler for the application.
Expand Down Expand Up @@ -136,6 +137,47 @@ public ResponseEntity<ErrorResponse> handlePkiException(PkiException ex, WebRequ
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}

/**
* Handles FilterCompilationException raised while validating/compiling a caller-supplied
* configuration filter. {@code Origin.REQUEST} - a malformed or unknown-attribute filter -
* maps to 400 with the exception's own message, which by construction names only the
* caller-supplied attribute, never an internal table/column name. {@code Origin.POLICY} - an
* attribute definition or stored value this system's own configuration cannot honour - maps
* to 500 with a generic message, consistent with the other 500 handlers below: the detail
* stays server-side, in the log.
*
* @param ex the exception
* @param request the current request
* @return a ResponseEntity with a 400 or 500 error message depending on {@link
* FilterCompilationException#origin()}
*/
@ExceptionHandler(FilterCompilationException.class)
public ResponseEntity<ErrorResponse> handleFilterCompilationException(
FilterCompilationException ex, WebRequest request) {

String errorId = generateErrorId();

if (ex.origin() == FilterCompilationException.Origin.REQUEST) {
log.debug(
"Rejected caller filter, error_id={}, path={}: {}",
errorId,
request.getDescription(false),
ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage(), errorId);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

log.error(
"Filter attribute configuration defect, error_id={}, path={}: {}",
errorId,
request.getDescription(false),
ex.getMessage(),
ex);
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(), "An internal server error occurred", errorId);
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}

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

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import java.util.Locale;

/** How the children of a {@link FilterNode.Group} combine. */
public enum Combinator {
AND("and"),
OR("or");

private final String wireName;

Combinator(String wireName) {
this.wireName = wireName;
}

@JsonValue
public String wireName() {
return wireName;
}

@JsonCreator
public static Combinator fromWireName(String value) {
String normalised = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
return Arrays.stream(values())
.filter(combinator -> combinator.wireName.equals(normalised))
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException("Unsupported combinator '" + value + "'; supported: [and, or]"));
}
}
Loading
Loading