diff --git a/.gitignore b/.gitignore
index d68e9b8..edd3c07 100755
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ logs/
*.swp
target/
.env
-/docker/sshd/config/sshd.pid
\ No newline at end of file
+/docker/sshd/config/sshd.pid
+.claude/settings.local.json
\ No newline at end of file
diff --git a/legal/headers/license-header-2025.txt b/legal/headers/license-header-2025.txt
new file mode 100644
index 0000000..8095de7
--- /dev/null
+++ b/legal/headers/license-header-2025.txt
@@ -0,0 +1,10 @@
+Copyright (c) 2025 Contributors to the Eclipse Foundation
+
+See the NOTICE file(s) distributed with this work for additional
+information regarding copyright ownership.
+
+This program and the accompanying materials are made available under the
+terms of the Eclipse Public License 2.0 which is available at
+http://www.eclipse.org/legal/epl-2.0
+
+SPDX-License-Identifier: EPL-2.0
diff --git a/legal/headers/license-header.txt b/legal/headers/license-header.txt
index 8095de7..5765d8e 100755
--- a/legal/headers/license-header.txt
+++ b/legal/headers/license-header.txt
@@ -1,4 +1,4 @@
-Copyright (c) 2025 Contributors to the Eclipse Foundation
+Copyright (c) 2026 Contributors to the Eclipse Foundation
See the NOTICE file(s) distributed with this work for additional
information regarding copyright ownership.
diff --git a/pom.xml b/pom.xml
index c6687aa..30d52d8 100755
--- a/pom.xml
+++ b/pom.xml
@@ -173,6 +173,7 @@
${project.basedir}/legal/headers/license-header-2023.txt
${project.basedir}/legal/headers/license-header-2024.txt
+ ${project.basedir}/legal/headers/license-header-2025.txt
${project.basedir}/legal/headers/license-header-xml-def.xml
diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyEntryImportableSubResourcesIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyEntryImportableSubResourcesIT.java
new file mode 100644
index 0000000..6ca8da7
--- /dev/null
+++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyEntryImportableSubResourcesIT.java
@@ -0,0 +1,432 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.testing.system.things.rest;
+
+import static org.eclipse.ditto.base.model.common.HttpStatus.BAD_REQUEST;
+import static org.eclipse.ditto.base.model.common.HttpStatus.CREATED;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NOT_FOUND;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NO_CONTENT;
+import static org.eclipse.ditto.base.model.common.HttpStatus.OK;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.policyResource;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.thingResource;
+import static org.eclipse.ditto.things.api.Permission.READ;
+import static org.eclipse.ditto.things.api.Permission.WRITE;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.ditto.json.JsonArray;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.json.JsonValue;
+import org.eclipse.ditto.policies.model.AllowedImportAddition;
+import org.eclipse.ditto.policies.model.EffectedImports;
+import org.eclipse.ditto.policies.model.EntriesAdditions;
+import org.eclipse.ditto.policies.model.EntryAddition;
+import org.eclipse.ditto.policies.model.ImportableType;
+import org.eclipse.ditto.policies.model.Label;
+import org.eclipse.ditto.policies.model.PoliciesModelFactory;
+import org.eclipse.ditto.policies.model.Policy;
+import org.eclipse.ditto.policies.model.PolicyEntry;
+import org.eclipse.ditto.policies.model.PolicyId;
+import org.eclipse.ditto.policies.model.PolicyImport;
+import org.eclipse.ditto.policies.model.Resource;
+import org.eclipse.ditto.policies.model.Subject;
+import org.eclipse.ditto.testing.common.IntegrationTest;
+import org.eclipse.ditto.testing.common.ResourcePathBuilder;
+import org.eclipse.ditto.testing.common.TestConstants;
+import org.eclipse.ditto.testing.common.matcher.GetMatcher;
+import org.eclipse.ditto.testing.common.matcher.PutMatcher;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for the dedicated policy entry sub-resource HTTP routes:
+ *
+ * - GET/PUT {@code /entries/{label}/importable}
+ * - GET/PUT {@code /entries/{label}/allowedImportAdditions}
+ *
+ */
+public final class PolicyEntryImportableSubResourcesIT extends IntegrationTest {
+
+ private PolicyId importedPolicyId;
+ private PolicyId importingPolicyId;
+ private Subject defaultSubject;
+ private Subject subject2;
+
+ @Before
+ public void setUp() {
+ importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+ defaultSubject = serviceEnv.getDefaultTestingContext().getOAuthClient().getDefaultSubject();
+ subject2 = serviceEnv.getTestingContext2().getOAuthClient().getDefaultSubject();
+ }
+
+ @Test
+ public void getAndPutPolicyEntryImportable() {
+ // Create policy with DEFAULT entry (IMPLICIT importable)
+ final Policy policy = buildImportedPolicy(importedPolicyId, Set.of());
+ putPolicy(policy).expectingHttpStatus(CREATED).fire();
+
+ // GET importable and verify it is "implicit"
+ getPolicyEntryImportable(importedPolicyId, "DEFAULT")
+ .expectingBody(containsOnly(JsonValue.of("implicit")))
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // PUT importable to "never"
+ putPolicyEntryImportable(importedPolicyId, "DEFAULT", "never")
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // GET importable again and verify it is "never"
+ getPolicyEntryImportable(importedPolicyId, "DEFAULT")
+ .expectingBody(containsOnly(JsonValue.of("never")))
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void getAndPutPolicyEntryAllowedImportAdditions() {
+ // Create policy with DEFAULT entry that has allowedImportAdditions=["subjects"]
+ final Policy policy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(policy).expectingHttpStatus(CREATED).fire();
+
+ // GET allowedImportAdditions and verify it contains "subjects"
+ getPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT")
+ .expectingBody(containsOnly(JsonArray.newBuilder().add("subjects").build()))
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // PUT allowedImportAdditions to ["subjects","resources"]
+ final JsonArray updated = JsonArray.newBuilder().add("subjects").add("resources").build();
+ putPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT", updated)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // GET allowedImportAdditions again and verify
+ getPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT")
+ .expectingBody(containsOnly(updated))
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void changingImportableToNeverRevokesThingAccess() {
+ // Create imported policy with DEFAULT entry (IMPLICIT, allows subject additions)
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with subject2 added via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Change importable of DEFAULT to NEVER on the imported (template) policy
+ putPolicyEntryImportable(importedPolicyId, "DEFAULT", "never")
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Verify user2 can no longer access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void addingAllowedImportAdditionsEnablesSubjectAdditions() {
+ // Create imported policy WITHOUT allowedImportAdditions (but IMPLICIT importable)
+ final Policy importedPolicy = buildImportedPolicyWithoutAllowedAdditions(importedPolicyId);
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with a simple import (no additions)
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Try to modify the import to add subject additions - should be rejected
+ final PolicyImport importWithAdditions = buildImportWithSubjectAdditions(importedPolicyId, subject2);
+ putPolicyImport(importingPolicyId, importWithAdditions)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+
+ // Add allowedImportAdditions=["subjects"] to the imported policy's DEFAULT entry
+ putPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT",
+ JsonArray.newBuilder().add("subjects").build())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Now modifying the import to add subject additions should succeed
+ putPolicyImport(importingPolicyId, importWithAdditions)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void addingAllowedImportAdditionsViaSubResourceEnablesThingAccess() {
+ // Create imported policy WITHOUT allowedImportAdditions (but IMPLICIT importable)
+ final Policy importedPolicy = buildImportedPolicyWithoutAllowedAdditions(importedPolicyId);
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with a simple import (no additions)
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 cannot access the thing initially
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Add allowedImportAdditions=["subjects"] via the sub-resource route on the imported policy
+ putPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT",
+ JsonArray.newBuilder().add("subjects").build())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Now add subject2 via entriesAdditions on the import
+ final PolicyImport importWithAdditions = buildImportWithSubjectAdditions(importedPolicyId, subject2);
+ putPolicyImport(importingPolicyId, importWithAdditions)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Verify user2 can now access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void removingAllowedImportAdditionsRejectsSubjectAdditions() {
+ // Create imported policy WITH allowedImportAdditions=["subjects"]
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with a simple import (no additions)
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Remove allowedImportAdditions from the imported policy's DEFAULT entry
+ putPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT",
+ JsonArray.empty())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Try to modify the import to add subject additions - should be rejected
+ final PolicyImport importWithAdditions = buildImportWithSubjectAdditions(importedPolicyId, subject2);
+ putPolicyImport(importingPolicyId, importWithAdditions)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ @Test
+ public void removingResourcesFromAllowedAdditionsRejectsNewResourceAdditions() {
+ // Create imported policy with allowedImportAdditions=["subjects","resources"]
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with a simple import (no additions)
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Verify that adding resource additions currently works
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition resourceAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"), null,
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions resourceAdditions = PoliciesModelFactory.newEntriesAdditions(
+ List.of(resourceAddition));
+ final EffectedImports effectedWithResources = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), resourceAdditions);
+ final PolicyImport importWithResources = PoliciesModelFactory.newPolicyImport(
+ importedPolicyId, effectedWithResources);
+ putPolicyImport(importingPolicyId, importWithResources)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Remove "resources" from allowedImportAdditions, keeping only "subjects"
+ putPolicyEntryAllowedImportAdditions(importedPolicyId, "DEFAULT",
+ JsonArray.newBuilder().add("subjects").build())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Now attempting to update the import with resource additions should be rejected
+ putPolicyImport(importingPolicyId, importWithResources)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ // --- Helper methods for building policies ---
+
+ private Policy buildImportedPolicy(final PolicyId policyId,
+ final Set allowedImportAdditions) {
+
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, allowedImportAdditions);
+
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ }
+
+ private Policy buildImportedPolicyWithoutAllowedAdditions(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setImportable(ImportableType.NEVER)
+ .forLabel("DEFAULT")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(thingResource("/"), READ)
+ .build();
+ }
+
+ private Policy buildImportingPolicy(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setGrantedPermissions(thingResource("/"), READ, WRITE)
+ .build();
+ }
+
+ private Policy buildImportingPolicyWithSubjectAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+ private static PolicyImport buildImportWithSubjectAdditions(final PolicyId importedPolicyId,
+ final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+
+ return PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+ }
+
+ // --- Helper methods for sub-resource HTTP operations ---
+
+ private static GetMatcher getPolicyEntryImportable(final CharSequence policyId,
+ final CharSequence label) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyEntry(label).toString() + "/importable";
+ return get(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyEntryImportable");
+ }
+
+ private static PutMatcher putPolicyEntryImportable(final CharSequence policyId,
+ final CharSequence label, final String importableType) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyEntry(label).toString() + "/importable";
+ return put(dittoUrl(TestConstants.API_V_2, path), "\"" + importableType + "\"")
+ .withLogging(LOGGER, "PolicyEntryImportable");
+ }
+
+ private static GetMatcher getPolicyEntryAllowedImportAdditions(final CharSequence policyId,
+ final CharSequence label) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyEntry(label).toString() + "/allowedImportAdditions";
+ return get(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyEntryAllowedImportAdditions");
+ }
+
+ private static PutMatcher putPolicyEntryAllowedImportAdditions(final CharSequence policyId,
+ final CharSequence label, final JsonArray allowedImportAdditions) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyEntry(label).toString() + "/allowedImportAdditions";
+ return put(dittoUrl(TestConstants.API_V_2, path), allowedImportAdditions.toString())
+ .withLogging(LOGGER, "PolicyEntryAllowedImportAdditions");
+ }
+
+}
diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportEntriesAdditionsIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportEntriesAdditionsIT.java
new file mode 100644
index 0000000..2924f14
--- /dev/null
+++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportEntriesAdditionsIT.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.testing.system.things.rest;
+
+import static org.eclipse.ditto.base.model.common.HttpStatus.BAD_REQUEST;
+import static org.eclipse.ditto.base.model.common.HttpStatus.CREATED;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NOT_FOUND;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NO_CONTENT;
+import static org.eclipse.ditto.base.model.common.HttpStatus.OK;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.policyResource;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.thingResource;
+import static org.eclipse.ditto.things.api.Permission.READ;
+import static org.eclipse.ditto.things.api.Permission.WRITE;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.policies.model.AllowedImportAddition;
+import org.eclipse.ditto.policies.model.EffectedImports;
+import org.eclipse.ditto.policies.model.EntriesAdditions;
+import org.eclipse.ditto.policies.model.EntryAddition;
+import org.eclipse.ditto.policies.model.ImportableType;
+import org.eclipse.ditto.policies.model.Label;
+import org.eclipse.ditto.policies.model.PoliciesModelFactory;
+import org.eclipse.ditto.policies.model.Policy;
+import org.eclipse.ditto.policies.model.PolicyEntry;
+import org.eclipse.ditto.policies.model.PolicyId;
+import org.eclipse.ditto.policies.model.PolicyImport;
+import org.eclipse.ditto.policies.model.Resource;
+import org.eclipse.ditto.policies.model.Subject;
+import org.eclipse.ditto.testing.common.IntegrationTest;
+import org.eclipse.ditto.testing.common.TestConstants;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for policy import {@code entriesAdditions} and {@code allowedImportAdditions} features.
+ */
+public final class PolicyImportEntriesAdditionsIT extends IntegrationTest {
+
+ private PolicyId importedPolicyId;
+ private PolicyId importingPolicyId;
+ private Subject defaultSubject;
+ private Subject subject2;
+
+ @Before
+ public void setUp() {
+ importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+ defaultSubject = serviceEnv.getDefaultTestingContext().getOAuthClient().getDefaultSubject();
+ subject2 = serviceEnv.getTestingContext2().getOAuthClient().getDefaultSubject();
+ }
+
+ @Test
+ public void putPolicyImportWithSubjectAdditionsAllowed() {
+ // Template allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy with entriesAdditions adding subject2
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Verify the import was created
+ getPolicy(importingPolicyId)
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithResourceAdditionsAllowed() {
+ // Template allows resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy with entriesAdditions adding a resource
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"), null,
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ getPolicy(importingPolicyId)
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithSubjectAndResourceAdditionsAllowed() {
+ // Template allows both subject and resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy with entriesAdditions adding both subject and resource
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2),
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ getPolicy(importingPolicyId)
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithSubjectAdditionsDisallowedIsRejected() {
+ // Template does NOT allow any additions (no allowedImportAdditions set)
+ final Policy importedPolicy = buildImportedPolicyWithoutAllowedAdditions(importedPolicyId);
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy tries to add subjects via entriesAdditions - should be rejected
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+
+ putPolicy(importingPolicy)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithResourceAdditionsWhenOnlySubjectsAllowed() {
+ // Template allows only subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy tries to add resources - should be rejected
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"), null,
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+
+ putPolicy(importingPolicy)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithAdditionsForEntryNotInEntriesArrayFails() {
+ // Template with allowedImportAdditions on DEFAULT entry
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // entriesAdditions references a label "NON_EXISTENT" not in the entries array
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("NON_EXISTENT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+
+ putPolicy(importingPolicy)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ @Test
+ public void thingAccessibleViaSubjectAddedThroughEntriesAdditions() {
+ // Template grants thing:/ READ and allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with subject2 added via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can access the thing via the imported subject
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void thingNotAccessibleWhenSubjectAdditionsNotAllowed() {
+ // Template does NOT allow additions
+ final Policy importedPolicy = buildImportedPolicyWithoutAllowedAdditions(importedPolicyId);
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Creating an importing policy with subject additions should be rejected
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+
+ putPolicy(importingPolicy)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+
+ // Create a simple importing policy without additions
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy simpleImportingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(simpleImportingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 cannot access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithMultipleResourceAdditionsAllowed() {
+ // Template allows resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy with entriesAdditions adding multiple resources in a single addition
+ final Resource attrResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()));
+ final Resource featResource = PoliciesModelFactory.newResource(thingResource("/features"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"), null,
+ PoliciesModelFactory.newResources(attrResource, featResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ getPolicy(importingPolicyId)
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportWithAdditionsForMultipleLabels() {
+ // Template with DEFAULT and EXTRA entries, both allow subject additions
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+ final PolicyEntry extraEntry = PoliciesModelFactory.newPolicyEntry("EXTRA",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.EXPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .set(extraEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy with entriesAdditions targeting both labels
+ final EntryAddition defaultAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntryAddition extraAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("EXTRA"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(
+ List.of(defaultAddition, extraAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT"), Label.of("EXTRA")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ getPolicy(importingPolicyId)
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ /**
+ * Builds an imported (template) policy with a DEFAULT entry that grants thing:/ READ
+ * and has the specified {@code allowedImportAdditions}.
+ */
+ private Policy buildImportedPolicy(final PolicyId policyId,
+ final Set allowedImportAdditions) {
+
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, allowedImportAdditions);
+
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ }
+
+ /**
+ * Builds an imported (template) policy without any {@code allowedImportAdditions}.
+ */
+ private Policy buildImportedPolicyWithoutAllowedAdditions(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setImportable(ImportableType.NEVER)
+ .forLabel("DEFAULT")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(thingResource("/"), READ)
+ .build();
+ }
+
+ /**
+ * Builds a basic importing policy with an ADMIN entry (full access on policy:/ and thing:/).
+ */
+ private Policy buildImportingPolicy(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setGrantedPermissions(thingResource("/"), READ, WRITE)
+ .build();
+ }
+
+ /**
+ * Builds an importing policy that imports from the given imported policy and adds the given subject
+ * via {@code entriesAdditions}.
+ */
+ private Policy buildImportingPolicyWithSubjectAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+}
diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportSubResourcesIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportSubResourcesIT.java
new file mode 100644
index 0000000..7f0d051
--- /dev/null
+++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportSubResourcesIT.java
@@ -0,0 +1,481 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.testing.system.things.rest;
+
+import static org.eclipse.ditto.base.model.common.HttpStatus.BAD_REQUEST;
+import static org.eclipse.ditto.base.model.common.HttpStatus.CREATED;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NOT_FOUND;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NO_CONTENT;
+import static org.eclipse.ditto.base.model.common.HttpStatus.OK;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.policyResource;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.thingResource;
+import static org.eclipse.ditto.things.api.Permission.READ;
+import static org.eclipse.ditto.things.api.Permission.WRITE;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.ditto.json.JsonArray;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.json.JsonValue;
+import org.eclipse.ditto.policies.model.AllowedImportAddition;
+import org.eclipse.ditto.policies.model.EffectedImports;
+import org.eclipse.ditto.policies.model.EntriesAdditions;
+import org.eclipse.ditto.policies.model.EntryAddition;
+import org.eclipse.ditto.policies.model.ImportableType;
+import org.eclipse.ditto.policies.model.Label;
+import org.eclipse.ditto.policies.model.PoliciesModelFactory;
+import org.eclipse.ditto.policies.model.Policy;
+import org.eclipse.ditto.policies.model.PolicyEntry;
+import org.eclipse.ditto.policies.model.PolicyId;
+import org.eclipse.ditto.policies.model.PolicyImport;
+import org.eclipse.ditto.policies.model.Resource;
+import org.eclipse.ditto.policies.model.Subject;
+import org.eclipse.ditto.testing.common.IntegrationTest;
+import org.eclipse.ditto.testing.common.ResourcePathBuilder;
+import org.eclipse.ditto.testing.common.TestConstants;
+import org.eclipse.ditto.testing.common.matcher.DeleteMatcher;
+import org.eclipse.ditto.testing.common.matcher.GetMatcher;
+import org.eclipse.ditto.testing.common.matcher.PutMatcher;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for the dedicated policy import sub-resource HTTP routes:
+ *
+ * - GET/PUT {@code /imports/{id}/entries}
+ * - GET/PUT {@code /imports/{id}/entriesAdditions}
+ * - GET/PUT/DELETE {@code /imports/{id}/entriesAdditions/{label}}
+ *
+ */
+public final class PolicyImportSubResourcesIT extends IntegrationTest {
+
+ private PolicyId importedPolicyId;
+ private PolicyId importingPolicyId;
+ private Subject defaultSubject;
+ private Subject subject2;
+
+ @Before
+ public void setUp() {
+ importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+ defaultSubject = serviceEnv.getDefaultTestingContext().getOAuthClient().getDefaultSubject();
+ subject2 = serviceEnv.getTestingContext2().getOAuthClient().getDefaultSubject();
+ }
+
+ @Test
+ public void getAndPutPolicyImportEntries() {
+ // Create imported policy with two importable entries: DEFAULT and EXTRA
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, Set.of());
+ final PolicyEntry extraEntry = PoliciesModelFactory.newPolicyEntry("EXTRA",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.EXPLICIT, Set.of());
+
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .set(extraEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy that imports only DEFAULT
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // GET entries and verify only DEFAULT is listed
+ final JsonArray expectedEntries = JsonArray.newBuilder().add("DEFAULT").build();
+ getPolicyImportEntries(importingPolicyId, importedPolicyId)
+ .expectingBody(containsOnly(expectedEntries))
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // PUT entries to also import EXTRA
+ final JsonArray updatedEntries = JsonArray.newBuilder().add("DEFAULT").add("EXTRA").build();
+ putPolicyImportEntries(importingPolicyId, importedPolicyId, updatedEntries)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // GET entries again and verify both are listed
+ getPolicyImportEntries(importingPolicyId, importedPolicyId)
+ .expectingBody(containsOnly(updatedEntries))
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void getAndPutPolicyImportEntriesAdditions() {
+ // Create imported policy with DEFAULT entry that allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with subject2 added via entriesAdditions
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(policyImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // GET entriesAdditions and verify
+ getPolicyImportEntriesAdditions(importingPolicyId, importedPolicyId)
+ .expectingBody(containsOnly(additions.toJson()))
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // PUT empty entriesAdditions
+ putPolicyImportEntriesAdditions(importingPolicyId, importedPolicyId, JsonObject.empty())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // GET entriesAdditions and verify empty
+ getPolicyImportEntriesAdditions(importingPolicyId, importedPolicyId)
+ .expectingBody(containsOnly(JsonObject.empty()))
+ .expectingHttpStatus(OK)
+ .fire();
+ }
+
+ @Test
+ public void putGetAndDeletePolicyImportEntryAddition() {
+ // Create imported policy with DEFAULT entry that allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy without additions
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // PUT single entry addition for DEFAULT
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final String additionBody = entryAdditionBodyString(Label.of("DEFAULT"), entryAddition);
+ putPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT", additionBody)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // GET single entry addition
+ getPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // DELETE single entry addition
+ deletePolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // GET after delete returns 404
+ getPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+ }
+
+ @Test
+ public void getPolicyImportEntryAdditionForNonExistentLabelFails() {
+ // Create imported policy
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy without additions
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // GET non-existent entry addition returns 404
+ getPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "NONEXISTENT")
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportEntryAdditionViaSubResourceGrantsAccess() {
+ // Create imported policy with DEFAULT entry that allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with a simple import (no additions)
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 cannot access the thing initially
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Add subject2 via the entriesAdditions sub-resource route
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final String additionBody = entryAdditionBodyString(Label.of("DEFAULT"), entryAddition);
+ putPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT", additionBody)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can now access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void deletePolicyImportEntryAdditionRemovesAccess() {
+ // Create imported policy with DEFAULT entry that allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with subject2 added via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // DELETE the entry addition for DEFAULT
+ deletePolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Verify user2 can no longer access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void putPolicyImportEntryAdditionViaSubResourceBypassesAllowedAdditionsCheck() {
+ // Create imported policy that only allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy without additions
+ final PolicyImport simpleImport = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId)
+ .toBuilder().setPolicyImport(simpleImport).build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // PUT a resource addition via the sub-resource route — accepted because
+ // the sub-resource route does not validate against allowedImportAdditions
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()));
+ final EntryAddition resourceAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"), null,
+ PoliciesModelFactory.newResources(additionalResource));
+ final String additionBody = entryAdditionBodyString(Label.of("DEFAULT"), resourceAddition);
+ putPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT", additionBody)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify the addition was stored
+ getPolicyImportEntryAddition(importingPolicyId, importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // In contrast, modifying the full import with resource additions IS rejected
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(resourceAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport importWithResourceAdditions =
+ PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+ putPolicyImport(importingPolicyId, importWithResourceAdditions)
+ .expectingHttpStatus(BAD_REQUEST)
+ .expectingErrorCode("policies:import.invalid")
+ .fire();
+ }
+
+ // --- Helper methods for building policies ---
+
+ private Policy buildImportedPolicy(final PolicyId policyId,
+ final Set allowedImportAdditions) {
+
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, allowedImportAdditions);
+
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ }
+
+ private Policy buildImportingPolicy(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setGrantedPermissions(thingResource("/"), READ, WRITE)
+ .build();
+ }
+
+ private Policy buildImportingPolicyWithSubjectAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+ /**
+ * Extracts the JSON body for a single entry addition by label from an EntriesAdditions object.
+ */
+ private static String entryAdditionBodyString(final Label label, final EntryAddition entryAddition) {
+ return PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition))
+ .toJson()
+ .getValue(label.toString())
+ .map(JsonValue::toString)
+ .orElseThrow();
+ }
+
+ // --- Helper methods for sub-resource HTTP operations ---
+
+ private static GetMatcher getPolicyImportEntries(final CharSequence policyId,
+ final CharSequence importedPolicyId) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entries";
+ return get(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyImportEntries");
+ }
+
+ private static PutMatcher putPolicyImportEntries(final CharSequence policyId,
+ final CharSequence importedPolicyId, final JsonArray entries) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entries";
+ return put(dittoUrl(TestConstants.API_V_2, path), entries.toString())
+ .withLogging(LOGGER, "PolicyImportEntries");
+ }
+
+ private static GetMatcher getPolicyImportEntriesAdditions(final CharSequence policyId,
+ final CharSequence importedPolicyId) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entriesAdditions";
+ return get(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyImportEntriesAdditions");
+ }
+
+ private static PutMatcher putPolicyImportEntriesAdditions(final CharSequence policyId,
+ final CharSequence importedPolicyId, final JsonObject entriesAdditions) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entriesAdditions";
+ return put(dittoUrl(TestConstants.API_V_2, path), entriesAdditions.toString())
+ .withLogging(LOGGER, "PolicyImportEntriesAdditions");
+ }
+
+ private static GetMatcher getPolicyImportEntryAddition(final CharSequence policyId,
+ final CharSequence importedPolicyId, final CharSequence label) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entriesAdditions/" + label;
+ return get(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyImportEntryAddition");
+ }
+
+ private static PutMatcher putPolicyImportEntryAddition(final CharSequence policyId,
+ final CharSequence importedPolicyId, final CharSequence label, final String body) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entriesAdditions/" + label;
+ return put(dittoUrl(TestConstants.API_V_2, path), body)
+ .withLogging(LOGGER, "PolicyImportEntryAddition");
+ }
+
+ private static DeleteMatcher deletePolicyImportEntryAddition(final CharSequence policyId,
+ final CharSequence importedPolicyId, final CharSequence label) {
+ final String path = ResourcePathBuilder.forPolicy(policyId)
+ .policyImport(importedPolicyId).toString() + "/entriesAdditions/" + label;
+ return delete(dittoUrl(TestConstants.API_V_2, path))
+ .withLogging(LOGGER, "PolicyImportEntryAddition");
+ }
+
+}
diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/ThingsWithImportedPoliciesEntriesAdditionsIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/ThingsWithImportedPoliciesEntriesAdditionsIT.java
new file mode 100644
index 0000000..695105d
--- /dev/null
+++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/ThingsWithImportedPoliciesEntriesAdditionsIT.java
@@ -0,0 +1,1022 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.testing.system.things.rest;
+
+import static org.eclipse.ditto.base.model.common.HttpStatus.CREATED;
+import static org.eclipse.ditto.base.model.common.HttpStatus.FORBIDDEN;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NOT_FOUND;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NO_CONTENT;
+import static org.eclipse.ditto.base.model.common.HttpStatus.OK;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.policyResource;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.thingResource;
+import static org.eclipse.ditto.things.api.Permission.READ;
+import static org.eclipse.ditto.things.api.Permission.WRITE;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.policies.model.AllowedImportAddition;
+import org.eclipse.ditto.policies.model.EffectedImports;
+import org.eclipse.ditto.policies.model.EntriesAdditions;
+import org.eclipse.ditto.policies.model.EntryAddition;
+import org.eclipse.ditto.policies.model.ImportableType;
+import org.eclipse.ditto.policies.model.Label;
+import org.eclipse.ditto.policies.model.PoliciesModelFactory;
+import org.eclipse.ditto.policies.model.Policy;
+import org.eclipse.ditto.policies.model.PolicyEntry;
+import org.eclipse.ditto.policies.model.PolicyId;
+import org.eclipse.ditto.policies.model.PolicyImport;
+import org.eclipse.ditto.policies.model.Resource;
+import org.eclipse.ditto.policies.model.Subject;
+import org.eclipse.ditto.testing.common.IntegrationTest;
+import org.eclipse.ditto.testing.common.TestConstants;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for Thing access via policy import {@code entriesAdditions}.
+ * Tests the connectivity use case where a template policy defines resource permissions and an importing
+ * policy adds user subjects via {@code entriesAdditions}.
+ */
+public final class ThingsWithImportedPoliciesEntriesAdditionsIT extends IntegrationTest {
+
+ private PolicyId importedPolicyId;
+ private PolicyId importingPolicyId;
+ private Subject defaultSubject;
+ private Subject subject2;
+
+ @Before
+ public void setUp() {
+ importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+ defaultSubject = serviceEnv.getDefaultTestingContext().getOAuthClient().getDefaultSubject();
+ subject2 = serviceEnv.getTestingContext2().getOAuthClient().getDefaultSubject();
+ }
+
+ @Test
+ public void secondUserGainsThingAccessViaEntriesAdditions() {
+ // Template grants thing:/ READ and allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds user2 subject via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can access the thing
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void secondUserLosesAccessWhenEntriesAdditionsRemoved() {
+ // Template grants thing:/ READ and allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds user2 subject via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // Verify user2 can access the thing initially
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Remove entriesAdditions by updating the import without additions
+ final PolicyImport importWithoutAdditions = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ putPolicyImport(importingPolicyId, importWithoutAdditions)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Verify user2 loses access
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void templateRevokePreservedWhenResourceAdditionsOverlap() {
+ // Template grants thing:/ READ but revokes WRITE, and allows both subject and resource additions
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of(WRITE)))),
+ ImportableType.IMPLICIT,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 and also tries to add grant WRITE on thing:/
+ final Resource additionalResource = PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2),
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 should be able to READ (from template grant)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 should NOT be able to WRITE (template revoke should be preserved)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(FORBIDDEN)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void resourceAdditionGrantsWriteAccess() {
+ // Template grants thing:/ READ only, allows both subject and resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 AND grants WRITE on thing:/ via resource addition
+ final Resource writeResource = PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2),
+ PoliciesModelFactory.newResources(writeResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (from template grant)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can WRITE (from resource addition granting WRITE on thing:/)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void additionsForMultipleImportedLabels() {
+ // Template has DEFAULT (thing:/ READ) and EXTRA (thing:/ WRITE), both allow subject additions
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+ final PolicyEntry extraEntry = PoliciesModelFactory.newPolicyEntry("EXTRA",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()))),
+ ImportableType.EXPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .set(extraEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 to both DEFAULT and EXTRA labels
+ final EntryAddition defaultAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntryAddition extraAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("EXTRA"),
+ PoliciesModelFactory.newSubjects(subject2), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(
+ List.of(defaultAddition, extraAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT"), Label.of("EXTRA")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (from DEFAULT import with subject addition)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can WRITE (from EXTRA import with subject addition)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void templatePermissionChangeBecomesEffectiveForImportingPolicy() {
+ // Template grants thing:/ READ only, allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds user2 subject via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with the importing policy
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (from template grant)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 cannot WRITE (template only grants READ)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(FORBIDDEN)
+ .fire();
+
+ // Modify the template policy's DEFAULT entry to grant READ + WRITE
+ final PolicyEntry updatedDefaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.IMPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicyEntry(importedPolicyId, updatedDefaultEntry)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 can now WRITE (template change is effective via the importing policy)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void reducingAllowedImportAdditionsRevokesResourceAdditionEffect() {
+ // Template grants thing:/ READ, allows both subject and resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 AND grants WRITE on thing:/ via resource addition
+ final Resource writeResource = PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(subject2),
+ PoliciesModelFactory.newResources(writeResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (from template grant) and WRITE (from resource addition)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Reduce allowedImportAdditions on the template: remove "resources", keep only "subjects"
+ final PolicyEntry reducedDefaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicyEntry(importedPolicyId, reducedDefaultEntry)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 can still READ (subject addition is still allowed)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can no longer WRITE (resource addition is no longer applied)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "updated").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(FORBIDDEN)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void templateRevokeAddedAfterImportOverridesResourceAdditionGrant() {
+ // Template grants thing:/ READ, allows both subject and resource additions
+ // DEFAULT entry has no subjects — only entriesAdditions subjects (user2) will be affected by the revoke
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 + WRITE resource addition on thing:/
+ final Resource writeResource = PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAndResourceAdditions(
+ importingPolicyId, importedPolicyId, subject2, writeResource);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (from template grant)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can WRITE (from resource addition)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Modify template: add explicit REVOKE on WRITE (keep READ grant + allowedAdditions)
+ final PolicyEntry updatedDefaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of(WRITE)))),
+ ImportableType.IMPLICIT,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicyEntry(importedPolicyId, updatedDefaultEntry)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 can still READ
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 WRITE is now FORBIDDEN (template revoke overrides resource addition grant)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "updated").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(FORBIDDEN)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void templateEntryDeletionRevokesEntriesAdditionsAccess() {
+ // Template grants thing:/ READ, allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds user2 subject via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Delete DEFAULT entry from template
+ deletePolicyEntry(importedPolicyId, "DEFAULT")
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 loses access
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void templatePolicyDeletionRevokesImportedAccess() {
+ // Template grants thing:/ READ, allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds user2 subject via entriesAdditions
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Delete the entire template policy
+ deletePolicy(importedPolicyId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 loses access
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void multipleImportersFromSameTemplateAreIndependentlyAffected() {
+ // Template grants thing:/ READ, allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Two importing policies (different IDs), each adds subject2 via entriesAdditions
+ final PolicyId importingPolicyId2 = PolicyId.of(idGenerator().withPrefixedRandomName("importing2"));
+
+ final Policy importingPolicy1 = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2);
+ putPolicy(importingPolicy1).expectingHttpStatus(CREATED).fire();
+
+ final Policy importingPolicy2 = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId2, importedPolicyId, subject2);
+ putPolicy(importingPolicy2).expectingHttpStatus(CREATED).fire();
+
+ // Create two things, one per importing policy
+ final String thingId1 = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId1)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ final String thingId2 = importingPolicyId2.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId2)
+ .set("policyId", importingPolicyId2.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ both things
+ getThing(TestConstants.API_V_2, thingId1)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+ getThing(TestConstants.API_V_2, thingId2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Modify template: change DEFAULT to importable=NEVER
+ final PolicyEntry neverDefaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.NEVER, Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicyEntry(importedPolicyId, neverDefaultEntry)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 loses access to both things
+ getThing(TestConstants.API_V_2, thingId1)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+ getThing(TestConstants.API_V_2, thingId2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NOT_FOUND)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId1)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ deleteThing(TestConstants.API_V_2, thingId2)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void subjectRetainsAccessFromOwnEntryWhenEntriesAdditionsRemoved() {
+ // Template grants thing:/ READ, allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy: adds subject2 via entriesAdditions AND has direct DIRECT_USER2 entry
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAdditions(
+ importingPolicyId, importedPolicyId, subject2).toBuilder()
+ .forLabel("DIRECT_USER2")
+ .setSubject(subject2)
+ .setGrantedPermissions(thingResource("/"), READ)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Remove entriesAdditions (update import without additions)
+ final PolicyImport importWithoutAdditions = PoliciesModelFactory.newPolicyImport(importedPolicyId,
+ PoliciesModelFactory.newEffectedImportedLabels(List.of(Label.of("DEFAULT"))));
+ putPolicyImport(importingPolicyId, importWithoutAdditions)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 can still READ (from own DIRECT_USER2 entry in importing policy)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void resourceAdditionRespectsSubPathGranularity() {
+ // Template grants thing:/ READ, allows both subject and resource additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS, AllowedImportAddition.RESOURCES));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Importing policy adds subject2 + WRITE resource addition on thing:/attributes only
+ final Resource writeAttributesResource = PoliciesModelFactory.newResource(thingResource("/attributes"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final Policy importingPolicy = buildImportingPolicyWithSubjectAndResourceAdditions(
+ importingPolicyId, importedPolicyId, subject2, writeAttributesResource);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing with attributes and features
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("key", "value").build())
+ .set("features", JsonObject.newBuilder()
+ .set("sensor", JsonObject.newBuilder()
+ .set("properties", JsonObject.newBuilder()
+ .set("value", 42)
+ .build())
+ .build())
+ .build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ whole thing (from template grant on thing:/)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can WRITE attributes (resource addition grants WRITE on thing:/attributes)
+ putAttributes(TestConstants.API_V_2, thingId,
+ JsonObject.newBuilder().set("key", "updated").build().toString())
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // user2 cannot WRITE features (no WRITE on thing:/features)
+ putFeatures(TestConstants.API_V_2, thingId,
+ JsonObject.newBuilder()
+ .set("sensor", JsonObject.newBuilder()
+ .set("properties", JsonObject.newBuilder()
+ .set("value", 99)
+ .build())
+ .build())
+ .build().toString())
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(FORBIDDEN)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ @Test
+ public void resourceAdditionWithoutSubjectAdditionAppliesToTemplateSubjects() {
+ // Template: DEFAULT entry has subject2 as subject with thing:/ READ, allows RESOURCES
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(subject2),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, Set.of(AllowedImportAddition.RESOURCES));
+ final Policy importedPolicy = PoliciesModelFactory.newPolicyBuilder(importedPolicyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Import: WRITE resource addition on thing:/ with no subject addition
+ final Resource writeResource = PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(WRITE), List.of()));
+ final Policy importingPolicy = buildImportingPolicyWithResourceAdditions(
+ importingPolicyId, importedPolicyId, writeResource);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create a thing
+ final String thingId = importingPolicyId.toString();
+ putThing(TestConstants.API_V_2, JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .expectingHttpStatus(CREATED)
+ .fire();
+
+ // user2 can READ (already a template subject)
+ getThing(TestConstants.API_V_2, thingId)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(OK)
+ .fire();
+
+ // user2 can WRITE (resource addition applies to existing template subjects)
+ putThing(TestConstants.API_V_2,
+ JsonObject.newBuilder()
+ .set("thingId", thingId)
+ .set("policyId", importingPolicyId.toString())
+ .set("attributes", JsonObject.newBuilder().set("test", "value").build())
+ .build(),
+ org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2)
+ .withConfiguredAuth(serviceEnv.getTestingContext2())
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+
+ // Cleanup
+ deleteThing(TestConstants.API_V_2, thingId)
+ .expectingHttpStatus(NO_CONTENT)
+ .fire();
+ }
+
+ private Policy buildImportedPolicy(final PolicyId policyId,
+ final Set allowedImportAdditions) {
+
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, allowedImportAdditions);
+
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ }
+
+ private Policy buildImportingPolicy(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setGrantedPermissions(thingResource("/"), READ, WRITE)
+ .build();
+ }
+
+ private Policy buildImportingPolicyWithSubjectAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+ private Policy buildImportingPolicyWithSubjectAndResourceAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Subject additionalSubject,
+ final Resource additionalResource) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject),
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+ private Policy buildImportingPolicyWithResourceAdditions(final PolicyId policyId,
+ final PolicyId importedPolicyId, final Resource additionalResource) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ null,
+ PoliciesModelFactory.newResources(additionalResource));
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ final PolicyImport policyImport = PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+
+ return buildImportingPolicy(policyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ }
+
+}
diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/PolicyImportEntriesAdditionsWsIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/PolicyImportEntriesAdditionsWsIT.java
new file mode 100644
index 0000000..b3c9e77
--- /dev/null
+++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/PolicyImportEntriesAdditionsWsIT.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.testing.system.things.ws;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.eclipse.ditto.base.model.common.HttpStatus.CREATED;
+import static org.eclipse.ditto.base.model.common.HttpStatus.NO_CONTENT;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.policyResource;
+import static org.eclipse.ditto.policies.model.PoliciesResourceType.thingResource;
+import static org.eclipse.ditto.testing.common.TestConstants.API_V_2;
+import static org.eclipse.ditto.things.api.Permission.READ;
+import static org.eclipse.ditto.things.api.Permission.WRITE;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.ditto.base.model.common.HttpStatus;
+import org.eclipse.ditto.base.model.headers.DittoHeaders;
+import org.eclipse.ditto.base.model.json.JsonSchemaVersion;
+import org.eclipse.ditto.base.model.signals.commands.CommandResponse;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.policies.model.AllowedImportAddition;
+import org.eclipse.ditto.policies.model.EffectedImports;
+import org.eclipse.ditto.policies.model.EntriesAdditions;
+import org.eclipse.ditto.policies.model.EntryAddition;
+import org.eclipse.ditto.policies.model.ImportableType;
+import org.eclipse.ditto.policies.model.Label;
+import org.eclipse.ditto.policies.model.PoliciesModelFactory;
+import org.eclipse.ditto.policies.model.Policy;
+import org.eclipse.ditto.policies.model.PolicyEntry;
+import org.eclipse.ditto.policies.model.PolicyId;
+import org.eclipse.ditto.policies.model.PolicyImport;
+import org.eclipse.ditto.policies.model.Subject;
+import org.eclipse.ditto.policies.model.signals.commands.modify.ModifyPolicyImport;
+import org.eclipse.ditto.policies.model.signals.commands.modify.ModifyPolicyImportResponse;
+import org.eclipse.ditto.testing.common.IntegrationTest;
+import org.eclipse.ditto.testing.common.TestConstants;
+import org.eclipse.ditto.testing.common.ws.ThingsWebsocketClient;
+import org.eclipse.ditto.things.model.Thing;
+import org.eclipse.ditto.things.model.ThingId;
+import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException;
+import org.eclipse.ditto.things.model.signals.commands.modify.CreateThing;
+import org.eclipse.ditto.things.model.signals.commands.modify.CreateThingResponse;
+import org.eclipse.ditto.things.model.signals.commands.modify.DeleteThing;
+import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThing;
+import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThingResponse;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * WebSocket integration tests for policy import {@code entriesAdditions} and {@code allowedImportAdditions}.
+ */
+public final class PolicyImportEntriesAdditionsWsIT extends IntegrationTest {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PolicyImportEntriesAdditionsWsIT.class);
+ private static final long TIMEOUT_SECONDS = 20L;
+
+ private ThingsWebsocketClient clientUser1;
+ private ThingsWebsocketClient clientUser2;
+ private Subject defaultSubject;
+ private Subject subject2;
+
+ @Before
+ public void setUp() {
+ defaultSubject = serviceEnv.getDefaultTestingContext().getOAuthClient().getDefaultSubject();
+ subject2 = serviceEnv.getTestingContext2().getOAuthClient().getDefaultSubject();
+
+ clientUser1 = newTestWebsocketClient(serviceEnv.getDefaultTestingContext(), Map.of(), API_V_2);
+ clientUser2 = newTestWebsocketClient(serviceEnv.getTestingContext2(), Map.of(), API_V_2);
+
+ clientUser1.connect("WsClient-User1-" + UUID.randomUUID());
+ clientUser2.connect("WsClient-User2-" + UUID.randomUUID());
+ }
+
+ @After
+ public void tearDown() {
+ if (clientUser1 != null) {
+ clientUser1.disconnect();
+ }
+ if (clientUser2 != null) {
+ clientUser2.disconnect();
+ }
+ }
+
+ @Test
+ public void modifyPolicyImportWithEntriesAdditionsViaWebSocket() throws Exception {
+ final PolicyId importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ final PolicyId importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+
+ // Create imported (template) policy via REST - allows subject additions
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy via REST (without imports initially)
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Now modify the policy import via WebSocket with entriesAdditions
+ final PolicyImport policyImport = buildPolicyImportWithSubjectAdditions(importedPolicyId, subject2);
+ final ModifyPolicyImport modifyPolicyImport = ModifyPolicyImport.of(
+ importingPolicyId, policyImport, dittoHeaders());
+
+ final CommandResponse> response = clientUser1.send(modifyPolicyImport)
+ .toCompletableFuture()
+ .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+ assertThat(response).isInstanceOf(ModifyPolicyImportResponse.class);
+ assertThat(response.getHttpStatus()).isEqualTo(HttpStatus.CREATED);
+ }
+
+ @Test
+ public void retrieveThingViaWebSocketAfterSubjectAddedViaAdditions() throws Exception {
+ final PolicyId importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ final PolicyId importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+ final ThingId thingId = ThingId.of(importingPolicyId);
+
+ // Create imported (template) policy via REST - allows subject additions, grants thing:/ READ
+ final Policy importedPolicy = buildImportedPolicy(importedPolicyId,
+ Set.of(AllowedImportAddition.SUBJECTS));
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy with subject2 added via entriesAdditions
+ final PolicyImport policyImport = buildPolicyImportWithSubjectAdditions(importedPolicyId, subject2);
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId).toBuilder()
+ .setPolicyImport(policyImport)
+ .build();
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create thing via WS with user1, referencing the existing importing policy
+ final Thing thing = Thing.newBuilder().setId(thingId).setPolicyId(importingPolicyId).build();
+ final CreateThing createThing = CreateThing.of(thing, null, dittoHeaders());
+ final CommandResponse> createResponse = clientUser1.send(createThing)
+ .toCompletableFuture()
+ .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertThat(createResponse).isInstanceOf(CreateThingResponse.class);
+
+ // Verify user2 can retrieve the thing via WS (subject was added through entriesAdditions)
+ final RetrieveThing retrieveThing = RetrieveThing.of(thingId, dittoHeaders());
+ final CommandResponse> retrieveResponse = clientUser2.send(retrieveThing)
+ .toCompletableFuture()
+ .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertThat(retrieveResponse).isInstanceOf(RetrieveThingResponse.class);
+
+ // Cleanup
+ clientUser1.send(DeleteThing.of(thingId, dittoHeaders()));
+ }
+
+ @Test
+ public void modifyPolicyImportWithDisallowedAdditionsViaWebSocket() throws Exception {
+ final PolicyId importedPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("imported"));
+ final PolicyId importingPolicyId = PolicyId.of(idGenerator().withPrefixedRandomName("importing"));
+
+ // Create imported (template) policy via REST - NO allowedImportAdditions
+ final Policy importedPolicy = buildImportedPolicyWithoutAllowedAdditions(importedPolicyId);
+ putPolicy(importedPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Create importing policy via REST (without imports initially)
+ final Policy importingPolicy = buildImportingPolicy(importingPolicyId);
+ putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire();
+
+ // Attempt to modify policy import via WebSocket with disallowed entriesAdditions
+ final PolicyImport policyImport = buildPolicyImportWithSubjectAdditions(importedPolicyId, subject2);
+ final ModifyPolicyImport modifyPolicyImport = ModifyPolicyImport.of(
+ importingPolicyId, policyImport, dittoHeaders());
+
+ final CommandResponse> response = clientUser1.send(modifyPolicyImport)
+ .toCompletableFuture()
+ .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+ // Expect an error response
+ assertThat(response.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ private Policy buildImportedPolicy(final PolicyId policyId,
+ final Set allowedImportAdditions) {
+
+ final PolicyEntry adminEntry = PoliciesModelFactory.newPolicyEntry("ADMIN",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(policyResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ, WRITE), List.of()))),
+ ImportableType.NEVER, Set.of());
+
+ final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT",
+ List.of(defaultSubject),
+ List.of(PoliciesModelFactory.newResource(thingResource("/"),
+ PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of()))),
+ ImportableType.IMPLICIT, allowedImportAdditions);
+
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .set(adminEntry)
+ .set(defaultEntry)
+ .build();
+ }
+
+ private Policy buildImportedPolicyWithoutAllowedAdditions(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setImportable(ImportableType.NEVER)
+ .forLabel("DEFAULT")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(thingResource("/"), READ)
+ .build();
+ }
+
+ private Policy buildImportingPolicy(final PolicyId policyId) {
+ return PoliciesModelFactory.newPolicyBuilder(policyId)
+ .forLabel("ADMIN")
+ .setSubject(defaultSubject)
+ .setGrantedPermissions(policyResource("/"), READ, WRITE)
+ .setGrantedPermissions(thingResource("/"), READ, WRITE)
+ .build();
+ }
+
+ private PolicyImport buildPolicyImportWithSubjectAdditions(final PolicyId importedPolicyId,
+ final Subject additionalSubject) {
+
+ final EntryAddition entryAddition = PoliciesModelFactory.newEntryAddition(
+ Label.of("DEFAULT"),
+ PoliciesModelFactory.newSubjects(additionalSubject), null);
+ final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions(List.of(entryAddition));
+ final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels(
+ List.of(Label.of("DEFAULT")), additions);
+ return PoliciesModelFactory.newPolicyImport(importedPolicyId, effectedImports);
+ }
+
+ private static DittoHeaders dittoHeaders() {
+ return DittoHeaders.newBuilder()
+ .schemaVersion(JsonSchemaVersion.V_2)
+ .correlationId(UUID.randomUUID().toString())
+ .build();
+ }
+
+}