From 787c185c490ed3f988612598bdbab145b510e631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Wed, 18 Feb 2026 20:28:11 +0100 Subject: [PATCH 1/7] Add entriesAdditions and allowedImportAdditions for policy imports (#2221) Introduce `entriesAdditions` on policy imports to allow importing policies to additively merge subjects and resources into imported policy entries. Template policies control what can be extended via `allowedImportAdditions` (enum-backed, secure-by-default: empty set means no additions allowed). New model types: EntryAddition, EntriesAdditions, AllowedImportAddition enum. Write-time validation ensures entriesAdditions labels are declared in entries. Merge-time logic in PolicyImporter silently skips disallowed additions. Includes OpenAPI schema updates, documentation, and comprehensive tests. Co-Authored-By: Claude Opus 4.6 --- .../main/resources/openapi/ditto-api-2.yml | 50 +- .../policies/allowedImportAdditions.yml | 24 + .../sources/schemas/policies/policyEntry.yml | 2 + .../sources/schemas/policies/policyImport.yml | 33 +- .../resources/pages/ditto/basic-policy.md | 108 +++- .../generated/commands/modify/modifyimport.md | 22 + .../pre/PolicyImportsPreEnforcer.java | 61 +- .../pre/PolicyImportsPreEnforcerTest.java | 172 +++++- .../policies/model/AllowedImportAddition.java | 72 +++ .../ditto/policies/model/EffectedImports.java | 22 + .../policies/model/EntriesAdditions.java | 89 +++ .../ditto/policies/model/EntryAddition.java | 78 +++ .../model/ImmutableEffectedImports.java | 49 +- .../model/ImmutableEntriesAdditions.java | 146 +++++ .../model/ImmutableEntryAddition.java | 138 +++++ .../policies/model/ImmutablePolicyEntry.java | 90 ++- .../policies/model/PoliciesModelFactory.java | 74 +++ .../ditto/policies/model/PolicyEntry.java | 19 + .../ditto/policies/model/PolicyImport.java | 11 + .../ditto/policies/model/PolicyImporter.java | 81 ++- .../model/ImmutableEffectedImportsTest.java | 30 + .../model/ImmutableEntriesAdditionsTest.java | 91 +++ .../model/ImmutableEntryAdditionTest.java | 95 ++++ .../model/ImmutablePolicyEntryTest.java | 73 +++ .../model/ImmutablePolicyImportTest.java | 31 + .../policies/model/PolicyImporterTest.java | 535 +++++++++++++++++- 26 files changed, 2150 insertions(+), 46 deletions(-) create mode 100644 documentation/src/main/resources/openapi/sources/schemas/policies/allowedImportAdditions.yml create mode 100644 policies/model/src/main/java/org/eclipse/ditto/policies/model/AllowedImportAddition.java create mode 100644 policies/model/src/main/java/org/eclipse/ditto/policies/model/EntriesAdditions.java create mode 100644 policies/model/src/main/java/org/eclipse/ditto/policies/model/EntryAddition.java create mode 100644 policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEntriesAdditions.java create mode 100644 policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEntryAddition.java create mode 100644 policies/model/src/test/java/org/eclipse/ditto/policies/model/ImmutableEntriesAdditionsTest.java create mode 100644 policies/model/src/test/java/org/eclipse/ditto/policies/model/ImmutableEntryAdditionTest.java diff --git a/documentation/src/main/resources/openapi/ditto-api-2.yml b/documentation/src/main/resources/openapi/ditto-api-2.yml index 42e66448e41..c69b819d9fd 100644 --- a/documentation/src/main/resources/openapi/ditto-api-2.yml +++ b/documentation/src/main/resources/openapi/ditto-api-2.yml @@ -9725,16 +9725,44 @@ components: type: array default: [] description: |- - The policy entries to import from the referenced policy identified by their labels. - In case the field is omitted or an empty array is provided, + The policy entries to import from the referenced policy identified by their labels. + In case the field is omitted or an empty array is provided, all policy entries defined as implicit ("importable": "implicit") are imported. items: type: string description: Label of a policy entry to import from the referenced policy. + entriesAdditions: + type: object + description: |- + Optional additional subjects and/or resources to additively merge into imported policy entries. + Each key is a label of an imported policy entry. The value is an object with optional "subjects" + and/or "resources" fields that will be merged into the corresponding imported entry. + Subjects are added (existing subjects from the template are preserved). + For resources, new paths are added and overlapping paths get their permissions merged (union of + grants and revokes; template revokes are always preserved). + The imported policy entry must explicitly allow these additions via its "allowedImportAdditions" + field, otherwise the additions are rejected. + additionalProperties: + type: object + properties: + subjects: + $ref: '#/components/schemas/Subjects' + resources: + $ref: '#/components/schemas/Resources' example: entries: - default - import + entriesAdditions: + default: + subjects: + 'integration:my-connection': + type: generated + resources: + 'thing:/features': + grant: + - READ + revoke: [] Importable: type: string description: |- @@ -9768,6 +9796,22 @@ components: $ref: '#/components/schemas/Resources' importable: $ref: '#/components/schemas/Importable' + allowedImportAdditions: + type: array + description: |- + Defines which types of additions are allowed when this entry is imported by other policies + via `entriesAdditions`. If omitted or empty, no additions are allowed. This default ensures + that existing policies cannot be extended with additional subjects or resources unless the + policy author explicitly opts in. + * `subjects` — allows importing policies to add additional subjects to this entry + * `resources` — allows importing policies to add additional resources to this entry + items: + type: string + enum: + - subjects + - resources + example: + - subjects required: - subjects - resources @@ -11303,7 +11347,7 @@ components: OpenIDConnect: type: openIdConnect description: OpenID Connect Discovery URL. The placeholder is replaced by Swagger UI when configured. - openIdConnectUrl: "__OIDC_DISCOVERY_URL__" + openIdConnectUrl: __OIDC_DISCOVERY_URL__ DevOpsBasic: type: http description: Eclipse Ditto devops user (devops) + password (foobar) diff --git a/documentation/src/main/resources/openapi/sources/schemas/policies/allowedImportAdditions.yml b/documentation/src/main/resources/openapi/sources/schemas/policies/allowedImportAdditions.yml new file mode 100644 index 00000000000..437694d2945 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/policies/allowedImportAdditions.yml @@ -0,0 +1,24 @@ +# 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 +type: array +description: |- + Defines which types of additions are allowed when this entry is imported by other policies + via `entriesAdditions`. If omitted or empty, no additions are allowed. This default ensures + that existing policies cannot be extended with additional subjects or resources unless the + policy author explicitly opts in. + * `subjects` — allows importing policies to add additional subjects to this entry + * `resources` — allows importing policies to add additional resources to this entry +items: + type: string + enum: + - subjects + - resources +example: [ "subjects" ] diff --git a/documentation/src/main/resources/openapi/sources/schemas/policies/policyEntry.yml b/documentation/src/main/resources/openapi/sources/schemas/policies/policyEntry.yml index a112b125214..5bb71f11a15 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/policies/policyEntry.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/policies/policyEntry.yml @@ -17,6 +17,8 @@ properties: $ref: 'resources.yml' importable: $ref: 'importable.yml' + allowedImportAdditions: + $ref: 'allowedImportAdditions.yml' required: - subjects - resources \ No newline at end of file diff --git a/documentation/src/main/resources/openapi/sources/schemas/policies/policyImport.yml b/documentation/src/main/resources/openapi/sources/schemas/policies/policyImport.yml index d50f736bcda..b3873b36139 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/policies/policyImport.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/policies/policyImport.yml @@ -15,11 +15,38 @@ properties: type: array default: [] description: |- - The policy entries to import from the referenced policy identified by their labels. - In case the field is omitted or an empty array is provided, + The policy entries to import from the referenced policy identified by their labels. + In case the field is omitted or an empty array is provided, all policy entries defined as implicit ("importable": "implicit") are imported. items: type: string description: Label of a policy entry to import from the referenced policy. + entriesAdditions: + type: object + description: |- + Optional additional subjects and/or resources to additively merge into imported policy entries. + Each key is a label of an imported policy entry. The value is an object with optional "subjects" + and/or "resources" fields that will be merged into the corresponding imported entry. + Subjects are added (existing subjects from the template are preserved). + For resources, new paths are added and overlapping paths get their permissions merged (union of + grants and revokes; template revokes are always preserved). + The imported policy entry must explicitly allow these additions via its "allowedImportAdditions" + field, otherwise the additions are rejected. + additionalProperties: + type: object + properties: + subjects: + $ref: 'subjects.yml' + resources: + $ref: 'resources.yml' example: - entries: [ "default", "import" ] \ No newline at end of file + entries: [ "default", "import" ] + entriesAdditions: + default: + subjects: + "integration:my-connection": + type: "generated" + resources: + "thing:/features": + grant: [ "READ" ] + revoke: [] diff --git a/documentation/src/main/resources/pages/ditto/basic-policy.md b/documentation/src/main/resources/pages/ditto/basic-policy.md index 0d5dc347db7..01de9121422 100644 --- a/documentation/src/main/resources/pages/ditto/basic-policy.md +++ b/documentation/src/main/resources/pages/ditto/basic-policy.md @@ -322,7 +322,13 @@ The field can have one of the following three values: If the field is not specified, the default value is `implicit`. -Example of a policy specifying different types of `importable` entries: +Additionally, each entry can specify `allowedImportAdditions` to control what kinds of additions importing +policies are permitted to merge into this entry via `entriesAdditions`. Valid values are `"subjects"` and +`"resources"`. If the field is omitted or empty, no additions are allowed. This default is intentional: existing +policies that were created before this feature cannot be extended with additional subjects or resources through +`entriesAdditions` unless the policy author explicitly opts in by setting `allowedImportAdditions`. + +Example of a policy specifying different types of `importable` entries and allowed additions: ```json { "entries": { @@ -333,12 +339,14 @@ Example of a policy specifying different types of `importable` entries: "IMPLICIT": { "subjects": { ... }, "resources": { ... }, - "importable": "implicit" + "importable": "implicit", + "allowedImportAdditions": [ "subjects" ] }, "EXPLICIT": { "subjects": { ... }, "resources": { ... }, - "importable": "explicit" + "importable": "explicit", + "allowedImportAdditions": [ "subjects", "resources" ] }, "NEVER": { "subjects": { ... }, @@ -358,13 +366,101 @@ Example of a policy importing two other policies: "entries": { ... }, "imports": { "ditto:imported-policy" : { - // import the "EXPLICIT" entry and entries that are of importable type implicit - "entries": [ "EXPLICIT" ] + // import the "EXPLICIT" entry and entries that are of importable type implicit + "entries": [ "EXPLICIT" ] }, "ditto:another-imported-policy" : { } // import only entries that are of importable type implicit } } -``` +``` + +### Entries additions + +Optionally, the importing policy can define `entriesAdditions` to additively merge additional subjects and/or +resources into imported policy entries. This enables template-based policy reuse: the imported (template) policy +defines resources (the "what"), and the importing policy adds subjects (the "who") and optionally extends resources. + +Each key in `entriesAdditions` is the label of an imported entry. The value is an object with optional `subjects` +and/or `resources` fields: +* **Subjects** are merged additively — all subjects from the template are preserved, and the additional subjects + are added. +* **Resources** at new paths are added directly. For overlapping resource paths, permissions are merged as a union + of grants and revokes. Template revokes are always preserved and cannot be removed by additions. + +The imported policy entry must explicitly allow these additions via its `allowedImportAdditions` field. +If the entry does not allow subject additions, any `subjects` in `entriesAdditions` for that entry will be rejected. +Likewise for `resources`. This gives the template policy author full control over what importing policies can extend. + +#### Example: role-based access template for a power plant + +A central template policy defines the roles and permissions that apply to all power plants in an organization. +Each entry specifies `allowedImportAdditions: ["subjects"]` so that the individual power plant policies can add +their own employees while the centrally defined permissions remain unchanged and under central control. + +Template policy (`energy-corp:power-plant-roles`): +```json +{ + "policyId": "energy-corp:power-plant-roles", + "entries": { + "operator": { + "subjects": {}, + "resources": { + "thing:/features/reactor": { "grant": ["READ", "WRITE"], "revoke": [] }, + "thing:/features/turbine": { "grant": ["READ", "WRITE"], "revoke": [] }, + "thing:/features/cooling": { "grant": ["READ", "WRITE"], "revoke": [] } + }, + "importable": "implicit", + "allowedImportAdditions": [ "subjects" ] + }, + "safetyInspector": { + "subjects": {}, + "resources": { + "thing:/features/reactor": { "grant": ["READ"], "revoke": [] }, + "thing:/features/cooling": { "grant": ["READ"], "revoke": [] }, + "thing:/features/safetyLogs": { "grant": ["READ"], "revoke": [] } + }, + "importable": "implicit", + "allowedImportAdditions": [ "subjects" ] + } + } +} +``` + +A specific power plant imports this template and assigns its employees to the predefined roles via +`entriesAdditions`: +```json +{ + "policyId": "energy-corp:plant-springfield", + "entries": { + "admin": { + "subjects": { "oauth2:plant-springfield-admin@energy-corp.com": { "type": "employee" } }, + "resources": { "policy:/": { "grant": ["READ", "WRITE"], "revoke": [] } } + } + }, + "imports": { + "energy-corp:power-plant-roles": { + "entriesAdditions": { + "operator": { + "subjects": { + "oauth2:homer.simpson@energy-corp.com": { "type": "employee" }, + "oauth2:lenny.leonard@energy-corp.com": { "type": "employee" } + } + }, + "safetyInspector": { + "subjects": { + "oauth2:frank.grimes@energy-corp.com": { "type": "employee" } + } + } + } + } + } +} +``` + +With this setup the operator subjects (`homer.simpson`, `lenny.leonard`) receive READ and WRITE access to the +reactor, turbine, and cooling features, while the safety inspector (`frank.grimes`) receives READ-only access to +reactor, cooling, and safety logs — all defined centrally. If the organization later adds a new resource to the +`operator` role in the template, every power plant that imports it automatically inherits the change. A subject creating or modifying a policy with policy imports must have the following permissions: * permission on the _importing policy_ to `WRITE` the modified policy import or policy imports diff --git a/documentation/src/main/resources/pages/ditto/protocol/examples/policies/generated/commands/modify/modifyimport.md b/documentation/src/main/resources/pages/ditto/protocol/examples/policies/generated/commands/modify/modifyimport.md index 6a6616d843d..57f6ff6111d 100644 --- a/documentation/src/main/resources/pages/ditto/protocol/examples/policies/generated/commands/modify/modifyimport.md +++ b/documentation/src/main/resources/pages/ditto/protocol/examples/policies/generated/commands/modify/modifyimport.md @@ -12,3 +12,25 @@ } } ``` + +### With entries additions + +```json +{ + "topic": "org.eclipse.ditto/the_policy_id/policies/commands/modify", + "headers": { + "correlation-id": "" + }, + "path": "/imports/org.eclipse.ditto:imported-policy", + "value": { + "entries" : [ "IMPORTED_ENTRY" ], + "entriesAdditions": { + "IMPORTED_ENTRY": { + "subjects": { + "integration:my-connection": { "type": "generated" } + } + } + } + } +} +``` diff --git a/policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcer.java b/policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcer.java index d21a8b44ea2..559280cbe86 100644 --- a/policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcer.java +++ b/policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcer.java @@ -12,6 +12,7 @@ */ package org.eclipse.ditto.policies.enforcement.pre; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -30,13 +31,19 @@ import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProviderExtension; +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.ImportedLabels; 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.PolicyImportInvalidException; import org.eclipse.ditto.policies.model.ResourceKey; import org.eclipse.ditto.policies.model.enforcers.Enforcer; import org.eclipse.ditto.policies.model.signals.commands.exceptions.PolicyNotAccessibleException; @@ -155,8 +162,58 @@ private boolean authorize(final PolicyModifyCommand command, final PolicyEnfo } if (!hasAccess) { throw errorForPolicyModifyCommand(policyImport); - } else { - return true; + } + + // validate entriesAdditions against the imported policy's allowedImportAdditions + validateEntriesAdditions(policyImport, importedPolicy); + + return true; + } + + private static void validateEntriesAdditions(final PolicyImport policyImport, final Policy importedPolicy) { + final Optional additionsOpt = policyImport.getEntriesAdditions(); + if (!additionsOpt.isPresent()) { + return; + } + final ImportedLabels declaredEntries = policyImport.getEffectedImports() + .map(EffectedImports::getImportedLabels) + .orElse(ImportedLabels.none()); + final EntriesAdditions additions = additionsOpt.get(); + for (final EntryAddition addition : additions) { + final Label label = addition.getLabel(); + if (!declaredEntries.contains(label)) { + throw PolicyImportInvalidException.newBuilder() + .message("The policy import for '" + policyImport.getImportedPolicyId() + + "' contains entriesAdditions for entry '" + label + + "' which is not listed in 'entries'.") + .description("Every label used in 'entriesAdditions' must also be declared in the " + + "'entries' array of the policy import.") + .build(); + } + final Optional entryOpt = importedPolicy.getEntryFor(label); + if (!entryOpt.isPresent()) { + // entry doesn't exist in imported policy — will be silently ignored at merge time + continue; + } + final Set allowed = entryOpt.get().getAllowedImportAdditions(); + if (addition.getSubjects().isPresent() && + !allowed.contains(AllowedImportAddition.SUBJECTS)) { + throw PolicyImportInvalidException.newBuilder() + .message("The policy import for '" + policyImport.getImportedPolicyId() + + "' contains disallowed subject additions for entry '" + label + "'.") + .description("The imported policy entry '" + label + + "' does not allow subject additions. Its 'allowedImportAdditions' is: " + allowed) + .build(); + } + if (addition.getResources().isPresent() && + !allowed.contains(AllowedImportAddition.RESOURCES)) { + throw PolicyImportInvalidException.newBuilder() + .message("The policy import for '" + policyImport.getImportedPolicyId() + + "' contains disallowed resource additions for entry '" + label + "'.") + .description("The imported policy entry '" + label + + "' does not allow resource additions. Its 'allowedImportAdditions' is: " + allowed) + .build(); + } } } diff --git a/policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcerTest.java b/policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcerTest.java index e865ea33038..d62ce7c1ce0 100644 --- a/policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcerTest.java +++ b/policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/pre/PolicyImportsPreEnforcerTest.java @@ -46,11 +46,13 @@ import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; import org.eclipse.ditto.policies.model.EffectedImports; +import org.eclipse.ditto.policies.model.EntriesAdditions; 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.PolicyId; import org.eclipse.ditto.policies.model.PolicyImport; +import org.eclipse.ditto.policies.model.PolicyImportInvalidException; import org.eclipse.ditto.policies.model.PolicyImports; import org.eclipse.ditto.policies.model.signals.commands.exceptions.PolicyNotAccessibleException; import org.eclipse.ditto.policies.model.signals.commands.modify.CreatePolicy; @@ -91,6 +93,9 @@ void setUp() { .thenReturn(CompletableFuture.completedFuture(Optional.of(PolicyEnforcer.of(IMPORTING)))); when(policyEnforcerProvider.getPolicyEnforcer(IMPORT_NOT_FOUND_POLICY_ID)) .thenReturn(CompletableFuture.completedFuture(Optional.of(PolicyEnforcer.of(IMPORT_NOT_FOUND)))); + when(policyEnforcerProvider.getPolicyEnforcer(Policies.IMPORTED_WITH_ADDITIONS_POLICY_ID)) + .thenReturn(CompletableFuture.completedFuture( + Optional.of(PolicyEnforcer.of(Policies.IMPORTED_WITH_ADDITIONS)))); when(policyEnforcerProvider.getPolicyEnforcer(argThat(id -> !KNOWN_IDS.contains(id)))) .thenReturn(CompletableFuture.completedFuture(Optional.empty())); @@ -131,6 +136,142 @@ void testEnforcerOfImportedPolicyNotFound() { .withCauseInstanceOf(PolicyNotAccessibleException.class); } + @Test + void testDisallowedSubjectAdditionsRejected() { + // IMPORTED policy's IMPLICIT entry does NOT have allowedImportAdditions=["subjects"] + // so adding subjects via entriesAdditions should be rejected + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder() + .authorizationContext(AuthorizationModelFactory.newAuthContext( + DittoAuthorizationContextType.UNSPECIFIED, + java.util.Collections.singletonList(AuthorizationSubject.newInstance("ditto:implicit")))) + .build(); + + final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions( + java.util.Collections.singletonList( + PoliciesModelFactory.newEntryAddition(Label.of("IMPLICIT"), + PoliciesModelFactory.newSubjects( + PoliciesModelFactory.newSubject( + PoliciesModelFactory.newSubjectId("ditto:extra"), + PoliciesModelFactory.newSubjectType("test"))), + null))); + + final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels( + java.util.Collections.singletonList(Label.of("IMPLICIT")), additions); + + final PolicyImport policyImport = PolicyImport.newInstance(IMPORTED_POLICY_ID, effectedImports); + + final ModifyPolicyImport command = ModifyPolicyImport.of(IMPORTING_POLICY_ID, policyImport, dittoHeaders); + + final CompletableFuture> applyFuture = policyImportsPreEnforcer.apply(command).toCompletableFuture(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(applyFuture::join) + .withCauseInstanceOf(PolicyImportInvalidException.class) + .withMessageContaining("subject additions"); + } + + @Test + void testDisallowedResourceAdditionsRejected() { + // IMPORTED policy's IMPLICIT entry does NOT have allowedImportAdditions=["resources"] + // so adding resources via entriesAdditions should be rejected + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder() + .authorizationContext(AuthorizationModelFactory.newAuthContext( + DittoAuthorizationContextType.UNSPECIFIED, + java.util.Collections.singletonList(AuthorizationSubject.newInstance("ditto:implicit")))) + .build(); + + final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions( + java.util.Collections.singletonList( + PoliciesModelFactory.newEntryAddition(Label.of("IMPLICIT"), + null, + PoliciesModelFactory.newResources( + PoliciesModelFactory.newResource("thing", "/features", + PoliciesModelFactory.newEffectedPermissions( + java.util.Collections.singletonList("READ"), + java.util.Collections.emptyList())))))); + + final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels( + java.util.Collections.singletonList(Label.of("IMPLICIT")), additions); + + final PolicyImport policyImport = PolicyImport.newInstance(IMPORTED_POLICY_ID, effectedImports); + + final ModifyPolicyImport command = ModifyPolicyImport.of(IMPORTING_POLICY_ID, policyImport, dittoHeaders); + + final CompletableFuture> applyFuture = policyImportsPreEnforcer.apply(command).toCompletableFuture(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(applyFuture::join) + .withCauseInstanceOf(PolicyImportInvalidException.class) + .withMessageContaining("resource additions"); + } + + @Test + void testAllowedAdditionsPassValidation() { + // Use IMPORTED_WITH_ADDITIONS policy that allows subject additions on IMPLICIT entry + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder() + .authorizationContext(AuthorizationModelFactory.newAuthContext( + DittoAuthorizationContextType.UNSPECIFIED, + java.util.Collections.singletonList(AuthorizationSubject.newInstance("ditto:implicit")))) + .build(); + + final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions( + java.util.Collections.singletonList( + PoliciesModelFactory.newEntryAddition(Label.of("IMPLICIT"), + PoliciesModelFactory.newSubjects( + PoliciesModelFactory.newSubject( + PoliciesModelFactory.newSubjectId("ditto:extra"), + PoliciesModelFactory.newSubjectType("test"))), + null))); + + final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels( + java.util.Collections.singletonList(Label.of("IMPLICIT")), additions); + + final PolicyImport policyImport = + PolicyImport.newInstance(Policies.IMPORTED_WITH_ADDITIONS_POLICY_ID, effectedImports); + + final ModifyPolicyImport command = + ModifyPolicyImport.of(IMPORTING_POLICY_ID, policyImport, dittoHeaders); + + final CompletableFuture> applyFuture = policyImportsPreEnforcer.apply(command).toCompletableFuture(); + + final Signal signal = applyFuture.join(); + assertThat(signal).isSameAs(command); + } + + @Test + void testEntriesAdditionsForEntryNotInEntriesArrayIsRejected() { + // Additions referencing a label not listed in the 'entries' array must be rejected + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder() + .authorizationContext(AuthorizationModelFactory.newAuthContext( + DittoAuthorizationContextType.UNSPECIFIED, + java.util.Collections.singletonList(AuthorizationSubject.newInstance("ditto:implicit")))) + .build(); + + final EntriesAdditions additions = PoliciesModelFactory.newEntriesAdditions( + java.util.Collections.singletonList( + PoliciesModelFactory.newEntryAddition(Label.of("NOT_IN_ENTRIES"), + PoliciesModelFactory.newSubjects( + PoliciesModelFactory.newSubject( + PoliciesModelFactory.newSubjectId("ditto:extra"), + PoliciesModelFactory.newSubjectType("test"))), + null))); + + final EffectedImports effectedImports = PoliciesModelFactory.newEffectedImportedLabels( + java.util.Collections.emptyList(), additions); + + final PolicyImport policyImport = PolicyImport.newInstance(IMPORTED_POLICY_ID, effectedImports); + + final ModifyPolicyImport command = ModifyPolicyImport.of(IMPORTING_POLICY_ID, policyImport, dittoHeaders); + + final CompletableFuture> applyFuture = policyImportsPreEnforcer.apply(command).toCompletableFuture(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(applyFuture::join) + .withCauseInstanceOf(PolicyImportInvalidException.class) + .withMessageContaining("NOT_IN_ENTRIES") + .withMessageContaining("not listed in 'entries'"); + } + static class PolicyModifyCommandsProvider implements ArgumentsProvider { public static final Set> ALL_SUBJECTS = @@ -302,6 +443,32 @@ static class Policies { } } """); + static final Policy IMPORTED_WITH_ADDITIONS = PoliciesModelFactory.newPolicy(""" + { + "policyId": "test:imported.with.additions", + "entries" : { + "DEFAULT" : { + "subjects": { + "ditto:admin" : { "type": "test" } + }, + "resources": { + "policy:/": { "grant": [ "READ", "WRITE" ], "revoke": [] } + }, + "importable":"never" + }, + "IMPLICIT" : { + "subjects": { + "ditto:implicit" : { "type": "test" } + }, + "resources": { + "policy:/entries/IMPLICIT": { "grant": [ "READ" ], "revoke": [] } + }, + "importable": "implicit", + "allowedImportAdditions": [ "subjects" ] + } + } + } + """); static final Policy IMPORT_NOT_FOUND = PoliciesModelFactory.newPolicy(""" { "policyId": "test:import.not.found", @@ -322,9 +489,12 @@ static class Policies { """); static final PolicyId IMPORTING_POLICY_ID = IMPORTING.getEntityId().orElseThrow(); static final PolicyId IMPORTED_POLICY_ID = IMPORTED.getEntityId().orElseThrow(); + static final PolicyId IMPORTED_WITH_ADDITIONS_POLICY_ID = + IMPORTED_WITH_ADDITIONS.getEntityId().orElseThrow(); static final PolicyId IMPORT_NOT_FOUND_POLICY_ID = IMPORT_NOT_FOUND.getEntityId().orElseThrow(); static final Collection KNOWN_IDS = - List.of(IMPORTED_POLICY_ID, IMPORTING_POLICY_ID, IMPORT_NOT_FOUND_POLICY_ID); + List.of(IMPORTED_POLICY_ID, IMPORTING_POLICY_ID, IMPORT_NOT_FOUND_POLICY_ID, + IMPORTED_WITH_ADDITIONS_POLICY_ID); } } \ No newline at end of file diff --git a/policies/model/src/main/java/org/eclipse/ditto/policies/model/AllowedImportAddition.java b/policies/model/src/main/java/org/eclipse/ditto/policies/model/AllowedImportAddition.java new file mode 100644 index 00000000000..7d5777c34d4 --- /dev/null +++ b/policies/model/src/main/java/org/eclipse/ditto/policies/model/AllowedImportAddition.java @@ -0,0 +1,72 @@ +/* + * 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.policies.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Determines which types of additions are allowed when a policy entry is imported by other policies + * via {@code entriesAdditions}. + * + * @since 3.9.0 + */ +public enum AllowedImportAddition { + + /** + * Allows importing policies to add additional subjects to this entry. + */ + SUBJECTS("subjects"), + + /** + * Allows importing policies to add additional resources to this entry. + */ + RESOURCES("resources"); + + private final String name; + + AllowedImportAddition(final String name) { + this.name = name; + } + + /** + * Returns the name of this allowed import addition type as used in JSON serialization. + * + * @return the name. + */ + public String getName() { + return name; + } + + @Override + public String toString() { + return name; + } + + /** + * Returns the {@code AllowedImportAddition} matching the given name. + * + * @param name the name to look up. + * @return the matching {@code AllowedImportAddition}, or empty if no match. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()) + .filter(c -> c.name.contentEquals(name)) + .findFirst(); + } + +} diff --git a/policies/model/src/main/java/org/eclipse/ditto/policies/model/EffectedImports.java b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EffectedImports.java index b40cd8a5ebc..3605bfd7b77 100644 --- a/policies/model/src/main/java/org/eclipse/ditto/policies/model/EffectedImports.java +++ b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EffectedImports.java @@ -12,6 +12,8 @@ */ package org.eclipse.ditto.policies.model; +import java.util.Optional; + import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; @@ -62,6 +64,17 @@ default JsonSchemaVersion[] getSupportedSchemaVersions() { */ ImportedLabels getImportedLabels(); + /** + * Returns the optional {@link EntriesAdditions} defining additional subjects and/or resources to merge into + * imported policy entries. + * + * @return the entries additions, or empty if none are defined. + * @since 3.9.0 + */ + default Optional getEntriesAdditions() { + return Optional.empty(); + } + /** * Returns all non-hidden marked fields of this EffectedImports. * @@ -89,6 +102,15 @@ final class JsonFields { public static final JsonFieldDefinition ENTRIES = JsonFactory.newJsonArrayFieldDefinition("entries", FieldType.REGULAR, JsonSchemaVersion.V_2); + /** + * JSON field containing additional subjects and/or resources to merge into imported entries. + * + * @since 3.9.0 + */ + public static final JsonFieldDefinition ENTRIES_ADDITIONS = + JsonFactory.newJsonObjectFieldDefinition("entriesAdditions", FieldType.REGULAR, + JsonSchemaVersion.V_2); + private JsonFields() { throw new AssertionError(); } diff --git a/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntriesAdditions.java b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntriesAdditions.java new file mode 100644 index 00000000000..f0973ba3833 --- /dev/null +++ b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntriesAdditions.java @@ -0,0 +1,89 @@ +/* + * 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.policies.model; + +import java.util.Optional; +import java.util.stream.Stream; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldSelector; +import org.eclipse.ditto.json.JsonObject; + +/** + * A collection of {@link EntryAddition}s keyed by {@link Label}, defining additional subjects and/or resources + * to merge into imported policy entries. + * + * @since 3.9.0 + */ +public interface EntriesAdditions extends Iterable, + Jsonifiable.WithFieldSelectorAndPredicate { + + /** + * Returns the {@link EntryAddition} for the given label, if present. + * + * @param label the label to look up. + * @return the addition for the given label, or empty. + * @throws NullPointerException if {@code label} is {@code null}. + */ + Optional getAddition(Label label); + + /** + * Returns the number of entries additions. + * + * @return the size. + */ + int getSize(); + + /** + * Indicates whether this collection is empty. + * + * @return {@code true} if empty, {@code false} otherwise. + */ + boolean isEmpty(); + + /** + * Returns a sequential stream of the contained {@link EntryAddition}s. + * + * @return a stream of entry additions. + */ + Stream stream(); + + /** + * EntriesAdditions is only available in JsonSchemaVersion V_2. + * + * @return the supported JsonSchemaVersions. + */ + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; + } + + /** + * Returns all non-hidden marked fields of this EntriesAdditions. + * + * @return a JSON object representation including only non-hidden marked fields. + */ + @Override + default JsonObject toJson() { + return toJson(FieldType.notHidden()); + } + + @Override + default JsonObject toJson(final JsonSchemaVersion schemaVersion, final JsonFieldSelector fieldSelector) { + return toJson(schemaVersion, FieldType.regularOrSpecial()).get(fieldSelector); + } + +} diff --git a/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntryAddition.java b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntryAddition.java new file mode 100644 index 00000000000..bfc3ef41eaf --- /dev/null +++ b/policies/model/src/main/java/org/eclipse/ditto/policies/model/EntryAddition.java @@ -0,0 +1,78 @@ +/* + * 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.policies.model; + +import java.util.Optional; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldSelector; +import org.eclipse.ditto.json.JsonObject; + +/** + * Represents additional {@link Subjects} and/or {@link Resources} to be merged into an imported policy entry + * identified by its {@link Label}. + * + * @since 3.9.0 + */ +public interface EntryAddition extends Jsonifiable.WithFieldSelectorAndPredicate { + + /** + * Returns the {@link Label} of the imported policy entry this addition applies to. + * + * @return the label. + */ + Label getLabel(); + + /** + * Returns the optional additional {@link Subjects} to merge into the imported entry. + * + * @return the additional subjects, or empty if none. + */ + Optional getSubjects(); + + /** + * Returns the optional additional {@link Resources} to merge into the imported entry. + * + * @return the additional resources, or empty if none. + */ + Optional getResources(); + + /** + * EntryAddition is only available in JsonSchemaVersion V_2. + * + * @return the supported JsonSchemaVersions of EntryAddition. + */ + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; + } + + /** + * Returns all non-hidden marked fields of this EntryAddition. + * + * @return a JSON object representation of this EntryAddition including only non-hidden marked fields. + */ + @Override + default JsonObject toJson() { + return toJson(FieldType.notHidden()); + } + + @Override + default JsonObject toJson(final JsonSchemaVersion schemaVersion, final JsonFieldSelector fieldSelector) { + return toJson(schemaVersion, FieldType.regularOrSpecial()).get(fieldSelector); + } + +} diff --git a/policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEffectedImports.java b/policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEffectedImports.java index 3149400bcc0..4f896a9467c 100644 --- a/policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEffectedImports.java +++ b/policies/model/src/main/java/org/eclipse/ditto/policies/model/ImmutableEffectedImports.java @@ -18,10 +18,12 @@ import java.util.Collection; import java.util.LinkedHashSet; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import org.eclipse.ditto.base.model.json.JsonSchemaVersion; @@ -29,6 +31,7 @@ import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.json.JsonField; import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; import org.eclipse.ditto.json.JsonValue; /** @@ -38,9 +41,12 @@ final class ImmutableEffectedImports implements EffectedImports { private final ImportedLabels importedLabels; + @Nullable private final EntriesAdditions entriesAdditions; - private ImmutableEffectedImports(final ImportedLabels importedLabels) { + private ImmutableEffectedImports(final ImportedLabels importedLabels, + @Nullable final EntriesAdditions entriesAdditions) { this.importedLabels = importedLabels; + this.entriesAdditions = entriesAdditions; } /** @@ -51,11 +57,25 @@ private ImmutableEffectedImports(final ImportedLabels importedLabels) { * @throws NullPointerException if any argument is {@code null}. */ public static EffectedImports of(final Iterable