Skip to content

Feature/dpav 3019 product subscription with policy-set terms - #75

Merged
nikan-negaresh-informed merged 13 commits into
developfrom
feature/DPAV-3019
Sep 24, 2026
Merged

nikan-negaresh-informed merged 13 commits into
developfrom
feature/DPAV-3019

Conversation

@nikan-negaresh-informed

@nikan-negaresh-informed nikan-negaresh-informed commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

feat(subscription): product subscription with policy-set terms

Sensitive Credential Checks

  • [ X] As the author of these changes, I have checked for any sensitive credentials prior to this review being requested.
  • As a reviewer of these changes, I have checked for any sensitive credentials prior to approving this merge.

Motivation and Context

DPAV-3018 gave a federator the ability to find a product and read one. It could not yet
take one. This adds the third endpoint, POST /api/v1/product/subscribe, which records a grant
of a product to one of the calling organisation's consumers.

Subscription is the first product action that writes, and the first whose policy decision is
about terms rather than visibility. That makes two things new here:

  1. The rule needs facts about a specific product. Discovery and view decide about a kind of
    thing and let the row filter answer per product in SQL; neither reads an entity in Rego. How
    long a subscription may run, though, depends on what the data is — so the product named in the
    body has to be loaded and sent to the PDP. The mechanism for that (@Policy(loadResource = true)
    and the loader/extractor pair) is introduced in this PR.
  2. Policy sets terms the service applies. The validity recorded on the grant is decided by the
    rule from the caller's purpose and the product's attributes. No validity arithmetic lives in
    Java.

Description

The endpoint

POST /api/v1/product/subscribe, role management-node:product_subscribe, annotated
@Policy(resource = "product", action = "subscribe", details = ProductSubscriptionPolicyDecisionDetails.class, loadResource = true).
The handler receives the decision and passes it to the service rather than letting the service ask
for a second one — the terms applied are the ones the request was allowed on.

ProductSubscriptionServiceImpl answers four questions in order, each able to end the request:
which consumer takes the subscription, does the product exist, does the grant already exist, and
how long is it valid for. Only the last needs the decision.

Choosing the consumer

consumerId is optional, because most organisations run one consumer or nominate a default;
naming one is for organisations that run several.

Step Condition Outcome
1 consumerId given used, after checking it belongs to the caller's organisation
2 the organisation has a consumer flagged is_default used
3 the organisation has exactly one consumer used; nothing to disambiguate
4 several consumers, no default 400, asking for consumer_id
5 no consumers 400

Step 1's ownership check is the one that matters for security: without it a consumer id would be a
way to subscribe somebody else's consumer.

Schema

Migration Change
V20260922100000 consumer.is_default — NOT NULL DEFAULT FALSE, plus a partial unique index on (org_id) WHERE is_default = TRUE
V20260922110000 (sample) gives ENV a default consumer, so both resolution paths are testable without editing the database
V20260922120000 drops the five policy-attribute soft-delete triggers and their shared function

The index is partial on purpose: an organisation has at most one default and may have none,
while any number of non-default consumers stays legal. A plain unique index on org_id would have
allowed only one consumer per organisation. The column is is_default rather than default
because DEFAULT is a reserved word in SQL — an unquoted reference would be a syntax error rather
than a missing-column error.

V20260922120000 is a fix, not a feature: the trigger function resolved its table names
unqualified, so PL/pgSQL resolved them against the caller's search_path, and a session
without the schema on its path could not delete from any owning table at all. Dropping it leaves a
consequence that is documented rather than fixed here — entity_id is polymorphic with no foreign
key, so nothing now soft-deletes an entity's attribute values when the entity is removed.
Whatever deletes one of those entities must do it in the same transaction. Recorded in
docs/DATABASE_SCHEMA.md, and PolicyAttributeValueSoftDeleteTriggerTest is updated to assert the
absence rather than the behaviour.

Status codes, and the two that are easy to confuse

Code When
200 grant recorded, and in force
400 invalid body, no consumer, several consumers and none named, or a consumer of another organisation
403 (role) token lacks product_subscribe — no reasons
403 (policy) policy refused — with reasons
404 no such product
409 that consumer already holds that product

403 is "you may not"; 409 is "that is already done". SubscriptionRejectedException carries
a Reason that GlobalExceptionHandler maps to the status, so the mapping is in one place.
Uniqueness is per product and consumer (uq_product_consumer_pair): one organisation may hold
the same product on several of its consumers — that is how it feeds the same data to more than one
system — while the same consumer cannot hold it twice. The duplicate is checked before insert so
the caller gets a message naming what already exists rather than a constraint violation.

The rule: policies.product.subscribe, 2.0.0 → 4.0.0

3.0.0 — validity_days, the term worked out from the product as well as the caller.
max_validity_days is the ceiling the caller's purpose allows and says nothing about the data.
validity_days is the grant actually made: the lower of that ceiling and what the product permits.

Product Ceiling
directly identifiable 30 days
experimental, provisional or superseded 30 days
pseudonymised 90 days
anything else (anonymised, non-personal, validated) 365 days
no attributes recorded at all 30 days

The last line is the deliberate one: an unclassified product is not evidence that it is safe to
hold for a year. The product can only shorten a grant, never extend one — a caller entitled to
a year of a validated, anonymised feed is not entitled to a year of a provisional, directly
identifiable one.

4.0.0 — the jurisdiction rule. An organisation whose jurisdictions include Wales may not
subscribe to a product whose type is topic, whatever else it is entitled to:

403  {"message": "Access denied by policy",
      "reasons": ["jurisdiction.topic_not_permitted:Wales Juristiction not allowed to access topics"]}

The code:subject form is the convention the product rules already use for a parameterised
refusal: the code before the colon is the stable key audit and logs match on, and the sentence
after it reaches the caller as written. The wording, including the spelling Juristiction, is
as agreed
— a reviewer "correcting" it silently breaks the tests that assert on it.

It is enforced entirely in Rego; no Java change was needed. The product's type already reaches
the PDP as input.resource.fields.type (from product_type.name), and any reason not prefixed
dispatch. or policy. passes through PolicyDecision.callerReasons() to the caller.

Three properties to review it against, all intended:

  • it reads the caller's remit, not the product's coverage — a product about England is refused
    just the same if the caller covers Wales;
  • Wales alongside other nations still counts — the sample organisation ENV (England and
    Wales) is caught; HEG and BCC are not;
  • it reads the product's type, not its owner — ENV is refused its own topic products.

Because V20251013135858 backfilled every product existing at that point to type topic, eight of
the seventeen sample products are topics, so this puts about half the sample catalogue out of
ENV's reach. That is the rule working, and it is why several scenarios in the test specification
moved onto file products.

Scope: the rule is on the subscribe rule alone. Discover and view are unchanged — ENV can
still find and read topic products, it just cannot subscribe to them.

Documentation

  • docs/tests/products/subscribe.md — new, the test requirement specification for the manual
    testing team: §3 covers subscription generally (S1–S16), §4 the jurisdiction rule (W1–W9), and
    §4.1 is a product → type table, needed because the type appears in no API response.
  • docs/POLICY_ENFORCEMENT.md — Loading the entity a decision is about, the subscribe refusal
    table, and the loadResource column.
  • docs/DATABASE_SCHEMA.md — consumer.is_default and the trigger removal with its consequence.
  • docs/AUTHENTICATION_REQUIREMENTS.md, docker/opa/policy_sample_stories.md,
    docs/tests/products/README.md — updated to match.

How Has This Been Tested?

Automated — all green

Suite Command Result
Java unit tests ./mvnw test 823 run, 0 failures, 0 errors, 0 skipped (86 classes)
Lint (CI's gating job) ./mvnw spotless:check clean
Rego policy tests opa test /p 130/130 pass
Rego static check opa check --strict /p clean
Rego formatting opa fmt --diff /p no diff

OPA commands run against the host policy tree, since the image has no shell:

cd docker/opa
docker run --rm -v "$PWD/policies:/p:ro" openpolicyagent/opa:1.20.2 check --strict /p
docker run --rm -v "$PWD/policies:/p:ro" openpolicyagent/opa:1.20.2 test /p
docker run --rm -v "$PWD/policies:/p:ro" openpolicyagent/opa:1.20.2 fmt --diff /p

Tests follow the repo convention: plain JUnit 5 with @ExtendWith(MockitoExtension.class), no
@SpringBootTest and no Testcontainers.

New and changed tests in this delta

Java

  • ProductSubscriptionServiceImplTest — new, 15 tests: every consumer-resolution path, the
    ownership check, the duplicate grant, and that the validity written is the one policy decided.
  • ProductPolicyResourceLoaderTest — new, 5 tests: that an unknown or non-numeric id loads
    nothing rather than failing the decision.
  • ProductControllerTest, PolicyInputFactoryTest, PolicyAnnotationValidatorTest,
    PolicyDecisionSerializationTest — extended for loadResource, resource.fields and the new
    response shape.
  • PolicyAttributeValueSoftDeleteTriggerTest — now asserts the trigger's absence.

Rego — subscribe_test.rego goes from 10 to 24 cases. Six cover validity_days (each
product ceiling, the no-attributes case, and that the product can only shorten). Eight cover the
jurisdiction rule: a Welsh organisation refused a topic product; the same caller allowed the same
product as a file; non-Welsh organisations allowed topic products; the caller's own topic product
still refused; a product with no type recorded allowed — which is what catches a rule written
as not file rather than is topic; both reasons reported together when a schedule type is also
wrong; the terms still returned with the refusal; and a non-string type not caught.
dispatch_test.rego follows the version bump.

Manual verification against a running PDP

OPA restarted (docker compose restart opa in docker/opa) and each case queried directly at
/v1/data/dispatch/decision. Provenance reads policies.product.subscribe/4.0.0, confirming the
new module answered rather than a cached rule:

Caller Product type Result
ENV (England, Wales) topic deny, the jurisdiction reason, terms still returned
ENV file allow, validity_days 30
ENV none recorded allow, validity_days 90
HEG (England, Scotland) topic allow
ENV + an impermissible schedule type topic deny, both reasons, sorted

How this affects other areas

  • The PDP fails closed. With OPA stopped every annotated endpoint returns 403; expected, not
    a defect. With application.opa.enabled=false no decision is taken at all, and the service falls
    back to a deliberately short 30-day validity — a grant nobody authorised should expire soon. Note
    that such a grant is in force and is indistinguishable from one policy allowed, which is the
    reason not to run with policy off anywhere that matters.
  • Discover and view are untouched behaviourally. The shared files that change
    (PolicyInput, PolicyInputFactory, PolicyResource, PolicyTarget, Policy,
    PolicyEnforcementInterceptor) are additive: loadResource defaults to false, so an endpoint
    that does not ask for an entity gets exactly what it got before.
  • Token claim reminder for anyone testing by hand: the organisation claim must be the
    database key (ENV), not the client id (FEDERATOR_ENV), or no organisation row matches,
    attributes come back empty, and every product rule refuses with organisation.missing.

Screenshots (if appropriate):

n/a — no UI in this repository.


Checklist:

  • It contains only changes required by issue (does not contain other PR) — against
    feature/DPAV-3018 this is the subscription work alone. If retargeted at develop this is
    no longer true
    , since it would then carry DPAV-3018's discovery and view work with it.
  • Includes link to an issue (if apply) — DPAV-3019
  • I have added tests to cover my changes — 2 new Java test classes (20 tests) plus 14 new Rego
    cases; full suite 823 Java tests and 130 Rego tests, all passing.

- Deleted `ProductService`, `ProductServiceImpl`, and associated test classes (`ProductServiceImplTest`, `ProductRepositoryTest`).
- Updated `ProductControllerTest` to use `ProductDiscoveryService` for discovery scenarios.
- Cleaned up redundant methods and DTOs, reducing unnecessary maintenance overhead.
- Updated `Product` entity to include a description field for more detailed search and discovery.
…andling

- Migrated `ProductDiscoveryServiceImpl` to a modular design, introducing search planning, criteria validation, repository execution, and assembly components.
- Enhanced policy enforcement with delegated searches through the `ProductQueryPlanner` and enriched output via the `DiscoveredProductAssembler`.
- Updated OPA fallback and dispatch rules for more descriptive policy denials with specific refusal reasons.
- Improved policy documentation to reflect updated request handling, configuration options, and contract-based search execution flows.
…andling

- Migrated `ProductDiscoveryServiceImpl` to a modular design, introducing search planning, criteria validation, repository execution, and assembly components.
- Enhanced policy enforcement with delegated searches through the `ProductQueryPlanner` and enriched output via the `DiscoveredProductAssembler`.
- Updated OPA fallback and dispatch rules for more descriptive policy denials with specific refusal reasons.
- Improved policy documentation to reflect updated request handling, configuration options, and contract-based search execution flows.
…andling

- Migrated `ProductDiscoveryServiceImpl` to a modular design, introducing search planning, criteria validation, repository execution, and assembly components.
- Enhanced policy enforcement with delegated searches through the `ProductQueryPlanner` and enriched output via the `DiscoveredProductAssembler`.
- Updated OPA fallback and dispatch rules for more descriptive policy denials with specific refusal reasons.
- Improved policy documentation to reflect updated request handling, configuration options, and contract-based search execution flows.
- Introduced test specification documentation for API manual testing, including objectives, preconditions, test data, and edge cases.
- Updated OPA policy documentation and sample stories to clarify product `view` and `discover` rules.
- Detailed the unification of the discovery and view rules to ensure consistent policy behavior and prevent rule divergence.
- Revised product attribute definitions, population risk tags, and coverage jurisdiction logic with new data samples and validation information.
- Introduced test specification documentation for API manual testing, including objectives, preconditions, test data, and edge cases.
- Updated OPA policy documentation and sample stories to clarify product `view` and `discover` rules.
- Detailed the unification of the discovery and view rules to ensure consistent policy behavior and prevent rule divergence.
- Revised product attribute definitions, population risk tags, and coverage jurisdiction logic with new data samples and validation information.
…arity

- Extracted column selection logic in `ProductSearchQueryBuilder` into a dedicated method for improved readability and reusability.
- Refactored test assertions for better readability by introducing intermediate variables for views and comparisons.
- Clarified SQL concatenation handling in `ProductDiscoveryRepository` to address injection risk review notes.
- Enhanced `ProductSearchCriteriaFactory` by separating filter and sort key extraction into modular private methods for better maintainability.
- Updated relevant unit tests to align with new method structures and improve test comprehension.
…arity

- Extracted column selection logic in `ProductSearchQueryBuilder` into a dedicated method for improved readability and reusability.
- Refactored test assertions for better readability by introducing intermediate variables for views and comparisons.
- Clarified SQL concatenation handling in `ProductDiscoveryRepository` to address injection risk review notes.
- Enhanced `ProductSearchCriteriaFactory` by separating filter and sort key extraction into modular private methods for better maintainability.
- Updated relevant unit tests to align with new method structures and improve test comprehension.
…d reuse

- Moved static SQL query strings in `ProductDiscoveryRepository` to centralized fields for reuse and improved maintainability.
- Updated query methods to use new query fields, reducing redundancy and enhancing readability.
- Refined generic handling and improved method signatures in `ProductQueryPlanner` for better type safety and modularity.
- Adjusted JavaDoc comments and formatting for consistency in `ProductSearchCriteriaFactory`.
…nced policy enforcement

- Introduced `ProductSubscriptionService` for processing subscription requests with direct policy decision integration.
- Added validation and exception handling logic for subscription edge cases (e.g., ambiguous consumers, duplicates, missing product).
- Updated `ProductController` to utilize `ProductSubscriptionService` and pass policy decisions consistently.
- Enhanced `PolicyTarget` to support resource loading, enabling more comprehensive decision-making.
- Revised unit tests to cover new subscription scenarios, response formatting, and error conditions comprehensively.
…ndling

- Implemented consumer default logic with `is_default` flag, enabling streamlined subscription scenarios.
- Added SQL migration to introduce `is_default` column and enforce one default consumer per organization.
- Created `ProductSubscriptionServiceImpl` with robust consumer resolution, policy-based validation, and exception handling.
- Introduced unit tests to ensure edge case coverage and enforce new subscription logic.
- Added `PolicyResourceIdExtractor` and loader unit tests to support flexible policy decision enforcement.
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

✅ OSS Checks Passed

All tracked OSS checks passed in this run.

📊 Total Files 🟢 Passed 🔴 Failed 🧮 Score
13 13 0 100%

Results from commit 029a4da, view the full job summary↗️ for detailed results.

♻️ This comment has been updated with latest results.

@nikan-negaresh-informed nikan-negaresh-informed changed the title Feature/dpav 3019 Feature/dpav 3019 product subscription with policy-set terms Sep 22, 2026
…ndling

- Implemented consumer default logic with `is_default` flag, enabling streamlined subscription scenarios.
- Added SQL migration to introduce `is_default` column and enforce one default consumer per organization.
- Created `ProductSubscriptionServiceImpl` with robust consumer resolution, policy-based validation, and exception handling.
- Introduced unit tests to ensure edge case coverage and enforce new subscription logic.
- Added `PolicyResourceIdExtractor` and loader unit tests to support flexible policy decision enforcement.
# Conflicts:
#	docker/opa/policies/product/subscribe.rego
#	docs/AUTHENTICATION_REQUIREMENTS.md
#	docs/DATABASE_SCHEMA.md
#	docs/POLICY_ENFORCEMENT.md
#	docs/tests/products/README.md
#	src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductController.java
#	src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java
#	src/main/resources/db/samples/V20260918140000__flag_sensitive_organisation_attributes.sql
#	src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductControllerTest.java
#	src/test/java/uk/gov/dbt/ndtp/ia/node/management/web/policy/PolicyAnnotationValidatorTest.java
@sonarqubecloud

Copy link
Copy Markdown

@nikan-negaresh-informed
nikan-negaresh-informed merged commit 4d2b4de into develop Sep 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants