Skip to content

Commit ef43dd8

Browse files
committed
#1638 add wildcard support
1 parent e785b63 commit ef43dd8

File tree

9 files changed

+331
-25
lines changed

9 files changed

+331
-25
lines changed

deployment/helm/ditto/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: |
1616
A digital twin is a virtual, cloud based, representation of his real world counterpart
1717
(real world “Things”, e.g. devices like sensors, smart heating, connected cars, smart grids, EV charging stations etc).
1818
type: application
19-
version: 3.8.16 # chart version is effectively set by release-job
19+
version: 3.8.17 # chart version is effectively set by release-job
2020
appVersion: 3.8.12
2121
keywords:
2222
- iot-chart
Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
# namespace root policies
2-
# Maps namespaces to lists of policy IDs whose implicit entries are transparently merged
3-
# into every policy in that namespace when building policy enforcers.
2+
# Maps namespace patterns to lists of policy IDs whose implicit entries are transparently merged
3+
# into every matching policy's enforcer at build time.
4+
#
5+
# Pattern syntax (key side):
6+
# - exact string e.g. "org.example.devices" matches only that namespace
7+
# - prefix wildcard e.g. "org.example.*" matches any namespace starting with "org.example."
8+
# - catch-all "*" matches every namespace
49
#
510
# Rules:
611
# - only entries with importable = "implicit" are merged; "explicit" and "never" are skipped.
712
# - local policy entries always win on label conflicts (namespace root cannot override local entries)
13+
# - matching patterns are applied in this precedence: exact > more specific prefix wildcard > less specific prefix wildcard > "*"
14+
# - unsupported wildcard syntax is rejected at config load time
815
# - if a configured root policy is missing or deleted, its entries are skipped and an ERROR is logged
16+
# - changing a root policy triggers cache invalidation for all cached policies in matching namespaces
917
# - this config is read by all services that perform policy enforcement (policies, things)
1018
#
11-
# Example:
12-
# "org.example.devices" = ["org.example:tenant-root"]
13-
# "org.example.sensors" = ["org.example:tenant-root", "org.example:audit-policy"]
14-
#
1519
# For Helm deployments, configure this via the policies.config.namespacePolicies and
1620
# things.config.namespacePolicies values, which are rendered into the respective
1721
# service extension config files.
1822
ditto.policies.namespace-policies {
19-
// "org.example.devices" = ["org.example:tenant-root"]
20-
// "org.example.sensors" = ["org.example:tenant-root", "org.example:audit-policy"]
23+
// "org.example.devices" = ["org.example:tenant-root"] # exact namespace
24+
// "org.example.devices.*" = ["org.example:devices-root"] # more specific prefix wildcard
25+
// "org.example.*" = ["org.example:tenant-root"] # broader prefix wildcard
26+
// "*" = ["root:catch-all-policy"] # every namespace
2127
}

policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/PolicyEnforcer.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public static CompletionStage<PolicyEnforcer> withResolvedImports(final Policy p
6868
* Creates a policy enforcer from a policy, resolving both its explicit imports and any namespace root policies
6969
* configured for the policy's namespace. Namespace root policies are resolved with their own imports and their
7070
* implicit entries are merged last with local entries taking precedence on label conflicts.
71+
* Matching namespace roots are applied in config precedence order: exact match first, then more
72+
* specific prefix wildcards, then broader prefix wildcards, and finally {@code *}.
7173
* <p>
7274
* This is the preferred factory method to use in the cache loader. Namespace root policy resolution bypasses
7375
* the normal READ permission pre-enforcer check, since namespace policies are operator-configured and injected

policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/PolicyEnforcerCache.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ private boolean invalidateNamespaceDependents(final PolicyId policyId,
147147
}
148148

149149
return delegate.asMap().keySet().stream()
150-
.filter(cachedPolicyId -> affectedNamespaces.contains(cachedPolicyId.getNamespace()))
150+
.filter(cachedPolicyId -> affectedNamespaces.stream()
151+
.anyMatch(pattern -> NamespacePoliciesConfig.namespaceMatchesPattern(
152+
cachedPolicyId.getNamespace(), pattern)))
151153
.map(invalidateFunction)
152154
.reduce((a, b) -> a || b)
153155
.orElse(false);

policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/config/DefaultNamespacePoliciesConfig.java

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import java.util.ArrayList;
1616
import java.util.Collections;
17+
import java.util.Comparator;
1718
import java.util.HashMap;
1819
import java.util.HashSet;
1920
import java.util.List;
@@ -23,6 +24,8 @@
2324

2425
import javax.annotation.concurrent.Immutable;
2526

27+
import org.eclipse.ditto.base.model.entity.id.RegexPatterns;
28+
import org.eclipse.ditto.internal.utils.config.DittoConfigError;
2629
import org.eclipse.ditto.policies.model.PolicyId;
2730

2831
import com.typesafe.config.Config;
@@ -34,13 +37,24 @@
3437
* Default implementation of {@link NamespacePoliciesConfig}.
3538
* <p>
3639
* Reads from the HOCON config path {@value #CONFIG_PATH}, which must be a config object mapping
37-
* namespace strings to lists of policy ID strings:
40+
* namespace patterns to lists of policy ID strings. Keys may be exact namespaces, prefix wildcards,
41+
* or {@code *} to match every namespace:
3842
* <pre>{@code
3943
* ditto.policies.namespace-policies {
40-
* "org.example.devices" = ["org.example:tenant-root"]
44+
* "org.example.devices" = ["org.example:tenant-root"] # exact namespace
4145
* "org.example.sensors" = ["org.example:tenant-root", "org.example:audit-policy"]
46+
* "org.example.*" = ["org.example:tenant-root"] # all namespaces under org.example
47+
* "*" = ["root:catch-all-policy"] # every namespace
4248
* }
4349
* }</pre>
50+
* <p>
51+
* Matching patterns are resolved in deterministic precedence order:
52+
* exact match first, then prefix wildcards ordered from most specific to least specific,
53+
* and finally {@code *}.
54+
* </p>
55+
* <p>
56+
* See {@link NamespacePoliciesConfig#namespaceMatchesPattern} for the supported pattern syntax.
57+
* </p>
4458
*/
4559
@Immutable
4660
public final class DefaultNamespacePoliciesConfig implements NamespacePoliciesConfig {
@@ -49,11 +63,15 @@ public final class DefaultNamespacePoliciesConfig implements NamespacePoliciesCo
4963

5064
private final Map<String, List<PolicyId>> forwardMap;
5165
private final Map<PolicyId, Set<String>> reverseMap;
66+
private final List<String> sortedPatterns;
5267

5368
private DefaultNamespacePoliciesConfig(final Map<String, List<PolicyId>> forwardMap,
5469
final Map<PolicyId, Set<String>> reverseMap) {
5570
this.forwardMap = toUnmodifiableForwardMap(forwardMap);
5671
this.reverseMap = toUnmodifiableReverseMap(reverseMap);
72+
this.sortedPatterns = this.forwardMap.keySet().stream()
73+
.sorted(Comparator.comparingInt(DefaultNamespacePoliciesConfig::patternPrecedence).reversed())
74+
.toList();
5775
}
5876

5977
/**
@@ -62,6 +80,7 @@ private DefaultNamespacePoliciesConfig(final Map<String, List<PolicyId>> forward
6280
*
6381
* @param config the root config (from {@code actorSystem.settings().config()}).
6482
* @return the parsed instance.
83+
* @throws DittoConfigError if any configured namespace pattern is invalid.
6584
*/
6685
public static DefaultNamespacePoliciesConfig of(final Config config) {
6786
if (!config.hasPath(CONFIG_PATH)) {
@@ -75,6 +94,7 @@ public static DefaultNamespacePoliciesConfig of(final Config config) {
7594
for (final Map.Entry<String, ConfigValue> entry : nsPoliciesConfig.root().entrySet()) {
7695
final String namespace = entry.getKey();
7796
final ConfigValue value = entry.getValue();
97+
patternPrecedence(namespace);
7898

7999
if (value.valueType() != ConfigValueType.LIST) {
80100
continue;
@@ -103,7 +123,10 @@ public Map<String, List<PolicyId>> getNamespacePolicies() {
103123

104124
@Override
105125
public List<PolicyId> getRootPoliciesForNamespace(final String namespace) {
106-
return forwardMap.getOrDefault(namespace, List.of());
126+
return sortedPatterns.stream()
127+
.filter(p -> NamespacePoliciesConfig.namespaceMatchesPattern(namespace, p))
128+
.flatMap(p -> forwardMap.get(p).stream())
129+
.toList();
107130
}
108131

109132
@Override
@@ -139,6 +162,22 @@ public String toString() {
139162
return getClass().getSimpleName() + " [namespacePolicies=" + forwardMap + "]";
140163
}
141164

165+
166+
private static int patternPrecedence(final String pattern) {
167+
if ("*".equals(pattern)) {
168+
return 0;
169+
}
170+
if (pattern.endsWith(".*") &&
171+
RegexPatterns.NAMESPACE_PATTERN.matcher(pattern.substring(0, pattern.length() - 2)).matches()) {
172+
return pattern.length();
173+
}
174+
if (RegexPatterns.NAMESPACE_PATTERN.matcher(pattern).matches()) {
175+
return Integer.MAX_VALUE;
176+
}
177+
throw new DittoConfigError("Unsupported namespace policy pattern <" + pattern + "> at config path <" +
178+
CONFIG_PATH + ">. Supported syntax is exact namespace, prefix wildcard '<namespace>.*', or '*'.");
179+
}
180+
142181
private static Map<String, List<PolicyId>> toUnmodifiableForwardMap(final Map<String, List<PolicyId>> source) {
143182
final Map<String, List<PolicyId>> result = new HashMap<>();
144183
source.forEach((namespace, policyIds) -> result.put(namespace, Collections.unmodifiableList(

policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/config/NamespacePoliciesConfig.java

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,50 +21,66 @@
2121
import org.eclipse.ditto.policies.model.PolicyId;
2222

2323
/**
24-
* Configuration mapping namespaces to a set of "namespace root" policy IDs whose implicit entries are
25-
* automatically merged into every policy belonging to that namespace during enforcer resolution.
24+
* Configuration mapping namespace patterns to a set of "namespace root" policy IDs whose implicit
25+
* entries are automatically merged into every policy belonging to a matching namespace during enforcer
26+
* resolution.
2627
* <p>
2728
* This allows operators to define tenant/namespace-wide access policies without requiring individual
2829
* policies to declare explicit imports. The injection happens transparently at enforcer-build time, so
2930
* the stored policy's {@code imports} field is never modified.
3031
* </p>
32+
* <p>
33+
* Namespace patterns support the following syntax:
34+
* <ul>
35+
* <li>{@code *} — matches every namespace</li>
36+
* <li>{@code org.example.*} — matches any namespace whose name starts with {@code org.example.}</li>
37+
* <li>exact string — matches only that exact namespace</li>
38+
* </ul>
39+
* Matching namespace roots are applied in deterministic precedence order:
40+
* exact match first, then prefix wildcards ordered from most specific to least specific, and finally
41+
* the catch-all pattern {@code *}.
42+
* </p>
3143
*
3244
* @since 3.9.0
3345
*/
3446
@Immutable
3547
public interface NamespacePoliciesConfig {
3648

3749
/**
38-
* Returns the full mapping of namespace to list of namespace root policy IDs.
50+
* Returns the full mapping of namespace patterns to list of namespace root policy IDs.
51+
* Map keys may be exact namespaces or wildcard patterns (e.g. {@code org.example.*}).
3952
*
40-
* @return immutable map of namespace string to list of PolicyIds.
53+
* @return immutable map of namespace pattern to list of PolicyIds.
4154
*/
4255
Map<String, List<PolicyId>> getNamespacePolicies();
4356

4457
/**
45-
* Returns the list of namespace root policy IDs configured for the given {@code namespace}.
46-
* Returns an empty list if no namespace root policies are configured for the namespace.
58+
* Returns the combined list of namespace root policy IDs whose patterns match the given
59+
* {@code namespace}. Returns an empty list if no pattern matches.
60+
* Matching patterns are resolved in deterministic precedence order: exact match first, then
61+
* prefix wildcards ordered from most specific to least specific, and finally {@code *}.
4762
*
48-
* @param namespace the namespace to look up.
63+
* @param namespace the concrete namespace to look up (not a pattern).
4964
* @return list of PolicyIds acting as namespace roots for the given namespace, never null.
5065
*/
5166
List<PolicyId> getRootPoliciesForNamespace(String namespace);
5267

5368
/**
54-
* Returns all policy IDs that are configured as namespace root policies across all namespaces.
55-
* Used to build the cache invalidation reverse map.
69+
* Returns all policy IDs that are configured as namespace root policies across all patterns.
70+
* Used to short-circuit cache invalidation checks.
5671
*
5772
* @return set of all namespace root PolicyIds.
5873
*/
5974
Set<PolicyId> getAllNamespaceRootPolicyIds();
6075

6176
/**
62-
* Returns the set of namespaces that the given {@code rootPolicyId} covers.
77+
* Returns the set of namespace patterns that the given {@code rootPolicyId} covers.
78+
* Patterns may be exact namespaces or wildcards (e.g. {@code org.example.*} or {@code *}).
6379
* Used during cache invalidation: when a namespace root policy changes, all cached policies
64-
* in its covered namespaces must be invalidated.
80+
* whose namespace matches any returned pattern must be invalidated.
6581
*
6682
* @param rootPolicyId the policy ID of a namespace root policy.
67-
* @return set of namespaces covered by the given root policy, empty if none.
83+
* @return set of namespace patterns covered by the given root policy, empty if none.
6884
*/
6985
Set<String> getNamespacesForRootPolicy(PolicyId rootPolicyId);
7086

@@ -75,4 +91,27 @@ public interface NamespacePoliciesConfig {
7591
*/
7692
boolean isEmpty();
7793

94+
/**
95+
* Returns whether the given concrete {@code namespace} matches the given {@code pattern}.
96+
* <ul>
97+
* <li>{@code *} matches any namespace.</li>
98+
* <li>{@code org.example.*} matches any namespace starting with {@code org.example.}</li>
99+
* <li>Any other value is treated as an exact match.</li>
100+
* </ul>
101+
*
102+
* @param namespace the concrete namespace string to test.
103+
* @param pattern the pattern from the config (exact, prefix wildcard, or {@code *}).
104+
* @return {@code true} if {@code namespace} matches {@code pattern}.
105+
* @since 3.9.0
106+
*/
107+
static boolean namespaceMatchesPattern(final String namespace, final String pattern) {
108+
if ("*".equals(pattern)) {
109+
return true;
110+
}
111+
if (pattern.endsWith(".*")) {
112+
return namespace.startsWith(pattern.substring(0, pattern.length() - 1));
113+
}
114+
return namespace.equals(pattern);
115+
}
116+
78117
}

policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/PolicyEnforcerCacheTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,55 @@ public void policyTagInvalidatesCachedPoliciesInNamespacesOfChangedRootPolicy()
172172
}};
173173
}
174174

175+
@Test
176+
public void wildcardNamespacePatternInvalidatesCachedPoliciesInMatchingNamespaces() throws Exception {
177+
final AsyncCacheLoader<PolicyId, Entry<PolicyEnforcer>> cacheLoader = mock(AsyncCacheLoader.class);
178+
final ExecutionContextExecutor executor = actorSystem.dispatcher();
179+
final PolicyId rootPolicyId = PolicyId.of("org.example", "tenant-root-wildcard");
180+
181+
// configure root policy via wildcard pattern "org.example.*"
182+
final var underTest = new PolicyEnforcerCache(
183+
cacheLoader,
184+
executor,
185+
DefaultCacheConfig.of(actorSystem.settings().config(), "ditto.policies-enforcer-cache"),
186+
namespacePoliciesConfigForWildcard(rootPolicyId, "org.example.*")
187+
);
188+
189+
final Policy devicesPolicy = Policy.newBuilder(PolicyId.of("org.example.devices", "policy-a")).build();
190+
final Policy sensorsPolicy = Policy.newBuilder(PolicyId.of("org.example.sensors", "policy-b")).build();
191+
// "org.examples" must NOT match "org.example.*" (no dot separator)
192+
final Policy unrelatedPolicy = Policy.newBuilder(PolicyId.of("org.examples", "policy-c")).build();
193+
194+
new TestKit(actorSystem) {{
195+
verifyLoadedFromCacheLoader(devicesPolicy, underTest, cacheLoader);
196+
verifyLoadedFromCacheLoader(sensorsPolicy, underTest, cacheLoader);
197+
verifyLoadedFromCacheLoader(unrelatedPolicy, underTest, cacheLoader);
198+
reset(cacheLoader);
199+
200+
verifyLoadedFromCache(devicesPolicy, underTest, cacheLoader);
201+
verifyLoadedFromCache(sensorsPolicy, underTest, cacheLoader);
202+
verifyLoadedFromCache(unrelatedPolicy, underTest, cacheLoader);
203+
reset(cacheLoader);
204+
205+
final boolean invalidated = underTest.invalidate(rootPolicyId);
206+
assertThat(invalidated).isTrue();
207+
208+
// matching namespace policies must be reloaded
209+
verifyLoadedFromCacheLoader(devicesPolicy, underTest, cacheLoader);
210+
verifyLoadedFromCacheLoader(sensorsPolicy, underTest, cacheLoader);
211+
// non-matching policy must still be served from cache
212+
verifyLoadedFromCache(unrelatedPolicy, underTest, cacheLoader);
213+
}};
214+
}
215+
216+
private NamespacePoliciesConfig namespacePoliciesConfigForWildcard(final PolicyId rootPolicyId,
217+
final String pattern) {
218+
final NamespacePoliciesConfig config = mock(NamespacePoliciesConfig.class);
219+
when(config.getAllNamespaceRootPolicyIds()).thenReturn(Collections.singleton(rootPolicyId));
220+
when(config.getNamespacesForRootPolicy(rootPolicyId)).thenReturn(Collections.singleton(pattern));
221+
return config;
222+
}
223+
175224
private NamespacePoliciesConfig namespacePoliciesConfigFor(final PolicyId rootPolicyId) {
176225
final NamespacePoliciesConfig config = mock(NamespacePoliciesConfig.class);
177226
final Map<String, List<PolicyId>> namespacePolicies = new HashMap<>();

0 commit comments

Comments
 (0)