From 63e2782f19c9e375ec567aa2075f1fb03977ddd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Fri, 20 Feb 2026 20:25:45 +0100 Subject: [PATCH 1/4] add integration tests for policy import sub-resource routes and entriesAdditions - PolicyImportSubResourcesIT: tests GET/PUT /imports/{id}/entries, GET/PUT /imports/{id}/entriesAdditions, GET/PUT/DELETE /imports/{id}/entriesAdditions/{label} including thing access verification - PolicyEntryImportableSubResourcesIT: tests GET/PUT /entries/{label}/importable and /entries/{label}/allowedImportAdditions including thing access verification - PolicyImportEntriesAdditionsIT: tests entriesAdditions and allowedImportAdditions policy-level validation - ThingsWithImportedPoliciesEntriesAdditionsIT: tests thing access granted/revoked via entriesAdditions subject additions - PolicyImportEntriesAdditionsWsIT: WebSocket variant of access verification Co-Authored-By: Claude Opus 4.6 --- .../PolicyEntryImportableSubResourcesIT.java | 388 ++++++++++++++ .../rest/PolicyImportEntriesAdditionsIT.java | 356 +++++++++++++ .../rest/PolicyImportSubResourcesIT.java | 481 ++++++++++++++++++ ...ithImportedPoliciesEntriesAdditionsIT.java | 266 ++++++++++ .../ws/PolicyImportEntriesAdditionsWsIT.java | 251 +++++++++ 5 files changed, 1742 insertions(+) create mode 100644 system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyEntryImportableSubResourcesIT.java create mode 100644 system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportEntriesAdditionsIT.java create mode 100644 system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportSubResourcesIT.java create mode 100644 system/src/test/java/org/eclipse/ditto/testing/system/things/rest/ThingsWithImportedPoliciesEntriesAdditionsIT.java create mode 100644 system/src/test/java/org/eclipse/ditto/testing/system/things/ws/PolicyImportEntriesAdditionsWsIT.java 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..6ac1e1e --- /dev/null +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyEntryImportableSubResourcesIT.java @@ -0,0 +1,388 @@ +/* + * 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.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: + * + */ +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(); + } + + // --- 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..00ed609 --- /dev/null +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/PolicyImportEntriesAdditionsIT.java @@ -0,0 +1,356 @@ +/* + * 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(); + } + + /** + * 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..b9ef4f0 --- /dev/null +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/ThingsWithImportedPoliciesEntriesAdditionsIT.java @@ -0,0 +1,266 @@ +/* + * 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.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(defaultSubject), + 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(NOT_FOUND) + .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(); + } + +} 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..20bc3d5 --- /dev/null +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/PolicyImportEntriesAdditionsWsIT.java @@ -0,0 +1,251 @@ +/* + * 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 + final Thing thing = Thing.newBuilder().setId(thingId).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) + .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(); + } + +} From 294a8992290aedb4e426f55ced32b0147ce2cc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Mon, 23 Feb 2026 11:16:31 +0100 Subject: [PATCH 2/4] fix and enhance integration tests for policy import entriesAdditions - fix templateRevokePreservedWhenResourceAdditionsOverlap: remove defaultSubject from template DEFAULT entry to avoid imported WRITE revoke blocking thing creation, and expect FORBIDDEN instead of NOT_FOUND since subject2 has READ but not WRITE - fix WS test: add policyId to CreateThing and thing:/ permissions to importing policy's ADMIN entry - add resourceAdditionGrantsWriteAccess: e2e test verifying resource additions actually grant thing WRITE access - add additionsForMultipleImportedLabels: e2e test with subject additions targeting two imported labels (DEFAULT=READ, EXTRA=WRITE) - add putPolicyImportWithMultipleResourceAdditionsAllowed: API test for multiple resources in a single EntryAddition - add putPolicyImportWithAdditionsForMultipleLabels: API test for entriesAdditions targeting multiple labels - add removingResourcesFromAllowedAdditionsRejectsNewResourceAdditions: test that narrowing allowedImportAdditions rejects resource additions Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- .../PolicyEntryImportableSubResourcesIT.java | 44 ++++++ .../rest/PolicyImportEntriesAdditionsIT.java | 77 ++++++++++ ...ithImportedPoliciesEntriesAdditionsIT.java | 141 +++++++++++++++++- .../ws/PolicyImportEntriesAdditionsWsIT.java | 5 +- 5 files changed, 265 insertions(+), 5 deletions(-) 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/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 index 6ac1e1e..6ca8da7 100644 --- 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 @@ -39,6 +39,7 @@ 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; @@ -278,6 +279,49 @@ public void removingAllowedImportAdditionsRejectsSubjectAdditions() { .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, 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 index 00ed609..2924f14 100644 --- 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 @@ -281,6 +281,83 @@ public void thingNotAccessibleWhenSubjectAdditionsNotAllowed() { .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}. 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 index b9ef4f0..19612ea 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -154,7 +155,7 @@ public void templateRevokePreservedWhenResourceAdditionsOverlap() { ImportableType.NEVER, Set.of()); final PolicyEntry defaultEntry = PoliciesModelFactory.newPolicyEntry("DEFAULT", - List.of(defaultSubject), + List.of(), List.of(PoliciesModelFactory.newResource(thingResource("/"), PoliciesModelFactory.newEffectedPermissions(List.of(READ), List.of(WRITE)))), ImportableType.IMPLICIT, @@ -208,7 +209,143 @@ public void templateRevokePreservedWhenResourceAdditionsOverlap() { .build(), org.eclipse.ditto.base.model.json.JsonSchemaVersion.V_2) .withConfiguredAuth(serviceEnv.getTestingContext2()) - .expectingHttpStatus(NOT_FOUND) + .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 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 index 20bc3d5..b3c9e77 100644 --- 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 @@ -143,8 +143,8 @@ public void retrieveThingViaWebSocketAfterSubjectAddedViaAdditions() throws Exce .build(); putPolicy(importingPolicy).expectingHttpStatus(CREATED).fire(); - // Create thing via WS with user1 - final Thing thing = Thing.newBuilder().setId(thingId).build(); + // 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() @@ -226,6 +226,7 @@ private Policy buildImportingPolicy(final PolicyId policyId) { .forLabel("ADMIN") .setSubject(defaultSubject) .setGrantedPermissions(policyResource("/"), READ, WRITE) + .setGrantedPermissions(thingResource("/"), READ, WRITE) .build(); } From 9031fc5cca2edca0b6ccea18ba1a81bd6902f86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Mon, 23 Feb 2026 12:14:49 +0100 Subject: [PATCH 3/4] update license header check to accept 2026 copyright year Co-Authored-By: Claude Opus 4.6 --- legal/headers/license-header-2025.txt | 10 ++++++++++ legal/headers/license-header.txt | 2 +- pom.xml | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 legal/headers/license-header-2025.txt 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 From 3b37e2017f305954531e2333253eebb953bd5b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Tue, 24 Feb 2026 14:03:44 +0100 Subject: [PATCH 4/4] add corner case integration tests for policy import entriesAdditions Add 7 tests covering cache invalidation, permission precedence, multi-importer scenarios, and sub-path granularity for entriesAdditions: - template revoke overrides resource addition grant - template entry deletion revokes entriesAdditions access - template policy deletion revokes imported access - multiple importers from same template are independently affected - subject retains access from own entry when entriesAdditions removed - resource addition respects sub-path granularity (attributes vs features) - resource addition without subject addition applies to template subjects Co-Authored-By: Claude Opus 4.6 --- ...ithImportedPoliciesEntriesAdditionsIT.java | 619 ++++++++++++++++++ 1 file changed, 619 insertions(+) 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 index 19612ea..695105d 100644 --- 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 @@ -354,6 +354,590 @@ public void additionsForMultipleImportedLabels() { .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) { @@ -400,4 +984,39 @@ private Policy buildImportingPolicyWithSubjectAdditions(final PolicyId policyId, .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(); + } + }