Skip to content

Memoize authorization verdicts in TreeBasedPolicyEnforcer - #2514

Merged
thjaeckle merged 5 commits into
eclipse-ditto:masterfrom
beyonnex-io:feature/policy-enforcer-authorization-memo
Aug 27, 2026
Merged

thjaeckle merged 5 commits into
eclipse-ditto:masterfrom
beyonnex-io:feature/policy-enforcer-authorization-memo

Conversation

@thjaeckle

Copy link
Copy Markdown
Member

What

Memoizes the per-command policy-tree walk in TreeBasedPolicyEnforcer so recurring authorization decisions become a hash lookup instead of a full tree traversal.

Why

A production JFR profile showed the policy-tree walk (visitTree) as a dominant things-service CPU hotspot — roughly 10–12% of things-service CPU and ~5.6% of overall Ditto-microservice fleet CPU. Every authorization check (hasUnrestrictedPermissions, hasPartialPermissions, getSubjectsWithPermission, getSubjectsWithPartialPermission) walks the entire policy tree on each call, and the (resource, subjects, permissions) tuples recur heavily across signals (the same subjects repeatedly access the same resources).

This mirrors the existing classifySubjects(resource, READ) memoization on PolicyEnforcer: TreeBasedPolicyEnforcer is effectively immutable after createInstance (its tree is built once and only read afterwards), and a policy change produces a brand-new instance via PolicyEnforcers.defaultEvaluator. So the cache lifetime equals the instance lifetime and needs no explicit invalidation.

What changed

Added per-instance ConcurrentHashMap memos inside TreeBasedPolicyEnforcer:

  • Boolean permission checks share one map keyed by (ResourceKey, subject-id Set, Permissions, partial-flag) — the partial flag keeps unrestricted and partial checks distinct.
  • Subject-collection results are keyed by (ResourceKey, Permissions). The immutable EffectedSubjects instance is shared directly; getSubjectsWithPartialPermission caches an immutable snapshot and returns a fresh mutable copy per call to preserve its original contract.
  • The authorization-subject-id Set is computed once and reused as the memo key, avoiding the previous per-call HashSet rebuild on cache hits.
  • Each memo is best-effort bounded (MAX_MEMO_SIZE) to stay memory-safe under pathological key diversity; above the cap, results are computed uncached.

Placing the memo on the enforcer itself (rather than the PolicyEnforcer wrapper) transparently benefits every caller across things, policies and gateway, all of which invoke these checks on the bare Enforcer.

Behavior & compatibility

  • Verdicts are byte-for-byte identical to the non-memoized path.
  • Thread-safe: ConcurrentHashMap with a get→compute→putIfAbsent helper; the computation only reads the immutable tree and never re-enters the map.
  • Only private members and private nested value classes are added — no public API change (japicmp passes). Java-8 compatible (plain value classes; no records / Set.copyOf).
  • Adds TreeBasedPolicyEnforcerMemoTest proving repeat calls match a cold enforcer and that distinct subjects / permissions / resources / check-kinds are never conflated. The full policies/model suite (1055 tests) passes.

@thjaeckle thjaeckle self-assigned this Jul 31, 2026
@thjaeckle thjaeckle added this to the 3.9.7 milestone Jul 31, 2026
@thjaeckle
thjaeckle requested a review from hu-ahmed August 18, 2026 12:34
@thjaeckle

Copy link
Copy Markdown
Member Author

@hu-ahmed could you have a brief look on this optimisation as well?

@hu-ahmed hu-ahmed left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change, and the safety homework is genuinely done — I verified the parts that would matter most: no time-dependent evaluation anywhere in the tree enforcer (so frozen verdicts can't go stale), DefaultEffectedSubjects and ImmutablePermissions really are immutable so sharing the cached instance is safe, and getSubjectsWithPartialPermission correctly keeps its mutable-set contract. Full policies/model suite green for me too (1057/0), plus policies/enforcement (268/0). No correctness concerns — verdicts are identical to the non-memoized path.

Two things I'd like to see addressed.

1. A cache hit still does most of the work the memo was meant to skip

All four memoized methods build the resource pointer before consulting the memo, and the two permission checks additionally re-derive the subject-id set. The pointer isn't part of the key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds() already memoizes the ids (ImmutableAuthorizationContext:133-147) — so the stream-and-collect is redundant.

Measured per call (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k iterations after 50k warmup):

hasUnrestrictedPermissions (memo hit)        as-is               with patch
10-label policy,  1 caller subject     351.0 ns / 1373 B     41.9 ns /  88 B
100-label policy, 1 caller subject     304.2 ns / 1385 B     51.0 ns / 121 B
100-label policy, 5 caller subjects    353.8 ns / 1528 B     61.2 ns / 152 B

Attributing the eager work:

createAbsoluteResourcePointer                 77-108 ns / ~730 B   <- unused on a hit
stream -> HashSet (current)                    51- 62 ns / ~385 B
ctx.getAuthorizationSubjectIds() (memoized)       4.5 ns /    0 B   <- already cached

The fix is to key the memo on the already-memoized ID list, and build both the resource pointer and the HashSet inside the supplier so they are paid only on a miss. The set does matter: CheckPermissionsVisitor.visitSubjectNode calls authSubjectIds.contains(...) once per subject node in the tree, so handing the visitor a List instead would turn the miss path from O(policy subjects) into O(policy subjects x caller subjects). That's 14 insertions / 12 deletions, full 1057-test suite still green — happy to push it.

One consequence worth naming: the key becomes order-dependent (list equality), so two contexts holding the same subjects in a different order get separate entries. Verdicts are unaffected and the order is stable per caller in practice, but the PermissionCheckKey javadoc currently advertises order-independence and would need rewording.

Minor, related: the code comment calls this eager work "cheap" — it measures ~1.1 KB of the ~1.4 KB allocated per hit.

2. authorization-memo-cache-max-size = 0 doesn't fully disable the memo

The configured value is only threaded through PolicyEnforcer:273. The other defaultEvaluator call sites (:132, :215, :242, :355, :382, :503) use the hardcoded DEFAULT_MAX_MEMO_SIZE.

:503 is the one that matters: it builds the namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on the things-service hot path. So the documented escape hatch — "Set to 0 to disable entirely - then no ConcurrentHashMaps are allocated and every check walks the tree directly" — does not hold for the very instance an operator would most likely be trying to disable it on.

Separately, (int) authorizationMemoMaxSize at :273 wraps negative for a configured value above Integer.MAX_VALUE, silently disabling the memo instead of enabling a large one. Worth clamping.

thjaeckle added a commit to beyonnex-io/ditto that referenced this pull request Aug 27, 2026
…ound

Review feedback on PR eclipse-ditto#2514 (thanks @hu-ahmed) raised two issues, both confirmed.

1. A memo hit still did most of the work the memo was meant to skip.

All four memoized methods built the absolute resource pointer *before*
consulting the memo, and the two permission checks additionally re-derived the
subject-id set by streaming the AuthorizationContext. The pointer is not part of
either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds()
already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect
was redundant.

Key the permission-check memo on the context's already-memoized id List and build
both the resource pointer and the subject-id HashSet inside the memo supplier, so
they are paid only on a miss. The Set is still handed to the visitor on the miss
path: CheckPermissionsVisitor probes it with contains() once per subject node, so
passing the List would turn the walk into O(policy subjects x caller subjects).
getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier.

Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k
iterations after 50k warmup):

                                          before            after
  10-label policy,  1 caller subject   496 ns / 1366 B    52 ns /  88 B
  100-label policy, 1 caller subject   460 ns / 1396 B    64 ns / 121 B
  100-label policy, 5 caller subjects  404 ns / 1528 B    72 ns / 152 B

Consequence, now documented on PermissionCheckKey: list equality makes the key
order-dependent, so two contexts holding the same subjects in a different order
occupy separate entries. Verdicts are unaffected (each entry is computed from its
own ids) and the order is stable per caller. The javadoc claim of
order-independence and the comment calling the eager work "cheap" are both gone -
that work was ~1.1 KB of the ~1.4 KB allocated per hit.

2. authorization-memo-cache-max-size = 0 did not fully disable the memo.

The configured value only reached the enforcer built by
withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used
the hardcoded default. computeForNamespace is the one that matters: it builds the
namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on
the things-service hot path - so the documented escape hatch did not hold for the
very instance an operator would be trying to disable it on. The neighbouring
comment already reasoned that this child must inherit the parent's
read-classification bound; the memo bound was simply missed.

Retain the configured bound on PolicyEnforcer (null = none configured, transient
of/embed instances, library default applies) and pass it to the filtered child,
alongside readClassificationCacheMaxSize. Also clamp instead of casting: a
configured value above Integer.MAX_VALUE wrapped negative and silently disabled
the memo - the opposite of the request.

Tests: three regression tests covering child inheritance of a configured bound, of
0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a
transient-instance case; and a model-side test pinning the documented
order-dependence of the new key. policies/model 1058 and policies/enforcement 272
tests green, japicmp and license:check clean, things/service still compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
@thjaeckle

Copy link
Copy Markdown
Member Author

Both points confirmed and fixed in e083c61, with a JMH benchmark in 57e2aa0.

1. Eager work on the hit path. The memo now keys on the context's already-memoized ID list, and the resource pointer + subject-ID HashSet moved into the supplier (miss-only). Kept the HashSet for the visitor for exactly the reason you gave — CheckPermissionsVisitor:72 probes it once per subject node and the field is typed Collection<String>, so passing a List would have compiled silently and gone quadratic. PermissionCheckKey javadoc reworded for order-dependence; the "cheap" comment is gone.

Two additions to your patch: checkNotNull(authorizationContext, ...) stays eager, since the old comment also cited null-argument check ordering and the NPE message is part of the contract; and the javadoc now records that the key relies on ImmutableAuthorizationContext returning an unmodifiable list, given it is no longer a private copy.

2. = 0 not disabling the memo. Right diagnosis, and :503 was indeed the one that mattered — the adjacent comment already argued that child must inherit the parent's bounds. PolicyEnforcer now retains the configured bound and passes it to the filtered child next to readClassificationCacheMaxSize, and the cast is a saturating clamp. Three regression tests (configured bound, 0, saturation above Integer.MAX_VALUE) all fail without the fix.

Same JMH harness run back-to-back against both enforcer versions (100 labels, 1 caller subject, -prof gc):

metric before after
hasUnrestrictedPermissions hit 255.6 ns / 1280 B 57.7 ns / 120 B
getSubjectsWithPermission hit 142.6 ns / 808 B 50.8 ns / 104 B

Your B/op figures reproduced exactly. Miss paths are unchanged within noise, with a small allocation drop there too (3424 → 3280 B) since getAuthorizationSubjectIds no longer re-streams the context. AuthorizationVerdictMemoBenchmark makes all of this re-runnable and keeps the removed work measurable (formerlyEagerPerHitWork, 1096 B) as a guard against reintroducing eager work on the hit path.

One thing worth flagging: policy-enforcement-use-throughput-optimized-evaluator-enabled defaults to true (ditto-devops.conf:43, and FeatureToggle.resolveProperty defaults unset properties to true), so on defaults defaultEvaluator returns the trie evaluator — which never memoizes and ignores maxMemoSize. This memo only engages where that toggle is explicitly false, as in the deployment the JFR came from. Relevant if you tried measuring locally and saw no effect.

policies/model 1058 and policies/enforcement 272 tests green, japicmp clean.

thjaeckle and others added 5 commits August 27, 2026 09:36
Every authorization check (hasUnrestrictedPermissions, hasPartialPermissions,
getSubjectsWithPermission, getSubjectsWithPartialPermission) walked the whole
policy tree via visitTree on each call. Production JFR showed this tree walk as
a dominant things-service CPU hotspot (~10-12% of things CPU, ~5.6% of the
Ditto-microservice fleet), and the (resource, subjects, permissions) tuples
recur heavily across signals.

Add per-instance ConcurrentHashMap memos so a recurring verdict becomes a hash
lookup instead of a full traversal. TreeBasedPolicyEnforcer is effectively
immutable after createInstance (the tree is built once and only read afterwards)
and a policy change produces a brand-new instance via
PolicyEnforcers.defaultEvaluator, so - exactly like the classifySubjects(resource,
READ) memo on PolicyEnforcer - the cache lifetime equals the instance lifetime and
needs no explicit invalidation. Placing the memo on the enforcer itself (rather
than the PolicyEnforcer wrapper) transparently benefits every caller across things,
policies and gateway, all of which invoke these checks on the bare Enforcer.

Details:
- Boolean checks share one map keyed by (ResourceKey, subject-id Set, Permissions,
  partial-flag); the partial flag keeps unrestricted and partial checks distinct.
- Subject-collection results are keyed by (ResourceKey, Permissions). The immutable
  EffectedSubjects instance is shared directly; getSubjectsWithPartialPermission
  caches an immutable snapshot and returns a fresh mutable copy per call to preserve
  its original contract.
- The authorization-subject-id set is computed once and reused as the memo key
  (avoids the previous per-call HashSet rebuild on cache hits).
- Each memo is best-effort bounded (MAX_MEMO_SIZE) to stay memory-safe under
  pathological key diversity; above the cap results are computed uncached.
- Only private members and private nested value classes are added: no public API
  change (japicmp passes), verdicts are byte-for-byte identical to the non-memoized
  path. Java-8 compatible (plain value classes, no records / Set.copyOf).

Adds TreeBasedPolicyEnforcerMemoTest proving repeat calls match a cold enforcer
and that distinct subjects / permissions / resources / check-kinds are never
conflated. Full policies/model suite (1055 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
…les it

The authorization-verdict memos added for the policy-tree-walk hotspot were
hard-bounded at a fixed 10000 entries and always allocated. Make the bound
operator-configurable and add a true off switch: with maxMemoSize <= 0 the three
ConcurrentHashMaps are not allocated at all and every check takes the direct
visitTree path (behaviourally identical to a non-memoizing enforcer, zero extra
memory), mirroring the existing read-classification-cache-max-size knob.

- TreeBasedPolicyEnforcer: DEFAULT_MAX_MEMO_SIZE + per-instance maxMemoSize /
  memoizationEnabled; nullable memos allocated only when enabled; each memoized
  method guards on memoizationEnabled; new createInstance(entries, maxMemoSize)
  overload (no-arg factory delegates with the default). Package-private
  isMemoizationEnabled() test hook.
- PolicyEnforcers: additive defaultEvaluator/memoryOptimizedEvaluator(entries,
  maxMemoSize) overloads (the bound only applies to the memory-optimized/tree
  evaluator; the trie evaluator does not memoize).
- PolicyEnforcer / PolicyEnforcerCacheLoader: thread authorizationMemoMaxSize from
  config (new reference.conf key authorization-memo-cache-max-size, env
  DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE, default 10000) into the
  bare-enforcer construction, mirroring readClassificationCacheMaxSize.
- Helm: expose authorizationMemoMaxSize for things/policies/connectivity
  (values.yaml + deployment templates); bump chart to 4.6.1.
- Tests: memoization-disabled (no maps allocated, verdicts still correct) and
  small-cap-exceeded (correct above the cap) cases.

Additive only; japicmp and the full policies/model + policies/enforcement suites
stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
…ound

Review feedback on PR eclipse-ditto#2514 (thanks @hu-ahmed) raised two issues, both confirmed.

1. A memo hit still did most of the work the memo was meant to skip.

All four memoized methods built the absolute resource pointer *before*
consulting the memo, and the two permission checks additionally re-derived the
subject-id set by streaming the AuthorizationContext. The pointer is not part of
either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds()
already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect
was redundant.

Key the permission-check memo on the context's already-memoized id List and build
both the resource pointer and the subject-id HashSet inside the memo supplier, so
they are paid only on a miss. The Set is still handed to the visitor on the miss
path: CheckPermissionsVisitor probes it with contains() once per subject node, so
passing the List would turn the walk into O(policy subjects x caller subjects).
getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier.

Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k
iterations after 50k warmup):

                                          before            after
  10-label policy,  1 caller subject   496 ns / 1366 B    52 ns /  88 B
  100-label policy, 1 caller subject   460 ns / 1396 B    64 ns / 121 B
  100-label policy, 5 caller subjects  404 ns / 1528 B    72 ns / 152 B

Consequence, now documented on PermissionCheckKey: list equality makes the key
order-dependent, so two contexts holding the same subjects in a different order
occupy separate entries. Verdicts are unaffected (each entry is computed from its
own ids) and the order is stable per caller. The javadoc claim of
order-independence and the comment calling the eager work "cheap" are both gone -
that work was ~1.1 KB of the ~1.4 KB allocated per hit.

2. authorization-memo-cache-max-size = 0 did not fully disable the memo.

The configured value only reached the enforcer built by
withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used
the hardcoded default. computeForNamespace is the one that matters: it builds the
namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on
the things-service hot path - so the documented escape hatch did not hold for the
very instance an operator would be trying to disable it on. The neighbouring
comment already reasoned that this child must inherit the parent's
read-classification bound; the memo bound was simply missed.

Retain the configured bound on PolicyEnforcer (null = none configured, transient
of/embed instances, library default applies) and pass it to the filtered child,
alongside readClassificationCacheMaxSize. Also clamp instead of casting: a
configured value above Integer.MAX_VALUE wrapped negative and silently disabled
the memo - the opposite of the request.

Tests: three regression tests covering child inheritance of a configured bound, of
0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a
transient-instance case; and a model-side test pinning the documented
order-dependence of the new key. policies/model 1058 and policies/enforcement 272
tests green, japicmp and license:check clean, things/service still compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
The review numbers were produced with a throwaway ThreadMXBean harness, so they
were not reproducible. Add a JMH benchmark alongside the existing
ClassifyReadSubjectsBenchmark (things/service already wires the
jmh-generator-annprocess annotation processor, so no pom change is needed).

Benchmarks, parameterised over policy size (10/100 labels) and caller subject
count (1/5):

- hasUnrestrictedPermissionsMemoHit / getSubjectsWithPermissionMemoHit: warm
  enforcer, every call a memo hit - the fast path.
- hasUnrestrictedPermissionsNoMemo / getSubjectsWithPermissionNoMemo: enforcer
  built with maxMemoSize = 0, so every call walks the whole tree - the baseline
  the memo removes.
- hasUnrestrictedPermissionsMemoMiss vs hasUnrestrictedPermissionsNoMemoSameKeys:
  tiny cap plus a 64-key resource pool so (almost) every call misses. Both cycle
  the same pool, so the pair is directly comparable and shows the memo adds no
  overhead on the pathological no-repeat workload.
- formerlyEagerPerHitWork: the work a hit used to pay before consulting the map
  (absolute resource pointer + streaming the AuthorizationContext into a subject-id
  Set). Neither is part of the memo key nor used on a hit, so both now live inside
  the memo supplier. Keeping it measurable guards against reintroducing eager work
  on the hit path.

Measured (100 labels, 1 caller subject, -prof gc):

  hasUnrestrictedPermissionsMemoHit           63.7 ns /    120 B
  hasUnrestrictedPermissionsNoMemo          4928   ns /   3280 B   (~77x, 27x)
  hasUnrestrictedPermissionsMemoMiss        4821   ns /   3428 B
  hasUnrestrictedPermissionsNoMemoSameKeys  4846   ns /   3528 B   (no regression)
  getSubjectsWithPermissionMemoHit            51.6 ns /    104 B
  getSubjectsWithPermissionNoMemo          44123   ns / 153280 B   (~855x, 1474x)
  formerlyEagerPerHitWork                    147   ns /   1096 B

formerlyEagerPerHitWork independently reproduces the review's finding: the eager
work allocated ~1.1 KB, roughly nine times the 120 B of the whole memo hit that
replaced it.

main() now honours command-line arguments (via CommandLineOptions as the options
parent) so "-prof gc" and "-p" actually take effect; the javadoc documents the
exec:exec invocation, since exec:java runs in-process and JMH's forked VM then
fails with ClassNotFoundException: ForkedMain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
master's 4f3bacd already bumped the chart to 4.6.1 for its own change
(operator-metrics MongoDB read settings). This branch had independently bumped
4.6.0 -> 4.6.1, so rebasing onto master left the version untouched — git saw the
bump as already applied — and this branch's chart changes (the three deployment
templates plus values.yaml) would have shipped under a version number master had
already claimed, which is what the CI Helm lint gate is there to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
@thjaeckle
thjaeckle force-pushed the feature/policy-enforcer-authorization-memo branch from 57e2aa0 to b6b30df Compare August 27, 2026 07:41

@hu-ahmed hu-ahmed left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
Thanks for the improvements

@thjaeckle
thjaeckle merged commit 742268e into eclipse-ditto:master Aug 27, 2026
10 checks passed
thjaeckle added a commit that referenced this pull request Sep 1, 2026
Release notes for the 3.9.7 security bugfix release, covering the two security
advisories, the performance work of the milestone and the two bugfixes.

Both security fixes were merged from private forks, so there are no public pull
requests to link. The release notes reference the GitHub Security Advisories,
the reserved CVEs and the fix commits instead:

- CVE-2026-82958 / GHSA-cgfq-3fv9-5c44 (high, fixed by 73bf966) - JSON
  template injection in the connectivity ImplicitThingCreationMessageMapper
- CVE-2026-84175 / GHSA-7f3j-xpvm-wwmg (medium, fixed by a0e704c) - SSRF via
  WoT ThingModel definition URL in the things service

Also bumps the Helm chart to 4.7.0 / appVersion 3.9.7 and records the chart
changes in its CHANGELOG: the new WoT hostValidation values of the SSRF fix,
authorizationMemoMaxSize (#2514), the operator-metrics persistence blocks
(#2510), the nginx/swagger-ui image bumps, and the Swagger UI OIDC mount fix
(#2478 / #2533) moved out of [Unreleased].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thjaeckle added a commit that referenced this pull request Sep 1, 2026
…ound

Review feedback on PR #2514 (thanks @hu-ahmed) raised two issues, both confirmed.

1. A memo hit still did most of the work the memo was meant to skip.

All four memoized methods built the absolute resource pointer *before*
consulting the memo, and the two permission checks additionally re-derived the
subject-id set by streaming the AuthorizationContext. The pointer is not part of
either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds()
already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect
was redundant.

Key the permission-check memo on the context's already-memoized id List and build
both the resource pointer and the subject-id HashSet inside the memo supplier, so
they are paid only on a miss. The Set is still handed to the visitor on the miss
path: CheckPermissionsVisitor probes it with contains() once per subject node, so
passing the List would turn the walk into O(policy subjects x caller subjects).
getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier.

Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k
iterations after 50k warmup):

                                          before            after
  10-label policy,  1 caller subject   496 ns / 1366 B    52 ns /  88 B
  100-label policy, 1 caller subject   460 ns / 1396 B    64 ns / 121 B
  100-label policy, 5 caller subjects  404 ns / 1528 B    72 ns / 152 B

Consequence, now documented on PermissionCheckKey: list equality makes the key
order-dependent, so two contexts holding the same subjects in a different order
occupy separate entries. Verdicts are unaffected (each entry is computed from its
own ids) and the order is stable per caller. The javadoc claim of
order-independence and the comment calling the eager work "cheap" are both gone -
that work was ~1.1 KB of the ~1.4 KB allocated per hit.

2. authorization-memo-cache-max-size = 0 did not fully disable the memo.

The configured value only reached the enforcer built by
withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used
the hardcoded default. computeForNamespace is the one that matters: it builds the
namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on
the things-service hot path - so the documented escape hatch did not hold for the
very instance an operator would be trying to disable it on. The neighbouring
comment already reasoned that this child must inherit the parent's
read-classification bound; the memo bound was simply missed.

Retain the configured bound on PolicyEnforcer (null = none configured, transient
of/embed instances, library default applies) and pass it to the filtered child,
alongside readClassificationCacheMaxSize. Also clamp instead of casting: a
configured value above Integer.MAX_VALUE wrapped negative and silently disabled
the memo - the opposite of the request.

Tests: three regression tests covering child inheritance of a configured bound, of
0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a
transient-instance case; and a model-side test pinning the documented
order-dependence of the new key. policies/model 1058 and policies/enforcement 272
tests green, japicmp and license:check clean, things/service still compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
thjaeckle added a commit that referenced this pull request Sep 1, 2026
Release notes for the 3.9.7 security bugfix release, covering the two security
advisories, the performance work of the milestone and the two bugfixes.

Both security fixes were merged from private forks, so there are no public pull
requests to link. The release notes reference the GitHub Security Advisories,
the reserved CVEs and the fix commits instead:

- CVE-2026-82958 / GHSA-cgfq-3fv9-5c44 (high, fixed by 73bf966) - JSON
  template injection in the connectivity ImplicitThingCreationMessageMapper
- CVE-2026-84175 / GHSA-7f3j-xpvm-wwmg (medium, fixed by a0e704c) - SSRF via
  WoT ThingModel definition URL in the things service

Also bumps the Helm chart to 4.7.0 / appVersion 3.9.7 and records the chart
changes in its CHANGELOG: the new WoT hostValidation values of the SSRF fix,
authorizationMemoMaxSize (#2514), the operator-metrics persistence blocks
(#2510), the nginx/swagger-ui image bumps, and the Swagger UI OIDC mount fix
(#2478 / #2533) moved out of [Unreleased].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thjaeckle added a commit that referenced this pull request Sep 1, 2026
The release notes named the HOCON key of the #2514 authorization-verdict memo
`authorization-memo-max-size`, but the actual key is
`authorization-memo-cache-max-size` (policies/enforcement reference.conf, read
via AUTHORIZATION_MEMO_MAX_SIZE_KEY in PolicyEnforcerCacheLoader). An operator
following the notes to set it via HOCON would have silently configured nothing.

Also corrects the surrounding path: the setting lives directly under the
`ditto.policies-enforcer-cache` block, not under a `cache` sub-block of a
policy-enforcer config. The stated default (10000), env variable
(DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE) and Helm value name
(authorizationMemoMaxSize) were already correct and are unchanged.

The same error is present on master and should be corrected there as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
thjaeckle added a commit that referenced this pull request Sep 1, 2026
The release notes named the HOCON key of the #2514 authorization-verdict memo
`authorization-memo-max-size`, but the actual key is
`authorization-memo-cache-max-size` (policies/enforcement reference.conf, read
via AUTHORIZATION_MEMO_MAX_SIZE_KEY in PolicyEnforcerCacheLoader). An operator
following the notes to set it via HOCON would have silently configured nothing.

Also corrects the surrounding path: the setting lives directly under the
`ditto.policies-enforcer-cache` block, not under a `cache` sub-block of a
policy-enforcer config. The stated default (10000), env variable
(DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE) and Helm value name
(authorizationMemoMaxSize) were already correct and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
(cherry picked from commit 0d31478)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants