Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bef3c29
fix(policy): return 400 for invalid request bodies instead of 500
Filip-sz-informed Aug 31, 2026
6812287
refactor(policy): expose organisation id lookup for reuse outside config
Filip-sz-informed Aug 31, 2026
8135d4c
feat(product-discovery): add policy-aware product discovery endpoint
Filip-sz-informed Aug 31, 2026
e0ff3cb
test(product-discovery): de-duplicate identical controller test bodies
Filip-sz-informed Aug 31, 2026
997f8e1
test(product-discovery): remove useless eq() around sole verify argument
Filip-sz-informed Aug 31, 2026
21a109e
fix(product-discovery): log request URI, not empty context path, on 400s
Filip-sz-informed Sep 1, 2026
2c47062
fix(product-discovery): fail fast on invalid max-candidates config
Filip-sz-informed Sep 1, 2026
d7141d9
fix(product-discovery): escape LIKE wildcards in name/topic filters
Filip-sz-informed Sep 1, 2026
43d36f9
refactor(product-discovery): use records for the discovery DTOs
Filip-sz-informed Sep 2, 2026
1bd80e2
test(product-discovery): resolve Sonar findings in ProductRepositoryTest
Filip-sz-informed Sep 2, 2026
4302cb0
feat(database): database structure and data change
nikan-negaresh-informed Sep 10, 2026
d830d2b
feat(database): database structure and data change
nikan-negaresh-informed Sep 11, 2026
a5b3519
feat(config): introduce organisation key and associated functionality
nikan-negaresh-informed Sep 11, 2026
9e3c16f
feat(config): introduce organisation key and associated functionality
nikan-negaresh-informed Sep 11, 2026
b74bde0
refactor(config): streamline organisation resolution and improve test…
nikan-negaresh-informed Sep 11, 2026
309688d
test(scope): improve attribute scope extraction logic and simplify se…
nikan-negaresh-informed Sep 11, 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 @@ -56,3 +56,6 @@ build/

### local-only openspec workspace
/openspec/

### vault unseal keys (local dev)
docker/vault/vault-keys.env
42 changes: 42 additions & 0 deletions docker/opa/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#
# 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.
#
services:
opa:
image: openpolicyagent/opa:1.20.2
container_name: opa
restart: unless-stopped

command:
- "run"
- "--server"
- "--addr=0.0.0.0:8181"
- "--diagnostic-addr=0.0.0.0:8282"
- "--log-level=${OPA_LOG_LEVEL:-info}"
- "--log-format=json"
- "--set=decision_logs.console=true"
- "/policies/policy.rego"

ports:
- "${OPA_PORT:-8181}:8181"
- "${OPA_DIAGNOSTIC_PORT:-8282}:8282"

volumes:
# Read-only: edit policy.rego on the host, then `docker compose restart opa`.
- ./policy.rego:/policies/policy.rego:ro

healthcheck:
# The image has no shell or curl, so health is probed with OPA's own binary
# against the diagnostic listener.
test:
- "CMD"
- "/opa"
- "eval"
- "--fail"
- 'http.send({"method":"get","url":"http://127.0.0.1:8282/health","raise_error":false}).status_code == 200'
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
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).
21 changes: 20 additions & 1 deletion docker/vault/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,28 @@ services:

volumes:
- vault-data:/vault/file
- ./config:/vault/config:ro
- ./vault/config:/vault/config:ro

command: vault server -config=/vault/config/vault.hcl

vault-unseal:
image: hashicorp/vault:1.16
container_name: vault-unseal
restart: unless-stopped
depends_on:
- vault

environment:
VAULT_ADDR: "http://vault:8200"

# Shamir keys on disk - local dev only. Copy vault-keys.env.example.
env_file:
- vault-keys.env

volumes:
- ./unseal.sh:/usr/local/bin/unseal.sh:ro

entrypoint: ["/usr/local/bin/unseal.sh"]

volumes:
vault-data:
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.
60 changes: 33 additions & 27 deletions docs/DATABASE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ 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
POLICY_ATTRIBUTE_DEFINITION ||--o{ POLICY_ATTRIBUTE_DEFINITION_SCOPE : "bound via"
POLICY_ATTRIBUTE_SCOPE ||--o{ POLICY_ATTRIBUTE_DEFINITION_SCOPE : "bound via"
POLICY_ATTRIBUTE_DEFINITION_SCOPE ||--o{ POLICY_ATTRIBUTE_VALUE : has

ORGANISATION {
BIGSERIAL id PK
VARCHAR name
VARCHAR organisation_key UK
BOOLEAN certificate_automation_enabled
}
PRODUCER {
Expand Down Expand Up @@ -119,13 +120,13 @@ erDiagram
TIMESTAMP event_time
VARCHAR performed_by
}
ATTRIBUTE_SCOPE {
POLICY_ATTRIBUTE_SCOPE {
BIGSERIAL id PK
VARCHAR code
VARCHAR table_name
VARCHAR description
}
ATTRIBUTE_DEFINITION {
POLICY_ATTRIBUTE_DEFINITION {
BIGSERIAL id PK
VARCHAR namespace
VARCHAR name
Expand All @@ -143,7 +144,7 @@ erDiagram
TIMESTAMP updated_at
VARCHAR updated_by
}
ATTRIBUTE_DEFINITION_SCOPE {
POLICY_ATTRIBUTE_DEFINITION_SCOPE {
BIGSERIAL id PK
BIGINT attribute_definition_id FK
BIGINT attribute_scope_id FK
Expand All @@ -155,7 +156,7 @@ erDiagram
TIMESTAMP updated_at
VARCHAR updated_by
}
ATTRIBUTE_VALUE {
POLICY_ATTRIBUTE_VALUE {
BIGSERIAL id PK
BIGINT attribute_definition_scope_id FK
BIGINT entity_id
Expand All @@ -178,10 +179,15 @@ Represents an organisation that owns Producers and Consumers.
Columns:
- `id` BIGSERIAL, primary key
- `name` VARCHAR(150), not null
- `organisation_key` VARCHAR(50), not null — stable, human-readable identifier for the organisation (e.g. `ENV`, `BCC`, `HEG`), so callers can address an organisation without depending on ids that differ between environments
- `certificate_automation_enabled` BOOLEAN, not null, default TRUE

Indexes and constraints:
- UNIQUE on `organisation_key` (`uq_organisation__organisation_key`)

Usage:
- Parent entity for `producer`, `consumer`, and `organisation_certificate`.
- `organisation_key` is exposed as `organisation.key` on the producer and consumer configuration APIs.

---

Expand Down Expand Up @@ -334,25 +340,25 @@ Usage:

---

### attribute_scope
Which core entity types may carry dynamic policy attributes, and the table `attribute_value.entity_id` resolves against for that scope.
### policy_attribute_scope
Which core entity types may carry dynamic policy attributes, and the table `policy_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
- `table_name` VARCHAR(150), not null — the table `policy_attribute_value.entity_id` is a row id in, for this scope
- `description` VARCHAR(500), nullable

Constraints:
- UNIQUE on `code` (`uq_attribute_scope__code`)
- UNIQUE on `code` (`uq_policy_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.
- Referenced by `policy_attribute_definition_scope` to say which scopes an attribute definition applies to.

---

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

Columns:
Expand All @@ -374,20 +380,20 @@ Columns:
- `updated_by` VARCHAR(255), nullable

Constraints:
- UNIQUE on (`namespace`, `name`) (`uq_attribute_definition__namespace_name`)
- UNIQUE on (`namespace`, `name`) (`uq_policy_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.
### policy_attribute_definition_scope
Which scopes a `policy_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)`
- `attribute_definition_id` BIGINT, not null, foreign key → `policy_attribute_definition(id)`
- `attribute_scope_id` BIGINT, not null, foreign key → `policy_attribute_scope(id)`
- `required` BOOLEAN, not null, default FALSE
- `default_value` JSONB, nullable
- `is_deleted` BOOLEAN, not null, default FALSE
Expand All @@ -397,22 +403,22 @@ Columns:
- `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`)
- UNIQUE on (`attribute_definition_id`, `attribute_scope_id`) (`uq_policy_attribute_definition_scope__definition_scope`)
- Index on `attribute_definition_id` (`idx_policy_attribute_definition_scope__attribute_definition_id`)
- Index on `attribute_scope_id` (`idx_policy_attribute_definition_scope__attribute_scope_id`)

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

---

### attribute_value
### policy_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.
- `attribute_definition_scope_id` BIGINT, not null, foreign key → `policy_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 `policy_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()`
Expand All @@ -421,11 +427,11 @@ Columns:
- `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)
- Index on `entity_id` (`idx_policy_attribute_value__entity_id`)
- Partial UNIQUE index on (`attribute_definition_scope_id`, `entity_id`, `value`) WHERE `is_deleted = FALSE` (`uq_policy_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 `policy_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.
- `trg_organisation_policy_attribute_value_soft_delete`, `trg_consumer_policy_attribute_value_soft_delete`, `trg_producer_policy_attribute_value_soft_delete`, `trg_product_policy_attribute_value_soft_delete`, `trg_product_consumer_policy_attribute_value_soft_delete` — one `AFTER DELETE` trigger per owning table (`organisation`, `consumer`, `producer`, `product`, `product_consumer`), all calling the shared function `fn_policy_attribute_value_soft_delete_on_entity_delete()`. When a row in one of those tables is deleted, every live (`is_deleted = FALSE`) `policy_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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, Abstra
private static final String CLAIM_AZP = "azp";
private static final String CLAIM_CLIENT_ID = "client_id";
private static final String CLAIM_RESOURCE_ACCESS = "resource_access";
private static final String CLAIM_ORGANISATION = "organisation";
private static final String CLAIM_ROLES = "roles";

// Constants for role prefixes and default values
private static final String ROLE_PREFIX = "ROLE_";
private static final String UNKNOWN_CLIENT = "unknown";
private static final String UNKNOWN_ORGANISATION = "unknown_organisation";
private static final String RESOURCE_ROLE_SEPARATOR = ":";

// Form data keys
Expand Down Expand Up @@ -144,8 +146,11 @@ public AbstractAuthenticationToken convert(Jwt jwt) {
subject = jwt.getSubject(); // Fallback to JWT subject if not in introspection data
}

// Create custom principal with subject and clientId
EnhancedPrincipal principal = new EnhancedPrincipal(subject, tokenClientId);
// Extract the organisation the token was issued for
String organisation = extractOrganisationFromIntrospection(introspectionData, jwt);

// Create custom principal with subject, clientId and organisation
EnhancedPrincipal principal = new EnhancedPrincipal(subject, tokenClientId, organisation);

log.debug("Successfully created authentication token for client ID: {}", tokenClientId);

Expand All @@ -160,7 +165,8 @@ public AbstractAuthenticationToken convert(Jwt jwt) {
e.getMessage());

Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId);
EnhancedPrincipal principal =
new EnhancedPrincipal(jwt.getSubject(), tokenClientId, extractOrganisation(jwt));
return new CustomJwtAuthenticationToken(jwt, authorities, principal);
} catch (ResourceAccessParsingException e) {
// If resource access parsing fails, log the error and fall back to JWT parsing
Expand All @@ -171,15 +177,17 @@ public AbstractAuthenticationToken convert(Jwt jwt) {
e.getMessage());

Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId);
EnhancedPrincipal principal =
new EnhancedPrincipal(jwt.getSubject(), tokenClientId, extractOrganisation(jwt));
return new CustomJwtAuthenticationToken(jwt, authorities, principal);
} catch (Exception e) {
// For any other unexpected exceptions
String tokenClientId = extractClientId(jwt);
log.error("Unexpected error during JWT conversion for client ID: {}", tokenClientId, e);

Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId);
EnhancedPrincipal principal =
new EnhancedPrincipal(jwt.getSubject(), tokenClientId, extractOrganisation(jwt));
return new CustomJwtAuthenticationToken(jwt, authorities, principal);
}
}
Expand All @@ -192,15 +200,51 @@ public AbstractAuthenticationToken convert(Jwt jwt) {
* @return A non-null client ID (either primary, fallback, or "unknown")
*/
private String getEffectiveClientId(String primaryId, String fallbackId) {
if (primaryId != null && !primaryId.isEmpty()) {
return primaryId;
return getEffectiveValue(primaryId, fallbackId, UNKNOWN_CLIENT);
}

/**
* Returns the first of two candidate values that is neither null nor empty, or the
* supplied default when neither is usable.
*
* @param primary The preferred value
* @param fallback The value to use when primary is null or empty
* @param defaultValue The value to use when neither candidate is usable
* @return A non-null value
*/
private String getEffectiveValue(String primary, String fallback, String defaultValue) {
if (primary != null && !primary.isEmpty()) {
return primary;
}

if (fallbackId != null && !fallbackId.isEmpty()) {
return fallbackId;
if (fallback != null && !fallback.isEmpty()) {
return fallback;
}

return UNKNOWN_CLIENT;
return defaultValue;
}

/**
* Extract the organisation from the JWT's "organisation" claim.
* Returns "unknown_organisation" when the claim is absent or empty, so the principal
* always carries a usable value.
*/
private String extractOrganisation(Jwt jwt) {
return getEffectiveValue(jwt.getClaimAsString(CLAIM_ORGANISATION), null, UNKNOWN_ORGANISATION);
}

/**
* Extract the organisation from introspection data, falling back to the JWT's own
* "organisation" claim when introspection does not carry one - introspection is the more
* authoritative source, but an older authorisation server may not echo the claim back.
*
* @param jwtToken The data from the introspection endpoint
* @param jwt The JWT the introspection was performed for
* @return The organisation, or "unknown_organisation" when neither source has one
*/
private String extractOrganisationFromIntrospection(JwtToken jwtToken, Jwt jwt) {
return getEffectiveValue(
jwtToken.getOrganisation(), jwt.getClaimAsString(CLAIM_ORGANISATION), UNKNOWN_ORGANISATION);
}

/**
Expand Down
Loading
Loading