From 57fa0927fa48f539d27537a894ff318b17d54f10 Mon Sep 17 00:00:00 2001 From: Andrei Dulvac Date: Thu, 30 Jul 2026 15:20:22 +0200 Subject: [PATCH 1/4] OAK-12331: add an audit event SPI with commit-attached capture and observer drain Adds a pluggable audit SPI so consumers can observe repository operations without patching Oak internals. Off by default behind the FT_OAK-12331 feature toggle. With the toggle off, or with no audit module deployed, the capture path short-circuits through a NOOP sink. New SPI, oak-core-spi (package org.apache.jackrabbit.oak.spi.audit): AuditEvent, AuditEventImpl immutable event: domain, type, timestamp, payload AuditEvents static facade over a pluggable Sink; NOOP until an audit module installs one AuditEventListener consumer contract, selects by domain, orders by rank AuditEventEmitter OSGi-facing emitter for the fire-and-forget path AuditBufferLifecycle session lifecycle callouts used by MutableRoot oak-security-spi contributes the security domain name and the user-management type vocabulary (SecurityAuditDomain, UserAuditTypes) so listener bundles compile against a stable set of strings. Pipeline, oak-core (org.apache.jackrabbit.oak.security.audit): Capture sites buffer events per session in a ThreadLocal. AuditDrainObserver runs as an Observer on the root NodeStore, and because observers fire after durable persistence and never for a failed merge, a dispatched event always corresponds to a persisted write. The drain is destructive, so a store that invokes the observer twice for one merge dedupes itself. CommitMetadataDecorator stamps the three reserved commit.* keys at drain time, overwriting anything a caller supplied; the fire-and-forget path strips those keys instead, so their presence attests that an event came from Oak's commit-attached pipeline. An outer Throwable barrier in contentChanged keeps a failing listener from surfacing as a commit failure to the merge caller. MutableRoot drains on refresh() and on a failed merge, and deliberately does not drain on rebase(), which keeps transient changes and replays them on the new base. The audit events captured alongside those surviving changes have to survive with them. The only instrumented capture sites so far are the two UserManagerImpl onGroupUpdate overloads, covering single and bulk group membership changes. Path resolution failures there drop the event and log rather than failing the surrounding update, with repeat warnings demoted to DEBUG. AuditConfigurationImpl owns the lifecycle. It registers as AuditConfiguration only, contributes no commit hooks, and is not a SecurityConfiguration, so it stays out of SecurityProvider.getConfiguration(). Under OSGi it publishes the observer as a service for ObserverTracker to pick up; embedded callers get it from getDrainObserver() and attach it themselves. Also adds an audit-enabled in-memory fixture to oak-run-commons and two oak-benchmarks runs that measure capture-site and empty-commit overhead. Documentation is submitted separately as a companion change. --- .../AuditCaptureSiteOverheadTest.java | 89 ++ .../AuditEmptyCommitOverheadTest.java | 78 ++ .../oak/benchmark/BenchmarkRunner.java | 3 + oak-core-spi/pom.xml | 1 + .../oak/spi/audit/AuditBufferLifecycle.java | 131 ++ .../oak/spi/audit/AuditConfiguration.java | 84 ++ .../jackrabbit/oak/spi/audit/AuditEvent.java | 210 +++ .../oak/spi/audit/AuditEventEmitter.java | 76 ++ .../oak/spi/audit/AuditEventImpl.java | 76 ++ .../oak/spi/audit/AuditEventListener.java | 106 ++ .../jackrabbit/oak/spi/audit/AuditEvents.java | 115 ++ .../oak/spi/audit/package-info.java | 20 + .../spi/audit/AuditBufferLifecycleTest.java | 93 ++ .../oak/spi/audit/AuditConfigurationTest.java | 39 + .../oak/spi/audit/AuditEventImplTest.java | 63 + .../oak/spi/audit/AuditEventListenerTest.java | 74 + .../oak/spi/audit/AuditEventTest.java | 124 ++ .../oak/spi/audit/AuditEventsTest.java | 98 ++ .../jackrabbit/oak/core/MutableRoot.java | 19 +- .../oak/security/audit/AuditBuffer.java | 200 +++ .../audit/AuditConfigurationImpl.java | 493 +++++++ .../security/audit/AuditDrainObserver.java | 206 +++ .../security/audit/AuditEventEmitterImpl.java | 46 + .../audit/CommitMetadataDecorator.java | 217 +++ .../WhiteboardAuditEventListenerRegistry.java | 198 +++ .../oak/security/user/UserAuditEvents.java | 179 +++ .../oak/security/user/UserManagerImpl.java | 77 ++ .../oak/security/audit/AuditBufferTest.java | 249 ++++ .../audit/AuditConfigurationImplTest.java | 436 ++++++ .../audit/AuditDrainObserverTest.java | 660 +++++++++ .../audit/AuditEventEmitterImplTest.java | 78 ++ .../oak/security/audit/AuditFixtureTest.java | 219 +++ .../oak/security/audit/AuditPipelineTest.java | 1212 +++++++++++++++++ .../oak/security/audit/AuditWiringTest.java | 310 +++++ .../audit/CommitMetadataDecoratorTest.java | 407 ++++++ ...teboardAuditEventListenerRegistryTest.java | 379 ++++++ .../SecurityProviderRegistrationTest.java | 4 + .../security/user/UserAuditEventsTest.java | 180 +++ .../user/UserManagerImplAuditTest.java | 381 ++++++ .../jackrabbit/oak/fixture/OakFixture.java | 182 ++- .../oak/fixture/OakRepositoryFixture.java | 11 + .../fixture/MemoryNSWithAuditFixtureTest.java | 98 ++ .../oak/fixture/OakFixturePropertyTest.java | 168 +++ oak-security-spi/pom.xml | 1 + .../security/audit/SecurityAuditDomain.java | 44 + .../oak/spi/security/audit/package-info.java | 20 + .../oak/spi/security/user/UserAuditTypes.java | 132 ++ .../oak/spi/security/user/package-info.java | 2 +- .../audit/SecurityAuditDomainTest.java | 42 + .../spi/security/user/UserAuditTypesTest.java | 83 ++ 50 files changed, 8401 insertions(+), 12 deletions(-) create mode 100644 oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java create mode 100644 oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycle.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/package-info.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycleTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditConfigurationTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImplTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java create mode 100644 oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java create mode 100644 oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java create mode 100644 oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java create mode 100644 oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/package-info.java create mode 100644 oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java create mode 100644 oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java create mode 100644 oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java new file mode 100644 index 00000000000..bba87c9e001 --- /dev/null +++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.benchmark; + +import javax.jcr.RepositoryException; +import javax.jcr.Session; + +import org.apache.jackrabbit.api.JackrabbitSession; +import org.apache.jackrabbit.api.security.user.Group; +import org.apache.jackrabbit.api.security.user.User; +import org.apache.jackrabbit.api.security.user.UserManager; +import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl; + +/** + * Microbenchmark isolating the per-event overhead of an audit capture + * site firing on commit. Each iteration does {@code pairsPerIteration} + * {@code addMember + save + removeMember + save} cycles on a fixed + * group / user pair, i.e. {@code 2 * pairsPerIteration} commits per + * iteration with exactly one audit capture-site fire per commit: + * {@code UserAuditEvents.memberAdded(...)} resp. {@code UserAuditEvents.memberRemoved(...)}. + * + * Delta = (Oak-MemoryNS-Audit median) − (Oak-MemoryNS median), divided + * by {@code 2 * pairsPerIteration}, is the per-event audit overhead + * (allocation + path resolve + buffer record + drain + listener + * dispatch). On the audit-OFF side capture sites short-circuit at + * {@code AuditEvents.isEnabled()} returning false (NOOP sink) so the + * commit cost is the audit-free baseline. + * + *

Tunable via {@code -DpairsPerIteration=N} (default 50). + */ +public class AuditCaptureSiteOverheadTest extends AbstractTest { + + private static final int PAIRS_PER_ITERATION = + Integer.getInteger("pairsPerIteration", 50); + + private static final String GROUP_ID = "auditBenchGroup_"; + private static final String USER_ID = "auditBenchUser_"; + + private JackrabbitSession session; + private UserManager userManager; + private Group group; + private User user; + + @Override + public void beforeSuite() throws RepositoryException { + session = (JackrabbitSession) loginWriter(); + userManager = session.getUserManager(); + group = userManager.createGroup(GROUP_ID + TEST_ID, + new PrincipalImpl(GROUP_ID + TEST_ID), null); + user = userManager.createUser(USER_ID + TEST_ID, null, + new PrincipalImpl(USER_ID + TEST_ID), null); + session.save(); + } + + @Override + protected void runTest() throws Exception { + for (int i = 0; i < PAIRS_PER_ITERATION; i++) { + group.addMember(user); + session.save(); + group.removeMember(user); + session.save(); + } + } + + @Override + public void afterSuite() throws RepositoryException { + try { + if (group != null) group.remove(); + if (user != null) user.remove(); + session.save(); + } finally { + logout(session); + } + } +} diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java new file mode 100644 index 00000000000..85578de8778 --- /dev/null +++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.benchmark; + +import javax.jcr.Node; +import javax.jcr.RepositoryException; +import javax.jcr.Session; + +/** + * Microbenchmark isolating the per-commit overhead of having the audit + * pipeline wired but no audit capture site firing. Each iteration does + * {@code commitsPerIteration} small {@code setProperty} + {@code save} + * cycles on a fixed leaf node. No {@code UserManager} traffic, so no + * {@code AuditEvents.record} calls happen — the only audit-attributable + * work per commit is: + *
    + *
  • {@code AuditBufferLifecycle.onRefresh(sessionId)} on each + * {@code root.refresh()} pre-commit;
  • + *
  • one extra observer in the commit-dispatch chain + * ({@code AuditDrainObserver}) that short-circuits on empty buffer.
  • + *
+ * + * Delta = (Oak-MemoryNS-Audit median) − (Oak-MemoryNS median), divided + * by {@code commitsPerIteration}, is the per-commit audit-pipeline-on + * overhead when no event is captured. + * + *

Tunable via {@code -DcommitsPerIteration=N} (default 5000). + */ +public class AuditEmptyCommitOverheadTest extends AbstractTest { + + private static final int COMMITS_PER_ITERATION = + Integer.getInteger("commitsPerIteration", 5000); + + private Session session; + private Node leaf; + private int iteration; + + @Override + public void beforeSuite() throws RepositoryException { + session = loginWriter(); + Node root = session.getRootNode().addNode( + "AuditEmptyCommitOverhead-" + TEST_ID, "nt:unstructured"); + leaf = root.addNode("leaf", "nt:unstructured"); + session.save(); + } + + @Override + protected void runTest() throws Exception { + // distinct property names per iteration so we don't fight the + // diff machinery's "same value, no commit" optimisation + String prefix = "p" + iteration++ + "_"; + for (int i = 0; i < COMMITS_PER_ITERATION; i++) { + leaf.setProperty(prefix + i, i); + session.save(); + } + } + + @Override + public void afterSuite() throws RepositoryException { + leaf.getParent().remove(); + session.save(); + logout(session); + } +} diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java index f7de6c3b6ad..2c458915951 100644 --- a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java +++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java @@ -104,6 +104,7 @@ public static void main(String[] args) throws Exception { RepositoryFixture[] allFixtures = new RepositoryFixture[]{ new JackrabbitRepositoryFixture(benchmarkOptions.getBase().value(options), cacheSize), OakRepositoryFixture.getMemoryNS(cacheSize * MB), + OakRepositoryFixture.getMemoryNSWithAudit(cacheSize * MB), OakRepositoryFixture.getMongo(uri, benchmarkOptions.getDropDBAfterTest().value(options), cacheSize * MB, benchmarkOptions.isThrottlingEnabled().value(options)), OakRepositoryFixture.getMongoWithDS(uri, @@ -471,6 +472,8 @@ public static void main(String[] args) throws Exception { new PersistentCacheTest(statsProvider), new StringWriteTest(), new BasicWriteTest(), + new AuditEmptyCommitOverheadTest(), + new AuditCaptureSiteOverheadTest(), new CanReadNonExisting(), new IsNodeTypeTest(benchmarkOptions.getRunAsAdmin().value(options)), new SetPropertyTransientTest(), diff --git a/oak-core-spi/pom.xml b/oak-core-spi/pom.xml index 854ea35ee52..ca002f8c150 100644 --- a/oak-core-spi/pom.xml +++ b/oak-core-spi/pom.xml @@ -48,6 +48,7 @@ org.apache.jackrabbit.oak.commons.jmx, org.apache.jackrabbit.oak.namepath, org.apache.jackrabbit.oak.osgi, + org.apache.jackrabbit.oak.spi.audit, org.apache.jackrabbit.oak.spi.descriptors, org.apache.jackrabbit.oak.spi.gc, org.apache.jackrabbit.oak.spi.lock, diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycle.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycle.java new file mode 100644 index 00000000000..7a37b230500 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycle.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Static façade used by {@code MutableRoot} (in {@code oak-core}) to notify + * the audit module of session-scoped lifecycle events that must purge any + * staged audit events: + *
    + *
  • {@link #onCommitFailed(String)} — the surrounding + * {@code Root.commit()} threw before audit could dispatch.
  • + *
  • {@link #onRefresh(String)} — the session called + * {@code Root.refresh()}, discarding pending transient changes. Note: + * {@code Root.rebase()} does NOT trigger this callback — rebase + * preserves transient changes (they are replayed on the new base), so + * the audit events staged alongside them must survive too.
  • + *
+ * When no audit module is deployed, the installed listener is a NOOP and + * each call costs a single volatile read plus a virtual method dispatch. + *

+ * The audit module installs its buffer on activation + * ({@code install(buffer)}) and clears it on deactivation + * ({@code install(null)}). + */ +public final class AuditBufferLifecycle { + + /** + * Lifecycle listener contract implemented by the audit module's + * per-session buffer. + */ + public interface Listener { + + /** + * Invoked when {@code Root.commit()} threw before audit dispatch + * was reached. The implementation must drop any events staged + * for the given session. + * + * @param sessionId the session id (as returned by + * {@code ContentSession.toString()}), non-null. + */ + void onCommitFailed(@NotNull String sessionId); + + /** + * Invoked when {@code Root.refresh()} is called, discarding the + * session's pending transient changes. The implementation must drop + * any events staged for the given session. + *

+ * Not invoked by {@code Root.rebase()}: rebase + * preserves transient changes, so the audit events staged alongside + * them must survive the rebase and be dispatched on the eventual + * commit. + * + * @param sessionId the session id, non-null. + */ + void onRefresh(@NotNull String sessionId); + } + + private static final Listener NOOP = new Listener() { + @Override + public void onCommitFailed(@NotNull String sessionId) { + // no audit module deployed. + } + + @Override + public void onRefresh(@NotNull String sessionId) { + // no audit module deployed. + } + }; + + private static volatile Listener listener = NOOP; + + private AuditBufferLifecycle() { + // utility class + } + + /** + * Installs the active listener. Called by the audit module on + * activation. Passing {@code null} resets to the NOOP listener. + *

+ * Bundle deployment is the security boundary. An attacker + * with bundle-deploy capability can intercept (by installing a custom + * Listener) or silently disable (by installing the NOOP via + * {@code install(null)}) the buffer-lifecycle wiring. Protecting against + * this requires OSGi-level controls (bundle signing, deployment policy); + * SPI-level access controls cannot help once a hostile bundle is already + * deployed. Embedded (non-OSGi) deployments inherit the JVM classpath as + * the boundary instead. + * + * @param newListener the listener to install, or {@code null}. + */ + public static void install(@Nullable Listener newListener) { + listener = (newListener != null) ? newListener : NOOP; + } + + /** + * Notifies the installed listener that the commit failed. Safe to + * call when no module is deployed (NOOP). + * + * @param sessionId the session id, non-null. + */ + public static void onCommitFailed(@NotNull String sessionId) { + listener.onCommitFailed(sessionId); + } + + /** + * Notifies the installed listener that the session refreshed or + * rebased. Safe to call when no module is deployed (NOOP). + * + * @param sessionId the session id, non-null. + */ + public static void onRefresh(@NotNull String sessionId) { + listener.onRefresh(sessionId); + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java new file mode 100644 index 00000000000..fad3e74d1a7 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.osgi.annotation.versioning.ProviderType; + +/** + * Audit pipeline configuration handle. Exposes pipeline-level state + * ({@link #isActive()}) so admin tooling, monitoring agents, and other + * Oak components can probe the pipeline without depending on its + * implementation class. + * + *

Wiring. Audit is a top-level Oak concern, not a + * {@code SecurityConfiguration}. Implementations are registered on the + * {@link org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard} (and, in + * OSGi deployments, as an OSGi service of this type). The pipeline + * subscribes to the root NodeStore's + * {@link org.apache.jackrabbit.oak.spi.commit.Observable} for commit + * notifications; it contributes no commit hooks. + * + *

Cardinality: unary optional. Multiple implementations + * are not supported — the {@link AuditBufferLifecycle} is a singleton install + * and multiple observers on the same root NodeStore would each produce a + * duplicate dispatch. Multiplexing belongs at the listener layer + * ({@link AuditEventListener}), not at the configuration layer. + * + *

When no implementation is bound, callers either see no service + * (Whiteboard / OSGi lookups return empty) or the {@link #NOOP} constant + * if they want a guaranteed-non-null handle. {@code NOOP.isActive()} + * returns {@code false}. + */ +@ProviderType +public interface AuditConfiguration { + + /** + * Returns {@code true} when the audit pipeline is currently active — + * i.e., the audit feature toggle is enabled AND at least one + * {@link AuditEventListener} is registered on the Whiteboard. The + * two predicates AND together so a deployed-but-unused pipeline still + * reports {@code false}, matching the no-allocation semantics + * documented at {@link AuditEvents#isEnabled()}. + * + *

Equivalent in semantics to {@code AuditEvents.isEnabled()}, but + * reachable via the typed handle. Drift-prevention: both paths read + * through the volatile {@code AuditEvents.sink} (single source of + * truth). Any future divergence MUST be documented explicitly in + * both Javadocs. + * + * @return {@code true} when the toggle is enabled and at least one + * listener is registered; {@code false} otherwise. + */ + boolean isActive(); + + /** + * NOOP default. Reports {@link #isActive()} as {@code false}. + */ + AuditConfiguration NOOP = new Noop(); + + /** + * NOOP implementation. Package-private by design — consumers refer + * to the {@link #NOOP} constant. + */ + final class Noop implements AuditConfiguration { + + @Override + public boolean isActive() { + return false; + } + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java new file mode 100644 index 00000000000..3c1195f34ec --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Collections; +import java.util.Map; + +import org.jetbrains.annotations.NotNull; +import org.osgi.annotation.versioning.ProviderType; + +/** + * Structured audit event. Implementations are expected to be immutable + * value types. + *

+ * Events may originate from two pipelines: + *

    + *
  • Oak-internal capture sites tied to a successful + * {@code Root.commit()}. The commit-attached drain step decorates + * payload with {@code commit.sessionId}, {@code commit.userId}, + * and {@code commit.timestamp} entries before dispatch.
  • + *
  • Any bundle calling {@link AuditEventEmitter#emit(AuditEvent)}. + * Such events carry the payload provided by the caller, except that + * Oak strips caller-supplied values for the three reserved + * {@code commit.*} attestation keys before dispatch — see the trust + * contract on {@link #getPayload()}.
  • + *
+ * The {@link #getDomain()} value selects the listeners that receive this + * event. + *

+ * Most callers do not implement this interface directly: use the static + * factory {@link #of(String, String, Map)} (or the no-payload overload + * {@link #of(String, String)}) to construct an immutable event with the + * current wall-clock timestamp. The package-private {@code AuditEventImpl} + * backs these factories. + */ +@ProviderType +public interface AuditEvent { + + /** + * Returns the domain that owns this event. Listeners are domain-scoped: + * an {@link AuditEventListener} only receives events whose + * {@code getDomain()} matches its own {@link AuditEventListener#getDomain()}. + * + * @return non-null domain name (e.g. {@code "oak.security"}, {@code "aem.content"}). + */ + @NotNull + String getDomain(); + + /** + * Returns the event type identifier within the domain. Type strings are + * stable across releases for any given domain. + * + * @return non-null type identifier. + */ + @NotNull + String getType(); + + /** + * Returns the wall-clock timestamp (millis since epoch) captured at + * the API call site when the event was constructed — i.e. the + * capture timestamp. + *

+ * For commit-attached events this can differ from the + * commit timestamp ({@code commit.timestamp} in + * {@link #getPayload()}): the capture timestamp is taken when the + * Oak API call ran; the commit timestamp is taken when the surrounding + * {@code Root.commit()} actually merged. The two can diverge when the + * surrounding operation takes a long time between capture and commit. + * Listeners that want "when did the change become visible?" should + * read {@code commit.timestamp}; listeners that want "when did the + * API call ran?" should read this value. + * + * @return event capture timestamp in milliseconds since epoch. + */ + long getTimestamp(); + + /** + * Returns the structured payload for this event. The default + * implementation returns an empty map; concrete event types override + * this to expose typed accessors and include their fields here. + *

+ * For commit-attached events, Oak's drain path adds entries with the + * keys {@code commit.sessionId}, {@code commit.userId}, and + * {@code commit.timestamp} when the buffer is drained on commit + * success. Oak does not add these entries on the fire-and-forget + * path (see the trust contract below). + *

+ * Trust contract (normative — other audit SPI and + * implementation docs defer to this paragraph). For events delivered + * through Oak dispatch, the three reserved keys {@code commit.sessionId}, + * {@code commit.userId} and {@code commit.timestamp} are Oak-attested: + *

    + *
  • On the commit-attached path Oak unconditionally + * overwrites the three keys with the values from + * {@code CommitInfo} at drain time (via + * {@code CommitMetadataDecorator}).
  • + *
  • On the fire-and-forget path + * ({@link AuditEventEmitter#emit(AuditEvent)} / + * {@code AuditEvents.dispatch}) Oak strips caller-supplied + * values for the same three keys before delivery.
  • + *
+ * A listener may therefore treat the presence of any of the three keys + * in a dispatched payload as "commit-attached event, values supplied by + * Oak". Every other entry — including other {@code commit.*}-prefixed + * keys — is forwarded verbatim from the caller-supplied payload on both + * paths and is untrusted: anchor trust on the three reserved keys, never + * on the {@code commit.} prefix in general. + *

+ * Boundaries of the attestation: + *

    + *
  • Oak dispatch only. Code that invokes + * {@code AuditEventListener.onEvents(...)} directly bypasses both + * the overwrite and the strip; constraining which bundles can do + * that is a deployment-level control.
  • + *
  • Attestation does not survive re-emission. A forwarder + * that copies a commit-attached payload into a new event and + * re-emits it via {@code emit(...)} gets the three keys stripped + * again — the re-emitted event is no longer the Oak-attested + * original.
  • + *
  • No redaction. Apart from the three reserved keys on the + * fire-and-forget path, the payload is never filtered at dispatch; + * the producer-side hygiene rules on {@link #of} still apply.
  • + *
+ * + * @return non-null, immutable payload map. + */ + @NotNull + default Map getPayload() { + return Collections.emptyMap(); + } + + /** + * Creates an immutable audit event with the supplied payload and the + * current wall-clock timestamp. The payload Map is defensively copied + * via {@link Map#copyOf}; the caller's Map reference is decoupled + * from the event. + * + * @param domain non-blank domain identifier. + * @param type non-blank event type identifier within {@code domain}. + * @param payload immutable, non-null payload Map. Values are stored + * by reference — see the shallow-copy note below. + * @return non-null event instance. + * @throws IllegalArgumentException if {@code domain} or {@code type} is blank. + * + * @apiNote + *

Shallow-copy semantics. {@link Map#copyOf} decouples + * the caller's Map reference but does NOT clone payload values. + * Callers MUST pass immutable values (Strings, boxed primitives, + * {@link java.util.List#copyOf(java.util.Collection) List.copyOf} / + * {@link java.util.Set#copyOf(java.util.Collection) Set.copyOf} results). + * Mutating a payload value after passing it to {@code of(...)} produces + * undefined dispatch behavior on the commit-attached path, where capture + * and dispatch are separated by the surrounding commit. + * + *

Security warning. The {@code payload} map values + * are forwarded verbatim to listeners. Callers MUST NOT pass: + *

    + *
  • Any {@link javax.jcr.Credentials} subtype.
  • + *
  • The value of a {@code rep:password} or {@code rep:credentials} + * property.
  • + *
  • Any token-bearing object (e.g. {@code TokenInfo}, + * {@code TokenCredentials}, raw token strings).
  • + *
  • Any node, property, or value that could transitively expose such + * data (e.g. a {@code Node} pointing at a {@code rep:User} subtree).
  • + *
+ * Pass user identifiers, paths, timestamps, and other non-sensitive + * scalars only. Oak does not redact or filter the payload at + * dispatch. See {@code oak-doc/src/site/markdown/security/audit-design.md} + * for the producer-side responsibility under the open trust model. + */ + @NotNull + static AuditEvent of(@NotNull String domain, + @NotNull String type, + @NotNull Map payload) { + if (domain.isBlank()) { + throw new IllegalArgumentException("domain must not be blank"); + } + if (type.isBlank()) { + throw new IllegalArgumentException("type must not be blank"); + } + return new AuditEventImpl(domain, type, System.currentTimeMillis(), Map.copyOf(payload)); + } + + /** + * Convenience overload for events with no payload. + * + * @param domain non-blank domain identifier. + * @param type non-blank event type identifier within {@code domain}. + * @return non-null event instance with an empty payload. + * @throws IllegalArgumentException if {@code domain} or {@code type} is blank. + */ + @NotNull + static AuditEvent of(@NotNull String domain, @NotNull String type) { + return of(domain, type, Map.of()); + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java new file mode 100644 index 00000000000..4751e90dfb4 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.jetbrains.annotations.NotNull; +import org.osgi.annotation.versioning.ProviderType; + +/** + * OSGi service for emitting audit events. Consumed via {@code @Reference}: + *
{@code
+ * @Reference private AuditEventEmitter audit;
+ *
+ * void onSomething() {
+ *     if (audit.isEnabledFor("aem.content")) {
+ *         audit.emit(new MyEvent(...));
+ *     }
+ * }
+ * }
+ *

+ * The emitter dispatches synchronously on the calling thread to all + * listeners registered for the event's domain. Not tied to any commit; + * not buffered; not rolled back on failure. + *

+ * Listeners are invoked under per-listener try/catch isolation — covering + * the {@link AuditEventListener#getDomain()} routing lookup as well as + * {@code onEvents()}: one listener throwing does not prevent others from + * running. Any {@link Throwable} (including {@link RuntimeException} and + * {@link Error} subclasses such as {@link LinkageError}) is logged at + * {@code WARN} and never propagates back to the caller. The barrier catches + * {@code Throwable} rather than {@code RuntimeException} so JVM-level + * failures from a misconfigured consumer bundle (missing transitive + * dependency, {@link OutOfMemoryError}, etc.) cannot prevent other + * listeners from receiving the event. + *

+ * Trust model: any bundle that resolves this service can + * emit any event for any domain. The event payload reflects the emitting + * bundle's claim; Oak does not verify it — except that caller-supplied + * values for the three reserved {@code commit.*} attestation keys are + * stripped before delivery. The normative statement is the trust contract + * on {@link AuditEvent#getPayload()}; see {@link AuditEventListener} for + * the listener-side view. + */ +@ProviderType +public interface AuditEventEmitter { + + /** + * Dispatches the event to all listeners registered for the event's + * domain. Synchronous on the calling thread. + * + * @param event the event to dispatch, non-null. + */ + void emit(@NotNull AuditEvent event); + + /** + * Returns {@code true} when at least one listener is registered for + * the given domain. Callers should gate event allocation with this + * method on hot paths. + * + * @param domain the domain to check, non-null. + */ + boolean isEnabledFor(@NotNull String domain); +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java new file mode 100644 index 00000000000..f1832e7ab23 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Map; + +import org.jetbrains.annotations.NotNull; + +/** + * Package-private immutable {@link AuditEvent} backing the + * {@link AuditEvent#of(String, String, Map) AuditEvent.of} static factories. + *

+ * Not part of the SPI surface — consumers always see the bare + * {@code AuditEvent} interface. Keeping the impl package-private is the + * core of item 3 (drop typed event hierarchy): consumers cannot + * {@code instanceof}-check or downcast; discrimination is via + * {@link AuditEvent#getDomain()} + {@link AuditEvent#getType()}. + *

+ * The {@code payload} Map is expected to already be immutable (the factory + * runs {@link Map#copyOf} before constructing the impl); the constructor + * stores it by reference. + */ +final class AuditEventImpl implements AuditEvent { + + private final String domain; + private final String type; + private final long timestamp; + private final Map payload; + + AuditEventImpl(@NotNull String domain, + @NotNull String type, + long timestamp, + @NotNull Map payload) { + this.domain = domain; + this.type = type; + this.timestamp = timestamp; + this.payload = payload; // factory invariant: already an immutable Map + } + + @NotNull + @Override + public String getDomain() { + return domain; + } + + @NotNull + @Override + public String getType() { + return type; + } + + @Override + public long getTimestamp() { + return timestamp; + } + + @NotNull + @Override + public Map getPayload() { + return payload; + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java new file mode 100644 index 00000000000..0d04ae5c0c0 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.osgi.annotation.versioning.ConsumerType; + +/** + * Receives a burst of audit events for the listener's domain. One method + * for both commit-attached events (drained after a successful + * {@code Root.commit()}) and fire-and-forget events (dispatched + * immediately via {@link AuditEventEmitter#emit(AuditEvent)}). + *

+ * Implementations must be non-blocking. Invocation is synchronous on the + * dispatching thread; expensive work (I/O, fan-out, persistence) belongs + * in an async wrapper provided by the consumer. + *

+ * Exceptions and Errors thrown from {@link #onEvents} — or from the + * {@link #getDomain()} / {@link #getRank()} accessors consulted during + * routing — are caught, logged at {@code WARN}, and swallowed by the + * dispatcher; they never propagate back to the dispatching thread. The + * dispatcher swallows {@link Throwable} broadly to ensure that one + * misconfigured listener (e.g., a {@link LinkageError} from a missing + * transitive dependency) cannot prevent other listeners from receiving + * events or abort the surrounding commit. A listener whose accessor + * throws is skipped for that dispatch (it receives nothing) and is + * picked up again once the accessor stops throwing. + *

+ * Listener invocation order is determined by {@link #getRank()} (higher + * value first). The dispatcher applies a stable sort, so listeners with + * equal rank are invoked in {@code Whiteboard} order. + * + *

Trust model

+ * Events delivered through this method may originate from either: + *
    + *
  • Oak-internal capture sites tied to a successful + * {@code Root.commit()}. Such events carry {@code commit.sessionId}, + * {@code commit.userId}, and {@code commit.timestamp} entries in + * their payload. {@code commit.userId} is {@code "oak:unknown"} for + * system commits and listeners MUST NOT attempt to + * resolve it to a real user identity.
  • + *
  • Any bundle calling {@link AuditEventEmitter#emit(AuditEvent)}. + * The accuracy of such events is the emitting bundle's responsibility; + * Oak does not verify them. They cannot carry the three reserved + * {@code commit.*} keys — Oak strips caller-supplied values for them + * before delivery. Other {@code commit.*}-prefixed keys are forwarded + * verbatim and are untrusted.
  • + *
+ * Consumers that need to distinguish between the two sources can rely on + * the presence of the {@code commit.sessionId} key — the normative + * statement and the boundaries of this attestation are documented on + * {@link AuditEvent#getPayload()}. + */ +@ConsumerType +public interface AuditEventListener { + + /** + * Returns the domain this listener is interested in. The registry + * queries {@code getDomain()} on every dispatch (no cache), so + * implementations must return a stable value across the listener's + * lifetime — if the value changes between dispatches the listener + * may silently start or stop receiving events. If it throws, the + * listener is skipped for that dispatch — see the Throwable-isolation + * note in the class Javadoc. + * + * @return non-null domain name. + */ + @NotNull + String getDomain(); + + /** + * Returns the dispatch rank for this listener — higher value is + * invoked first. The default implementation returns {@code 0}. + * + * @return rank value. + */ + default int getRank() { + return 0; + } + + /** + * Invoked when one or more events for this listener's domain are + * dispatched. Events arrive in capture order (earliest first). + * + * @param events the non-empty list of events for this listener's + * domain. Each event's payload map values are never + * null; optional fields are absent from the map. + */ + void onEvents(@NotNull List events); +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java new file mode 100644 index 00000000000..90d312f0a33 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.apache.jackrabbit.oak.api.Root; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Static façade used by Oak-internal code (commit-attached capture sites, + * the OSGi {@link AuditEventEmitter} impl) to talk to the audit pipeline. + * The façade is wired to a {@link Sink} on activation of the audit module; + * when no module is deployed (or the feature toggle is off) it short-circuits + * with zero allocation. + */ +public final class AuditEvents { + + /** + * Sink contract implemented by the audit module. Installed via + * {@link #install(Sink)} on activation of the audit module; on + * deactivation the sink is reset to a NOOP. + */ + public interface Sink { + + /** + * Returns {@code true} when the feature toggle is enabled and at + * least one listener is registered (for any domain). Cheapest gate. + */ + boolean isEnabled(); + + /** + * Returns {@code true} when the feature toggle is enabled AND at + * least one listener is registered for the given domain. Used to + * avoid event allocation when no consumer cares about the domain. + */ + boolean isEnabledFor(@NotNull String domain); + + /** + * Commit-attached path. Buffers the event against the session + * backing the supplied {@link Root}. Dispatched on commit success; + * discarded on commit failure. + */ + void record(@NotNull Root root, @NotNull AuditEvent event); + + /** + * Fire-and-forget path. Dispatches the event synchronously on the + * calling thread to all listeners registered for its domain. Not + * buffered; not tied to any commit. Caller-supplied values for the + * three reserved {@code commit.*} attestation keys are stripped + * before delivery — see the trust contract on + * {@link AuditEvent#getPayload()}. + */ + void dispatch(@NotNull AuditEvent event); + } + + private static final Sink NOOP = new Sink() { + @Override public boolean isEnabled() { return false; } + @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { } + @Override public void dispatch(@NotNull AuditEvent event) { } + }; + + private static volatile Sink sink = NOOP; + + private AuditEvents() { + // utility class + } + + /** + * Installs the active sink. Called by the audit module on activation. + * Passing {@code null} resets the façade to the NOOP sink. + *

+ * Bundle deployment is the security boundary. An attacker + * with bundle-deploy capability can intercept (by installing a custom + * Sink that exfiltrates events) or silently disable (by installing the + * NOOP via {@code install(null)}) the audit pipeline. Protecting against + * this requires OSGi-level controls (bundle signing, deployment policy); + * SPI-level access controls cannot help once a hostile bundle is already + * deployed. Embedded (non-OSGi) deployments inherit the JVM classpath as + * the boundary instead. + */ + public static void install(@Nullable Sink newSink) { + sink = (newSink != null) ? newSink : NOOP; + } + + public static boolean isEnabled() { + return sink.isEnabled(); + } + + public static boolean isEnabledFor(@NotNull String domain) { + return sink.isEnabledFor(domain); + } + + public static void record(@NotNull Root root, @NotNull AuditEvent event) { + sink.record(root, event); + } + + public static void dispatch(@NotNull AuditEvent event) { + sink.dispatch(event); + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/package-info.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/package-info.java new file mode 100644 index 00000000000..8a9b858496c --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/package-info.java @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Version("1.0.0") +package org.apache.jackrabbit.oak.spi.audit; + +import org.osgi.annotation.versioning.Version; diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycleTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycleTest.java new file mode 100644 index 00000000000..d1ca75f48fc --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditBufferLifecycleTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.concurrent.atomic.AtomicReference; + +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class AuditBufferLifecycleTest { + + @After + public void tearDown() { + AuditBufferLifecycle.install(null); + } + + @Test + public void noOpWhenNoListenerInstalled() { + // Calls on the NOOP listener must complete without exception AND + // without observable side effect on any subsequently installed + // listener. Sentinel pattern: first call NOOP, then install a + // sentinel and verify it is fresh (no spurious replay). + AuditBufferLifecycle.onCommitFailed("s-noop-1"); + AuditBufferLifecycle.onRefresh("s-noop-1"); + + AtomicReference sentinel = new AtomicReference<>(); + AuditBufferLifecycle.install(new AuditBufferLifecycle.Listener() { + @Override public void onCommitFailed(@NotNull String sessionId) { + sentinel.set("failed:" + sessionId); + } + @Override public void onRefresh(@NotNull String sessionId) { + sentinel.set("refresh:" + sessionId); + } + }); + // No prior invocation should have been queued / replayed onto + // the newly-installed sentinel. + assertNull("sentinel must not observe pre-install NOOP calls", sentinel.get()); + } + + @Test + public void onCommitFailedRoutesThroughInstalledListener() { + AtomicReference received = new AtomicReference<>(); + AuditBufferLifecycle.install(new AuditBufferLifecycle.Listener() { + @Override public void onCommitFailed(@NotNull String sessionId) { received.set(sessionId); } + @Override public void onRefresh(@NotNull String sessionId) { /* not used */ } + }); + AuditBufferLifecycle.onCommitFailed("s-1"); + assertEquals("s-1", received.get()); + } + + @Test + public void onRefreshRoutesThroughInstalledListener() { + AtomicReference received = new AtomicReference<>(); + AuditBufferLifecycle.install(new AuditBufferLifecycle.Listener() { + @Override public void onCommitFailed(@NotNull String sessionId) { /* not used */ } + @Override public void onRefresh(@NotNull String sessionId) { received.set(sessionId); } + }); + AuditBufferLifecycle.onRefresh("s-2"); + assertEquals("s-2", received.get()); + } + + @Test + public void installNullResetsToNoOp() { + AtomicReference received = new AtomicReference<>(); + AuditBufferLifecycle.install(new AuditBufferLifecycle.Listener() { + @Override public void onCommitFailed(@NotNull String sessionId) { received.set(sessionId); } + @Override public void onRefresh(@NotNull String sessionId) { received.set(sessionId); } + }); + AuditBufferLifecycle.install(null); + // After reset, the custom listener must not be invoked. + AuditBufferLifecycle.onCommitFailed("s-3"); + AuditBufferLifecycle.onRefresh("s-3"); + assertNull(received.get()); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditConfigurationTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditConfigurationTest.java new file mode 100644 index 00000000000..bb8eba4fa63 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditConfigurationTest.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +public class AuditConfigurationTest { + + @Test + public void noopSingletonIsNotNull() { + assertNotNull(AuditConfiguration.NOOP); + } + + @Test + public void noopIsActiveReturnsFalse() { + // NOOP placeholder: the audit pipeline is by definition NOT active when + // no implementation is bound. The Noop inner class explicitly overrides + // isActive() to return false, so any caller probing via a + // null-safe NOOP handle safely reports "audit not running". + assertFalse(AuditConfiguration.NOOP.isActive()); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java new file mode 100644 index 00000000000..c6ca8d3afe6 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +/** + * Unit tests for the package-private {@link AuditEventImpl} value holder. + *

+ * Co-located in the same package as the impl so we can construct it + * directly (it is not exposed in the SPI). The factory paths are covered + * by {@link AuditEventTest}; this class focuses on the ctor + getters. + */ +public class AuditEventImplTest { + + @Test + public void ctorStoresFields() { + Map payload = Map.of("k", "v"); + AuditEventImpl e = new AuditEventImpl("test.domain", "t", 42L, payload); + + assertEquals("test.domain", e.getDomain()); + assertEquals("t", e.getType()); + assertEquals(42L, e.getTimestamp()); + // Factory invariant: payload is stored by reference (already immutable). + assertSame(payload, e.getPayload()); + } + + @Test + public void ctorAcceptsEmptyPayload() { + AuditEventImpl e = new AuditEventImpl("test.domain", "t", 0L, Map.of()); + assertEquals(Map.of(), e.getPayload()); + } + + @Test + public void payloadInstanceIsImmutable() { + // AuditEventImpl trusts the caller to pass an immutable Map. + // We exercise that the contract holds end-to-end by constructing + // with Map.of() (immutable) and verifying mutation throws. + AuditEventImpl e = new AuditEventImpl("test.domain", "t", 0L, Map.of("k", "v")); + assertThrows(UnsupportedOperationException.class, + () -> e.getPayload().put("k2", "v2")); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java new file mode 100644 index 00000000000..1dff2894768 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Collections; +import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class AuditEventListenerTest { + + @Test + public void defaultRankIsZero() { + AuditEventListener listener = new AuditEventListener() { + @Override + public @NotNull String getDomain() { + return "test"; + } + + @Override + public void onEvents(@NotNull List events) { + // no-op for this test + } + }; + assertEquals(0, listener.getRank()); + } + + @Test + public void listenerReceivesCallToOnEvents() { + // Per AuditEventListener.onEvents Javadoc: "the non-empty list of + // events for this listener's domain." Test exercises the contract + // with a non-empty list — passing emptyList would contradict the + // documented contract. + AuditEvent event = new AuditEvent() { + @Override public @NotNull String getDomain() { return "test"; } + @Override public @NotNull String getType() { return "t"; } + @Override public long getTimestamp() { return 0L; } + }; + List input = Collections.singletonList(event); + + final List[] received = new List[]{null}; + AuditEventListener listener = new AuditEventListener() { + @Override + public @NotNull String getDomain() { + return "test"; + } + + @Override + public void onEvents(@NotNull List events) { + received[0] = events; + } + }; + listener.onEvents(input); + assertEquals(1, received[0].size()); + assertEquals(event, received[0].get(0)); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java new file mode 100644 index 00000000000..f0ad0387486 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Collections; +import java.util.Map; + +import org.jetbrains.annotations.NotNull; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class AuditEventTest { + + @Test + public void defaultGetPayloadReturnsEmptyMap() { + // Impl that does NOT override getPayload() — exercises the default method body. + AuditEvent e = new AuditEvent() { + @Override public @NotNull String getDomain() { return "test"; } + @Override public @NotNull String getType() { return "t"; } + @Override public long getTimestamp() { return 0L; } + }; + assertEquals(Collections.emptyMap(), e.getPayload()); + } + + //---------------------------< static factory of(domain, type, payload) >--- + + @Test + public void factoryWithPayloadReturnsEventWithSuppliedFields() { + long before = System.currentTimeMillis(); + AuditEvent e = AuditEvent.of("test.domain", "membership.added", + Map.of("groupPath", "/g", "memberPath", "/u")); + long after = System.currentTimeMillis(); + + assertNotNull(e); + assertEquals("test.domain", e.getDomain()); + assertEquals("membership.added", e.getType()); + assertEquals(Map.of("groupPath", "/g", "memberPath", "/u"), e.getPayload()); + // Capture timestamp is taken inside of(...) — must fall within the + // call window observed by the test thread. + assertTrue("captured timestamp must be in [before, after]", + before <= e.getTimestamp() && e.getTimestamp() <= after); + } + + @Test + public void factoryWithoutPayloadReturnsEventWithEmptyPayload() { + AuditEvent e = AuditEvent.of("test.domain", "membership.removed"); + assertEquals("test.domain", e.getDomain()); + assertEquals("membership.removed", e.getType()); + assertEquals(Collections.emptyMap(), e.getPayload()); + } + + @Test + public void factoryRejectsBlankDomain() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditEvent.of("", "type", Map.of())); + assertTrue(ex.getMessage().contains("domain")); + } + + @Test + public void factoryRejectsWhitespaceDomain() { + // String.isBlank() — exercise the non-empty-but-blank path. + assertThrows(IllegalArgumentException.class, + () -> AuditEvent.of(" ", "type", Map.of())); + } + + @Test + public void factoryRejectsBlankType() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditEvent.of("test.domain", "", Map.of())); + assertTrue(ex.getMessage().contains("type")); + } + + @Test + public void factoryRejectsWhitespaceType() { + assertThrows(IllegalArgumentException.class, + () -> AuditEvent.of("test.domain", " \t ", Map.of())); + } + + @Test + public void noPayloadOverloadAlsoValidatesDomain() { + // The no-payload overload delegates to the 3-arg form; verify both + // validation branches still fire via this route. + assertThrows(IllegalArgumentException.class, () -> AuditEvent.of("", "type")); + assertThrows(IllegalArgumentException.class, () -> AuditEvent.of("test.domain", "")); + } + + @Test + public void factoryPayloadIsImmutable() { + AuditEvent e = AuditEvent.of("test.domain", "t", Map.of("k", "v")); + Map p = e.getPayload(); + assertThrows(UnsupportedOperationException.class, () -> p.put("k2", "v2")); + } + + @Test + public void factoryDecouplesCallerMap() { + // Map.copyOf returns the same instance for an already-immutable Map.of result, + // so we use HashMap to verify the defensive-copy semantics. + java.util.Map mutable = new java.util.HashMap<>(); + mutable.put("k", "v"); + AuditEvent e = AuditEvent.of("test.domain", "t", mutable); + + mutable.put("k2", "v2"); // mutate the source AFTER construction + assertEquals("event payload must not reflect post-construction source mutation", + Map.of("k", "v"), e.getPayload()); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java new file mode 100644 index 00000000000..dc7c6619535 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.jackrabbit.oak.api.Root; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class AuditEventsTest { + + @After + public void tearDown() { + AuditEvents.install(null); + } + + private static AuditEvent fixedEvent(@NotNull String domain) { + return new AuditEvent() { + @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull String getType() { return "t"; } + @Override public long getTimestamp() { return 0L; } + @Override public @NotNull Map getPayload() { return Collections.emptyMap(); } + }; + } + + @Test + public void facadeNoOpWhenNoSinkInstalled() { + assertFalse(AuditEvents.isEnabled()); + assertFalse(AuditEvents.isEnabledFor("test.domain")); + AuditEvents.record(mock(Root.class), fixedEvent("test.domain")); + AuditEvents.dispatch(fixedEvent("test.domain")); + // no exception, no observable effect — verified by no sink installed + } + + @Test + public void recordRoutesThroughInstalledSink() { + AtomicReference received = new AtomicReference<>(); + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return true; } + @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { received.set(event); } + @Override public void dispatch(@NotNull AuditEvent event) { /* not used */ } + }); + AuditEvent e = fixedEvent("test.domain"); + AuditEvents.record(mock(Root.class), e); + assertSame(e, received.get()); + } + + @Test + public void dispatchRoutesThroughInstalledSink() { + AtomicReference received = new AtomicReference<>(); + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return true; } + @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* not used */ } + @Override public void dispatch(@NotNull AuditEvent event) { received.set(event); } + }); + AuditEvent e = fixedEvent("aem.content"); + AuditEvents.dispatch(e); + assertSame(e, received.get()); + } + + @Test + public void installNullResetsToNoOp() { + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return true; } + @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { } + @Override public void dispatch(@NotNull AuditEvent event) { } + }); + assertTrue(AuditEvents.isEnabled()); + AuditEvents.install(null); + assertFalse(AuditEvents.isEnabled()); + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/core/MutableRoot.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/core/MutableRoot.java index a9b9c0b9db2..ef62b1ac72a 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/core/MutableRoot.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/core/MutableRoot.java @@ -46,6 +46,7 @@ import org.apache.jackrabbit.oak.query.ExecutionContext; import org.apache.jackrabbit.oak.query.QueryEngineImpl; import org.apache.jackrabbit.oak.query.QueryEngineSettings; +import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; import org.apache.jackrabbit.oak.spi.commit.CommitContext; import org.apache.jackrabbit.oak.spi.commit.CommitHook; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; @@ -234,6 +235,13 @@ public MutableTree getTree(@NotNull String path) { @Override public void rebase() { checkLive(); + // Intentionally does NOT drain the audit buffer: rebase() preserves + // the session's transient changes (they are replayed on the new + // base), so the audit events captured alongside those surviving + // changes must survive too. Draining here would drop audit events + // for changes that are still pending and will be committed. Contrast + // with refresh(), which discards transient changes and therefore + // also drains the buffer. store.rebase(builder); secureBuilder.baseChanged(); if (permissionProvider.hasValue()) { @@ -244,6 +252,7 @@ public void rebase() { @Override public final void refresh() { checkLive(); + AuditBufferLifecycle.onRefresh(getContentSession().toString()); store.reset(builder); secureBuilder.baseChanged(); modCount = 0; @@ -258,7 +267,15 @@ public void commit(@NotNull Map info) throws CommitFailedExcepti ContentSession session = getContentSession(); CommitInfo commitInfo = new CommitInfo( session.toString(), session.getAuthInfo().getUserID(), newInfoWithCommitContext(info)); - store.merge(builder, getCommitHook(), commitInfo); + boolean merged = false; + try { + store.merge(builder, getCommitHook(), commitInfo); + merged = true; + } finally { + if (!merged) { + AuditBufferLifecycle.onCommitFailed(commitInfo.getSessionId()); + } + } secureBuilder.baseChanged(); modCount = 0; if (permissionProvider.hasValue()) { diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java new file mode 100644 index 00000000000..049e67c0feb --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Per-thread, per-session staging area for audit events. Events captured + * via {@link org.apache.jackrabbit.oak.spi.audit.AuditEvents#record} + * are appended to a session-scoped buffer held in a {@link ThreadLocal}; + * the buffer is allocated lazily on first {@code record} and is removed + * when {@link #drain(String)} is called (i.e. on commit snapshot) or when + * a lifecycle event clears it. + *

+ * The buffer also implements {@link AuditBufferLifecycle.Listener}; it is + * installed via {@code AuditBufferLifecycle.install(this)} by + * {@link AuditConfigurationImpl} on activation. + *

+ * Soft per-session cap. A single session must not be able + * to accumulate an unbounded number of staged events (e.g. a very large + * transaction, or a session that records without ever committing/refreshing). + * Once {@value #MAX_EVENTS_PER_SESSION} events are staged for a session, + * further events are dropped and a single WARN is logged for that session + * (not one per dropped event). The drop is bounded and self-healing: the + * next {@link #drain(String)} / {@link #onRefresh(String)} / + * {@link #onCommitFailed(String)} clears the session slot and re-arms the + * warning. + *

+ * Threading contract: capture, drain and lifecycle calls all happen on + * the session's caller thread. Cross-thread invocation is not supported + * — sessions are not thread-safe in Oak. Because the staging area is a + * {@link ThreadLocal}, a drain issued from a thread other than the one + * that captured simply sees an empty buffer (returns {@code null}); it + * never observes or removes another thread's events. + */ +final class AuditBuffer implements AuditBufferLifecycle.Listener { + + private static final Logger log = LoggerFactory.getLogger(AuditBuffer.class); + + /** + * Soft upper bound on the number of events staged for a single session + * on a single thread. Events beyond this are dropped (with a single + * WARN per session) to bound the per-thread memory a runaway session + * can pin. + */ + static final int MAX_EVENTS_PER_SESSION = 10_000; + + /** + * Thread-local map keyed by {@code sessionId} + * ({@code ContentSession.toString()}). The inner {@link SessionBuffer} + * is created lazily on the first {@link #record(String, AuditEvent)} + * for the given session, kept alive across multiple captures, and + * removed by {@link #drain(String)} / {@link #onCommitFailed(String)} / + * {@link #onRefresh(String)}. + *

+ * The outer map starts {@code null} (a single {@link ThreadLocal} + * lookup yielding {@code null}) and is allocated on first capture + * for the thread. + */ + private final ThreadLocal> tl = new ThreadLocal<>(); + + /** + * Appends {@code event} to the session's per-thread buffer, + * allocating the inner buffer lazily. Drops the event (logging a + * single WARN per session) once the session has reached + * {@link #MAX_EVENTS_PER_SESSION} staged events. + * + * @param sessionId session id, non-null. + * @param event event to record, non-null. + */ + void record(@NotNull String sessionId, @NotNull AuditEvent event) { + Map bySession = tl.get(); + if (bySession == null) { + bySession = new HashMap<>(4); + tl.set(bySession); + } + SessionBuffer sb = bySession.computeIfAbsent(sessionId, k -> new SessionBuffer()); + // Soft per-session cap. Overflow drops the LATEST events with a + // WARN-once (no per-event log spam). Deferred follow-up: surface the + // truncation IN-BAND (e.g. an audit.system meta-domain overflow event + // carrying a dropped count) so a consumer sees the gap, not just a log + // line. Threat is narrow — an attacker would need write access AND a + // single transaction emitting >MAX_EVENTS_PER_SESSION audit events to + // push a later (sensitive) event past the cap; bounded and self-healing + // (the slot re-arms on the next drain/refresh). + if (sb.events.size() >= MAX_EVENTS_PER_SESSION) { + if (!sb.overflowWarned) { + sb.overflowWarned = true; + log.warn("Audit buffer for session {} reached the cap of {} staged events; " + + "dropping further events for this session until the next commit/refresh. " + + "This usually indicates a very large transaction or a session that records " + + "audit events without committing.", sessionId, MAX_EVENTS_PER_SESSION); + } + return; + } + sb.events.add(event); + } + + /** + * Test-only inspector. Returns a defensive copy of the + * events staged for {@code sessionId} without removing + * them. Mutating the returned list does not affect the buffer. Production + * drain goes through {@link #drain(String)}. + * + * @param sessionId session id, non-null. + * @return an immutable copy of the staged events, or {@code null} when + * nothing was staged for the session on the current thread. + */ + @Nullable + List peek(@NotNull String sessionId) { + Map bySession = tl.get(); + if (bySession == null) { + return null; + } + SessionBuffer sb = bySession.get(sessionId); + return (sb == null) ? null : List.copyOf(sb.events); + } + + /** + * Detaches and returns the staged events for {@code sessionId}, + * leaving the buffer empty for that session. + * + * @param sessionId session id, non-null. + * @return the staged events, or {@code null} when nothing was + * staged for the session on the current thread. + */ + @Nullable + List drain(@NotNull String sessionId) { + Map bySession = tl.get(); + if (bySession == null) { + return null; + } + SessionBuffer drained = bySession.remove(sessionId); + if (bySession.isEmpty()) { + tl.remove(); + } + return (drained == null) ? null : drained.events; + } + + /** + * Drops all staged events for the current thread. + * Called by {@link AuditConfigurationImpl#deactivate} so the + * deactivator thread leaves no residue. + *

+ * Note: this cannot reach across thread boundaries. ThreadLocal + * entries on other threads remain until their owning thread next + * calls {@link #record(String, AuditEvent)}, {@link #drain(String)}, + * or the {@code AuditBufferLifecycle} listener is invoked. The + * resulting residual leak is bounded by + * {@code worker-pool × in-flight sessions}. + */ + void clearAll() { + tl.remove(); + } + + //----------------------------------------< AuditBufferLifecycle.Listener >--- + @Override + public void onCommitFailed(@NotNull String sessionId) { + drain(sessionId); + } + + @Override + public void onRefresh(@NotNull String sessionId) { + drain(sessionId); + } + + /** + * Per-session staging holder: the captured events plus a one-shot + * flag that ensures the overflow WARN is logged at most once per + * session slot (re-armed when the slot is recreated after a drain). + */ + private static final class SessionBuffer { + final List events = new ArrayList<>(4); + boolean overflowWarned; + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java new file mode 100644 index 00000000000..6d780e45754 --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java @@ -0,0 +1,493 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.osgi.OsgiWhiteboard; +import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; +import org.apache.jackrabbit.oak.spi.audit.AuditConfiguration; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.commit.Observer; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default {@link AuditConfiguration} implementation. Contributes the + * audit pipeline to Oak: + *

    + *
  • Registers a {@link Feature} toggle gating capture and dispatch.
  • + *
  • Installs a per-session buffer ({@link AuditBuffer}) into + * {@link AuditBufferLifecycle}.
  • + *
  • Installs the {@link AuditEvents} sink that routes capture-site + * calls into the buffer.
  • + *
  • Tracks {@code AuditEventListener} services on the Whiteboard + * via {@link WhiteboardAuditEventListenerRegistry}.
  • + *
  • Registers an {@link AuditDrainObserver} as an OSGi {@link Observer} + * service; Oak's {@code ObserverTracker} (in {@code oak-jcr}'s + * {@code RepositoryManager}) picks it up and subscribes it to the root + * NodeStore. The observer drains the buffer on commit success and + * dispatches events to listeners.
  • + *
+ * Registered as {@link AuditConfiguration} only — not a + * {@code SecurityConfiguration}, contributes no commit hooks, not reachable + * via {@code SecurityProvider.getConfiguration(AuditConfiguration.class)}. + * Embedded callers obtain the drain observer via {@link #getDrainObserver()}. + *

+ * When the feature toggle is disabled, capture is a no-op and the observer + * short-circuits — see {@link AuditEvents#isEnabled()} and + * {@link AuditDrainObserver#contentChanged}. + */ +@Component(service = AuditConfiguration.class) +@Designate(ocd = AuditConfigurationImpl.Configuration.class) +public class AuditConfigurationImpl implements AuditConfiguration { + + /** + * Feature toggle name, following the {@code FT_OAK-} convention + * in {@code AGENTS.md}. Disabled by default: this is a new feature, + * not a bug fix. + *

+ * Why not on the public SPI interface + * ({@link AuditConfiguration}): moving this constant to the + * SPI would commit the literal value to the public surface forever. + * It stays impl-local; promoting it later is a binary-additive change + * if a need arises. + */ + public static final String FEATURE_TOGGLE_NAME = "FT_OAK-12331"; + + @ObjectClassDefinition(name = "Apache Jackrabbit Oak AuditConfiguration", + description = "Audit event pipeline. Capture and dispatch are " + + "gated by the '" + FEATURE_TOGGLE_NAME + "' feature toggle " + + "(disabled by default).") + @interface Configuration { + // Configuration is currently empty by design: capture/dispatch behavior + // is controlled exclusively by the feature toggle. Listeners are + // contributed via OSGi services / the Whiteboard. + } + + private static final Logger log = LoggerFactory.getLogger(AuditConfigurationImpl.class); + + // Package-private (not private) on the internal-state fields so + // AuditConfigurationImplTest can mock-replace them to verify the + // dispose-order invariant via Mockito InOrder. Production callers MUST + // NOT touch these fields directly — go through initialize() / dispose(). + // + // JMM-safety. featureToggle, buffer, registry, and drainObserver are + // package-private and non-volatile by design. The invariant they rely on: + // they are mutated only by initialize(Whiteboard) and dispose(), which + // the contract specifies must each run exactly once and on the same + // thread; reads happen either + // (a) on that same thread — the static AuditEvents.record / dispatch + // facade reads through the AuditEvents.sink field (itself volatile, + // providing the publication barrier), and getDrainObserver() is + // called by activate() on the SCR thread AFTER initialize() on the + // SCR thread; or + // (b) on a commit thread that arrives via Observer.contentChanged, + // after the ServiceRegistration publication barrier established + // by BundleContext.registerService in activate(). + // Both paths satisfy the JMM happens-before contract without per-field + // volatile. If a future change adds a "share the singleton across + // pipelines" pattern OR cross-thread mutation of these fields, this + // invariant breaks — at that point the fields MUST be made volatile (or + // properly immutable via constructor injection). + Feature featureToggle; + AuditBuffer buffer; + WhiteboardAuditEventListenerRegistry registry; + + /** + * Singleton {@link AuditDrainObserver} instance constructed by + * {@link #initialize(Whiteboard)} and zeroed by {@link #dispose()}. + * Exposed via {@link #getDrainObserver()} as an {@link Observer}. + *

+ * Singleton-not-factory by design: each {@code AuditConfigurationImpl} + * owns at most one Observer because (a) the {@link AuditBuffer} + * {@code ThreadLocal} is buffer-instance-scoped, so multiple Observer + * instances would compete for the same drain, and (b) the destructive + * {@code buffer.drain(sessionId)} contract is the cleanup mechanism — + * double-attach would mask future non-destructive-drain refactors. + */ + AuditDrainObserver drainObserver; + + /** + * OSGi service registration for the {@link AuditDrainObserver}. Held + * so {@link #deactivate} can unregister and let {@code ObserverTracker} + * close its subscription on the root NodeStore. {@code null} outside + * the OSGi-active window; embedded callers manage observer lifetime + * through their own {@code ((Observable) store).addObserver(...)} call + * (see class Javadoc). + */ + ServiceRegistration observerRegistration; + + public AuditConfigurationImpl() { + super(); + } + + @SuppressWarnings("UnusedDeclaration") + @Activate + private void activate(@NotNull Configuration configuration, + @NotNull BundleContext bundleContext, + @NotNull Map properties) { + // Step 1-4: install sinks/registry/buffer/toggle. Capture-site + // record(...) calls reach the buffer as soon as initialize returns. + // The singleton AuditDrainObserver is also constructed inside + // initialize() (step 5 below) so the impl is fully wired before + // we publish anything externally. + initialize(new OsgiWhiteboard(bundleContext)); + // Step 6 LAST: publish the Observer service. ObserverTracker + // (oak-store-spi/.../spi/commit/ObserverTracker.java, instantiated + // per-NodeStoreService in DocumentNodeStoreService, SegmentNodeStoreRegistrar, + // CompositeNodeStoreService) subscribes it to the root NodeStore. + // Any commit thread racing with activation that reaches step 6 before + // ObserverTracker has noticed the service will simply miss the drain + // on this one commit — events stay in the per-thread buffer until the + // next commit on the same session. No correctness risk. + observerRegistration = bundleContext.registerService( + Observer.class.getName(), getDrainObserver(), null); + } + + /** + * Non-OSGi entry point for wiring up the audit pipeline. Called by + * {@link #activate} in OSGi deployments after the {@code BundleContext} + * has been unwrapped into an {@code OsgiWhiteboard}, and by embedded + * callers (tests, {@code OakFixture}) directly. + *

+ * Embedded callers must follow up with + * {@link #getDrainObserver()} to obtain the Observer and attach + * it to the root NodeStore. See {@link #getDrainObserver()} Javadoc for + * the recommended attach pattern and the {@code Oak.with(Observer)} + * caveat. + *

+ * Must be called exactly once per instance. Calling + * it more than once orphans the previous {@code Feature} toggle and + * registry tracker, and silently overwrites the static + * {@link AuditEvents} / {@link AuditBufferLifecycle} sinks. To rewire, + * call {@link #dispose()} first. + *

+ * Activation ordering rationale. + * {@link AuditBufferLifecycle#install AuditBufferLifecycle.install(buffer)} + * runs before + * {@link AuditEvents#install AuditEvents.install(BufferSink)} so that any + * concurrent capture arriving in the install window goes through the + * NOOP sink (no buffer write) rather than through a live {@code BufferSink} + * with an orphaned lifecycle handle. The inverse ordering would minimize + * lifecycle bypass but maximize silent capture loss; we prefer the former. + * + * @param whiteboard the whiteboard to register the {@code Feature} + * toggle and {@code AuditEventListener} tracker on; + * non-null. + */ + public void initialize(@NotNull Whiteboard whiteboard) { + featureToggle = Feature.newFeature(FEATURE_TOGGLE_NAME, whiteboard); + + registry = new WhiteboardAuditEventListenerRegistry(); + registry.start(whiteboard); + + buffer = new AuditBuffer(); + AuditBufferLifecycle.install(buffer); + + AuditEvents.install(new BufferSink(featureToggle, registry, buffer)); + + // Construct the singleton AuditDrainObserver LAST in initialize(). + // This closes a potential TOCTOU window in the OSGi @Activate flow: + // ObserverTracker (which subscribes to Observer services on a + // background thread) might fire the bootstrap CommitInfo.EMPTY_EXTERNAL + // invocation before @Activate returns. With the singleton constructed + // here (inside initialize, which @Activate calls FIRST), getDrainObserver() + // is safe to call from anywhere in @Activate after this point. + drainObserver = new AuditDrainObserver(featureToggle, buffer, registry); + + log.info("Audit pipeline activated. Toggle '{}' = {}.", + FEATURE_TOGGLE_NAME, featureToggle.isEnabled()); + } + + /** + * Returns the singleton {@link Observer} bound to this pipeline's + * buffer, registry, and feature toggle. Constructed once by + * {@link #initialize(Whiteboard)} and cached for the lifetime of this + * {@code AuditConfigurationImpl} instance; zeroed by {@link #dispose()}. + *

+ * The singleton shape is deliberate. Each {@code AuditConfigurationImpl} + * owns at most one Observer because (a) the {@link AuditBuffer} + * {@code ThreadLocal} is buffer-instance-scoped, so multiple Observer + * instances would compete for the same drain on every commit thread, + * and (b) the destructive {@code buffer.drain(sessionId)} contract is + * the cleanup mechanism — a future non-destructive-drain refactor + * would silently turn double-attach into double-dispatch. + *

+ * Embedded callers pass the returned Observer to + * {@code ((Observable) store).addObserver(...)}, holding the returned + * {@code Closeable} for tear-down. {@code Oak.with(Observer)} is + * not a reliable embedded path when the caller also passes + * {@code Oak.with(Whiteboard)} to replace Oak's default whiteboard: + * the auto-attach at {@code Oak.java:300-302} is wired to the default + * whiteboard's anonymous override only. + *

+ * OSGi callers never invoke this method directly — {@code @Activate} + * does, then publishes the singleton via + * {@code BundleContext.registerService(...)}. + * + * @return the singleton drain observer; never {@code null}. + * @throws IllegalStateException when called before + * {@link #initialize(Whiteboard)} OR after {@link #dispose()} + * (both states leave {@code drainObserver == null}). + */ + public @NotNull Observer getDrainObserver() { + if (drainObserver == null) { + throw new IllegalStateException( + "AuditConfigurationImpl.initialize(...) must be called first" + + " (or dispose() has already run)"); + } + return drainObserver; + } + + // Package-private (was private) so the test suite can invoke the + // OSGi-shaped tear-down flow directly to verify the + // "unregister before dispose internals" ordering invariant via + // Mockito InOrder. OSGi DS resolves @Deactivate via reflection; + // package-private access does not change DS binding. + @Deactivate + void deactivate() { + // Step 0: unregister the Observer service FIRST. ObserverTracker + // notices the service disappear → closes its subscription on the + // root NodeStore → no further contentChanged calls reach our + // AuditDrainObserver. A commit thread that's mid-way through + // contentChanged when this runs is protected by the outer Throwable + // barrier in AuditDrainObserver.contentChanged (defense in depth). + if (observerRegistration != null) { + try { + observerRegistration.unregister(); + } catch (RuntimeException e) { + log.warn("Audit deactivate: observerRegistration.unregister() failed; continuing.", e); + } finally { + observerRegistration = null; + } + } + dispose(); + } + + /** + * Non-OSGi tear-down entry point, paired with + * {@link #initialize(Whiteboard)}. Called by {@link #deactivate} in + * OSGi deployments (after observer unregistration) and directly by + * tests / embedded callers. Safe to call when no pipeline was + * previously initialized — each step guards against unset state. + *

+ * Each cleanup step is wrapped in its own try/catch so an exception + * at one step does not skip the rest: an OSGi deactivate that leaves + * static façades pointing at half-torn-down state is worse than a + * noisy log. + */ + public void dispose() { + // Order matters: close the feature toggle first so any racing capture + // short-circuits before reaching state we're about to tear down; then + // stop discovery; then NOOP the static façades; then drain the buffer. + + // Precondition: the Observer must be detached from the root NodeStore + // BEFORE we tear down the pipeline state it references. The outer + // Throwable barrier in AuditDrainObserver.contentChanged is the safety + // net, but the dispose-order invariant is the policy. + // - OSGi path: @Deactivate calls observerRegistration.unregister() then + // zeros the field before invoking dispose(); precondition trivially + // satisfied. + // - Embedded path: observerRegistration is null (no OSGi + // registerService call); precondition trivially satisfied. The + // embedded caller is separately responsible for closing the + // Closeable returned by ((Observable) store).addObserver(...) BEFORE + // calling dispose() — that Closeable is owned by the caller, not by + // AuditConfigurationImpl, because tests/fixtures need explicit + // lifecycle control over their per-store subscriptions. + // - Misuse case (e.g. test calls dispose() directly after @Activate + // without invoking @Deactivate): caught here, loud failure with + // actionable message. + if (observerRegistration != null) { + throw new IllegalStateException( + "Observer registration must be unregistered before dispose(). " + + "OSGi @Deactivate handles this automatically; " + + "direct callers must unregister first."); + } + + // 1. Close the feature toggle FIRST. AuditEvents.isEnabled() + // immediately returns false, so any new capture-site call + // that races with deactivation short-circuits before reaching + // the buffer (which we're about to dismantle). + if (featureToggle != null) { + try { + featureToggle.close(); + } catch (RuntimeException e) { + log.warn("Audit deactivate: featureToggle.close() failed; continuing.", e); + } finally { + featureToggle = null; + } + } + // 2. Stop discovery — listeners disappear from getServices(). + if (registry != null) { + try { + registry.stop(); + } catch (RuntimeException e) { + log.warn("Audit deactivate: registry.stop() failed; continuing.", e); + } finally { + registry = null; + } + } + // 3. Route AuditEvents/AuditBufferLifecycle to NOOP. Now even + // callers that already passed the isEnabled() gate land on + // no-ops. + try { + AuditEvents.install(null); + } catch (RuntimeException e) { + log.warn("Audit deactivate: AuditEvents.install(null) failed; continuing.", e); + } + try { + AuditBufferLifecycle.install(null); + } catch (RuntimeException e) { + log.warn("Audit deactivate: AuditBufferLifecycle.install(null) failed; continuing.", e); + } + // 4. Drain the deactivator thread's ThreadLocal. Residual entries + // on other threads are bounded by worker-pool × in-flight + // sessions; acknowledged trade-off (no weak-reference machinery). + if (buffer != null) { + try { + buffer.clearAll(); + } catch (RuntimeException e) { + log.warn("Audit deactivate: buffer.clearAll() failed; continuing.", e); + } finally { + buffer = null; + } + } + // 5. Zero the cached singleton observer. Subsequent getDrainObserver() + // calls throw IllegalStateException — same contract as pre-init. + drainObserver = null; + log.info("Audit pipeline deactivated."); + } + + //------------------------------------------------< AuditConfiguration >--- + + /** + * Delegates to {@link AuditEvents#isEnabled()} — the single source of + * truth for "is the audit pipeline up?". The static + * {@code AuditEvents.sink} field is {@code volatile}, so any thread + * reading {@code isActive()} sees a JMM-safe value without depending + * on the OSGi activation publication barrier. + *

+ * Pre-init, post-dispose, and NOOP-bound deployments all return + * {@code false} for free: the NOOP sink installed by default reports + * {@code isEnabled() == false}, {@link #initialize(Whiteboard)} + * installs the active sink as its LAST step, and {@link #dispose()} + * resets the sink to NOOP. No null-checks needed. + */ + @Override + public boolean isActive() { + return AuditEvents.isEnabled(); + } + + //-----------------------------------------------------------< internal >--- + /** + * Composite gate exposed to capture sites via {@link AuditEvents}. + * Both predicates ({@link Feature#isEnabled()} and + * {@link WhiteboardAuditEventListenerRegistry#hasAnyListener()}) are + * single volatile reads; together they keep the disabled path free + * of allocation. + */ + private static final class BufferSink implements AuditEvents.Sink { + + private final Feature toggle; + private final WhiteboardAuditEventListenerRegistry registry; + private final AuditBuffer buffer; + + BufferSink(@NotNull Feature toggle, + @NotNull WhiteboardAuditEventListenerRegistry registry, + @NotNull AuditBuffer buffer) { + this.toggle = toggle; + this.registry = registry; + this.buffer = buffer; + } + + @Override + public boolean isEnabled() { + return toggle.isEnabled() && registry.hasAnyListener(); + } + + @Override + public boolean isEnabledFor(@NotNull String domain) { + return toggle.isEnabled() && registry.hasListenerFor(domain); + } + + @Override + public void record(@NotNull Root root, @NotNull AuditEvent event) { + if (!isEnabledFor(event.getDomain())) { + return; + } + buffer.record(root.getContentSession().toString(), event); + } + + @Override + public void dispatch(@NotNull AuditEvent event) { + if (!toggle.isEnabled()) { + return; + } + List listeners = registry.getListeners(); + if (listeners.isEmpty()) { + return; + } + // Fire-and-forget payloads are caller-supplied and undecorated: + // strip the three Oak-attested commit.* keys so their presence in + // any dispatched payload is a reliable "Oak-attested" signal — + // see the AuditEvent.getPayload() trust contract. + AuditEvent toDispatch = CommitMetadataDecorator.stripReservedCommitKeys(event); + String domain = toDispatch.getDomain(); + List single = Collections.singletonList(toDispatch); + for (AuditEventListener listener : listeners) { + // The listener's getDomain() filter sits INSIDE the barrier — + // it is listener code just like onEvents(), and a throw here + // would otherwise escape into the emitter, breaching the + // published AuditEventEmitter contract ("never propagates + // back to the caller"). + try { + if (!domain.equals(listener.getDomain())) { + continue; + } + listener.onEvents(single); + } catch (Throwable t) { + // Per-listener isolation: a misconfigured consumer bundle whose listener + // throws e.g. LinkageError must not fail the commit for unrelated work. + // JVM-level pathology (OutOfMemoryError) is caught here too but + // re-triggers on the next allocation and surfaces through normal channels. + // Do not narrow this catch to RuntimeException — listener Throwables + // (any kind) must not escape into the dispatch caller. `domain` is the + // EVENT's domain (computed before the loop), not a listener re-invocation. + log.warn("AuditEventListener {} threw {} on fire-and-forget dispatch in domain '{}'; isolating from other listeners.", + listener.getClass().getName(), t.getClass().getSimpleName(), domain, t); + } + } + } + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java new file mode 100644 index 00000000000..4d129fe685e --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.apache.jackrabbit.oak.spi.commit.Observer; +import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link Observer} that drains the {@link AuditBuffer} on commit success + * and dispatches captured events to all registered {@link AuditEventListener}s. + *

+ * The observer fires synchronously on the same thread as the surrounding + * {@code MutableRoot.commit()} — by the contract of + * {@link org.apache.jackrabbit.oak.spi.commit.Observable#addObserver} + * the call is made from the commit dispatch path, before + * {@code NodeStore.merge(...)} returns. This preserves the + * {@code ThreadLocal} semantics that the {@link AuditBuffer} relies on. + *

+ * External changes are ignored. When + * {@link CommitInfo#isExternal()} returns {@code true} (cluster sync from + * a peer node, or the initial replay invocation at {@code addObserver()} + * time with {@link CommitInfo#EMPTY_EXTERNAL}), the observer returns + * immediately. External commits did not originate any local + * {@code AuditEvents.record(...)} calls, so there is nothing in the + * per-session buffer to drain. Explicit short-circuit; cleaner than + * relying on the buffer to return empty. + *

+ * Two-layer exception isolation: + *

    + *
  • Outer barrier wraps the ENTIRE method body. The + * Observer chain has no per-observer isolation + * ({@code CompositeObserver.java:46-53} — bare {@code for} loop with no + * try/catch). Any throw out of this method propagates through + * {@code DocumentNodeStore.java:1140-1144} (in a {@code finally} after + * {@code setRoot}) or {@code LockBasedScheduler.java:303} (after + * {@code head.set}), surfacing as a {@code RuntimeException} to the + * merge caller despite a successful durable commit. Worse, on + * DocumentNodeStore the inner catch at {@code DocumentNodeStore:1130-1139} + * suppresses in-memory commit-apply failures, so an audit-induced throw + * would mask a different kind of failure entirely. The outer Throwable + * barrier guarantees audit never masquerades as a commit failure.
  • + *
  • Inner barrier per listener (in {@code dispatchOne}), + * covering the {@code getDomain()} routing lookup as well as + * {@code onEvents()} — both are listener code. A misconfigured consumer + * bundle whose listener throws {@link LinkageError}, + * {@link OutOfMemoryError}, or other {@link Throwable} subtypes does not + * stop other listeners. Without the accessor coverage, a throwing + * {@code getDomain()} would escape into the outer barrier and silently + * starve every remaining listener of the already-drained (hence + * unrecoverable) batch.
  • + *
+ * + *

DO NOT wrap this Observer in {@code BackgroundObserver}. + * The async wrapper drops the {@code CommitInfo.sessionId} on queue overflow + * (it replaces the latest queued entry with + * {@code new ContentChange(root, CommitInfo.EMPTY_EXTERNAL)} — + * {@code BackgroundObserver.java:283-286}). The audit drain keys exclusively + * on {@code info.getSessionId()} to look up the per-thread buffer; losing + * session id on overflow → silent audit-event loss for high-rate writers. + * Plus: the {@link AuditBuffer} is a {@code ThreadLocal} populated on the + * commit thread, so it can ONLY be drained on that same thread. Synchronous + * dispatch is mandatory. + * + *

Drain is unconditional; only dispatch is gated. The + * per-session buffer is drained for every local commit, even when the + * feature toggle is OFF at observer-fire time. The toggle (and the + * empty-buffer check) gate only the listener dispatch. This prevents a + * toggle-flicker leak: if the toggle is ON at capture, OFF when a later + * successful commit on the same session fires the observer, then ON again + * for a subsequent commit, an early return BEFORE the drain would leave the + * stale event in the buffer to be dispatched against the later commit's + * {@code commit.*} metadata (misattribution). Draining first, then gating + * dispatch, discards the staged event cleanly during the toggle-OFF window. + */ +final class AuditDrainObserver implements Observer { + + private static final Logger log = LoggerFactory.getLogger(AuditDrainObserver.class); + + private final Feature featureToggle; + private final AuditBuffer buffer; + private final WhiteboardAuditEventListenerRegistry registry; + + AuditDrainObserver(@NotNull Feature featureToggle, + @NotNull AuditBuffer buffer, + @NotNull WhiteboardAuditEventListenerRegistry registry) { + this.featureToggle = featureToggle; + this.buffer = buffer; + this.registry = registry; + } + + @Override + public void contentChanged(@NotNull NodeState root, @NotNull CommitInfo info) { + // OUTER Throwable barrier. CompositeObserver + // (oak-store-spi/.../spi/commit/CompositeObserver.java:46-53) does NOT + // isolate per-observer exceptions: a Throwable from this method + // cascades through the observer chain and would break peer observers + // such as JCR's observation dispatcher. We swallow defensively so the + // audit pipeline can never destabilise unrelated observer work. The + // per-listener barrier inside dispatchOne catches listener-induced + // failures; this outer catch protects against drain/decorator bugs. + // Do NOT narrow this catch to RuntimeException — any Throwable + // escaping here masquerades as a commit failure to the merge caller. + try { + doContentChanged(info); + } catch (Throwable t) { + log.warn("AuditDrainObserver: unexpected error during drain/dispatch (session {}); " + + "swallowing to preserve observer-chain isolation.", + info.getSessionId(), t); + } + } + + private void doContentChanged(@NotNull CommitInfo info) { + // External commits never produce local audit events (capture sites + // are local-only by construction). The bootstrap invocation at + // addObserver-time with CommitInfo.EMPTY_EXTERNAL also lands here. + if (info.isExternal()) { + return; + } + String sessionId = info.getSessionId(); + // The buffer is per-thread; this drain runs on the same thread that + // called Root.commit() (synchronous Observer contract via + // ChangeDispatcher for local commits). The sessionId returned by + // CommitInfo equals ContentSession.toString() — set in + // MutableRoot.commit() — so it matches the buffer's keying. + // + // Drain UNCONDITIONALLY (before the toggle check) so a mid-flight + // toggle flip cannot strand a captured event in the buffer to be + // misattributed to a later commit — see the toggle-flicker note in + // the class Javadoc. The early return below then discards the drained + // events when there is nothing to dispatch OR the toggle is now off. + List events = buffer.drain(sessionId); + if (events == null || events.isEmpty() || !featureToggle.isEnabled()) { + return; + } + List listeners = registry.getListeners(); + if (listeners.isEmpty()) { + return; + } + List decorated = CommitMetadataDecorator.decorate(events, info); + Map> byDomain = groupByDomain(decorated); + for (AuditEventListener listener : listeners) { + dispatchOne(listener, byDomain); + } + } + + private static @NotNull Map> groupByDomain(@NotNull List events) { + Map> byDomain = new HashMap<>(4); + for (AuditEvent event : events) { + byDomain.computeIfAbsent(event.getDomain(), k -> new ArrayList<>(events.size())).add(event); + } + return byDomain; + } + + private static void dispatchOne(@NotNull AuditEventListener listener, + @NotNull Map> byDomain) { + // The getDomain() routing lookup sits INSIDE the barrier — it is + // listener code just like onEvents(), and a throw escaping to the + // outer barrier would starve every remaining listener. + try { + List forListener = byDomain.get(listener.getDomain()); + if (forListener == null || forListener.isEmpty()) { + return; + } + // Hand each listener an immutable view so one misbehaving listener + // cannot mutate the per-domain list seen by its peers. + listener.onEvents(Collections.unmodifiableList(forListener)); + } catch (Throwable t) { + // Per-listener isolation: a misconfigured consumer bundle whose listener + // throws e.g. LinkageError must not crash the commit-dispatch path for + // unrelated work. JVM-level pathology (OutOfMemoryError) is caught here + // too but re-triggers on the next allocation and surfaces through normal + // channels. Do not narrow this catch to RuntimeException — listener + // Throwables (any kind) must not escape into the dispatch loop. The log + // must not re-invoke getDomain(): it may be exactly what threw. + log.warn("AuditEventListener {} threw {} during commit-attached dispatch; isolating from other listeners.", + listener.getClass().getName(), t.getClass().getSimpleName(), t); + } + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java new file mode 100644 index 00000000000..b7066c99278 --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.jetbrains.annotations.NotNull; +import org.osgi.service.component.annotations.Component; + +/** + * OSGi service implementation of {@link AuditEventEmitter}. Thin wrapper + * around the static {@link AuditEvents} façade so consumer bundles do not + * need to know about the façade. + *

+ * A single instance is registered per OSGi container at activation of + * the audit module; all consumers receive the same instance via + * {@code @Reference AuditEventEmitter}. + */ +@Component(service = AuditEventEmitter.class) +public class AuditEventEmitterImpl implements AuditEventEmitter { + + @Override + public void emit(@NotNull AuditEvent event) { + AuditEvents.dispatch(event); + } + + @Override + public boolean isEnabledFor(@NotNull String domain) { + return AuditEvents.isEnabledFor(domain); + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java new file mode 100644 index 00000000000..1b074027b99 --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Enforces the {@code commit.*} trust contract at both dispatch + * boundaries: {@link #decorate} stamps commit-attached payloads with + * commit metadata (sessionId, userId, timestamp) at drain time, and + * {@link #stripReservedCommitKeys} removes caller-supplied values for the + * same three keys from fire-and-forget payloads before delivery. + *

+ * Both operations return NEW {@link AuditEvent} instances that wrap the + * originals; the input events are not mutated. The wrapper's payload map + * is unmodifiable. + * + *

Security invariant

+ * Both halves enforce the same property: listeners can treat the presence + * of {@link #KEY_SESSION_ID}, {@link #KEY_USER_ID}, or {@link #KEY_TIMESTAMP} + * in a dispatched payload as Oak-attested — see the normative trust contract + * on {@link org.apache.jackrabbit.oak.spi.audit.AuditEvent#getPayload()}. + * {@link #decorate} unconditionally overwrites the three + * keys with the values from the {@link CommitInfo} captured for the + * surrounding commit; {@link #stripReservedCommitKeys} removes + * caller-supplied values for the same keys on the fire-and-forget path. + * Weakening either half — {@code putIfAbsent} / {@code computeIfAbsent} / + * conditional {@code put} in the decorator, or skipping the strip at + * dispatch — is a regression in the trust model. + * + *

Payload null-value contract

+ * The decorator trusts the no-null-keys/no-null-values contract documented + * on {@link org.apache.jackrabbit.oak.spi.audit.AuditEventListener#onEvents}. + * Buggy event implementations that violate it may leak null values to + * listeners — runtime validation is the event author's responsibility, + * not the decorator's. Adding per-entry null checks here would impose + * hot-path cost for what is an SPI-contract violation. + */ +final class CommitMetadataDecorator { + + private static final Logger log = LoggerFactory.getLogger(CommitMetadataDecorator.class); + + static final String KEY_SESSION_ID = "commit.sessionId"; + static final String KEY_USER_ID = "commit.userId"; + static final String KEY_TIMESTAMP = "commit.timestamp"; + + /** + * One-shot latch for the strip WARN (package-private so tests can + * reset it). First strip in the JVM logs WARN; subsequent strips log + * DEBUG — an emitter that persistently sends reserved keys would + * otherwise hand any bundle a WARN-flood vector. + */ + static final AtomicBoolean STRIP_WARNED = new AtomicBoolean(); + + private CommitMetadataDecorator() { + // utility class + } + + /** + * Strips caller-supplied values for the three Oak-attested keys + * ({@link #KEY_SESSION_ID}, {@link #KEY_USER_ID}, {@link #KEY_TIMESTAMP}) + * from a fire-and-forget payload. Applied by {@code BufferSink.dispatch} + * before listener delivery so the presence of those keys in a dispatched + * payload is a reliable Oak-attestation signal — the fire-and-forget + * counterpart of the unconditional overwrite in {@link #decorate}. + * Non-reserved {@code commit.*} keys and all other entries are forwarded + * verbatim; events without any reserved key are returned unchanged (no + * wrapping, no copy), preserving concrete event types for well-behaved + * emitters. + *

+ * TOCTOU. The payload is consulted exactly once and the + * filtered snapshot is taken eagerly in the wrapper constructor — + * {@link AuditEvent} is directly implementable, so deciding on one + * {@code getPayload()} result and delivering another (lazy filtering, a + * second consult) would let a hostile implementation pass the check + * clean and hand listeners a forged map. A hostile implementation that + * instead returns a clean map to THIS consult escapes wrapping, but the + * snapshot listeners read is that same clean map; presenting forged keys + * later is only possible where Oak dispatch is not mediating (direct + * {@code listener.onEvents()} invocation) — the already-accepted + * deployment-boundary bypass. + *

+ * The wrapper delegates domain/type/timestamp rather than rebuilding via + * {@link AuditEvent#of} — rebuilding would reset the capture timestamp + * to wall-clock now. + */ + static @NotNull AuditEvent stripReservedCommitKeys(@NotNull AuditEvent event) { + Map payload = event.getPayload(); + if (!payload.containsKey(KEY_SESSION_ID) + && !payload.containsKey(KEY_USER_ID) + && !payload.containsKey(KEY_TIMESTAMP)) { + return event; + } + logStrip(event, payload); + return new StrippedAuditEvent(event, payload); + } + + private static void logStrip(@NotNull AuditEvent event, @NotNull Map payload) { + boolean firstTime = STRIP_WARNED.compareAndSet(false, true); + if (!firstTime && !log.isDebugEnabled()) { + return; + } + // Key NAMES only — the forged values are attacker-controlled and + // must never reach the log. + List stripped = new ArrayList<>(3); + if (payload.containsKey(KEY_SESSION_ID)) { + stripped.add(KEY_SESSION_ID); + } + if (payload.containsKey(KEY_USER_ID)) { + stripped.add(KEY_USER_ID); + } + if (payload.containsKey(KEY_TIMESTAMP)) { + stripped.add(KEY_TIMESTAMP); + } + if (firstTime) { + log.warn("Stripped reserved commit attestation key(s) {} from fire-and-forget audit event" + + " (domain '{}', type '{}'); these keys are Oak-attested and cannot be supplied" + + " by emitters. Further occurrences are logged at DEBUG.", + stripped, event.getDomain(), event.getType()); + } else { + log.debug("Stripped reserved commit attestation key(s) {} from fire-and-forget audit event" + + " (domain '{}', type '{}').", + stripped, event.getDomain(), event.getType()); + } + } + + static @NotNull List decorate(@NotNull List events, + @NotNull CommitInfo info) { + if (events.isEmpty()) { + return Collections.emptyList(); + } + String sessionId = info.getSessionId(); + String userId = info.getUserId(); + long timestamp = info.getDate(); + List out = new ArrayList<>(events.size()); + for (AuditEvent e : events) { + out.add(new DecoratedAuditEvent(e, sessionId, userId, timestamp)); + } + return out; + } + + private static final class DecoratedAuditEvent implements AuditEvent { + + private final AuditEvent delegate; + private final Map payload; + + DecoratedAuditEvent(@NotNull AuditEvent delegate, + @NotNull String sessionId, + @NotNull String userId, + long commitTimestamp) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + Map merged = new HashMap<>(delegate.getPayload()); + merged.put(KEY_SESSION_ID, sessionId); + merged.put(KEY_USER_ID, userId); + merged.put(KEY_TIMESTAMP, commitTimestamp); + this.payload = Collections.unmodifiableMap(merged); + } + + @Override public @NotNull String getDomain() { return delegate.getDomain(); } + @Override public @NotNull String getType() { return delegate.getType(); } + @Override public long getTimestamp() { return delegate.getTimestamp(); } + @Override public @NotNull Map getPayload() { return payload; } + } + + /** + * Delegating wrapper whose payload is the eagerly-filtered snapshot of + * the single {@code getPayload()} consult taken in + * {@link #stripReservedCommitKeys} — see the TOCTOU note there. The + * delegate's payload accessor is never consulted again. + */ + private static final class StrippedAuditEvent implements AuditEvent { + + private final AuditEvent delegate; + private final Map payload; + + StrippedAuditEvent(@NotNull AuditEvent delegate, + @NotNull Map consultedPayload) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + Map filtered = new HashMap<>(consultedPayload); + filtered.remove(KEY_SESSION_ID); + filtered.remove(KEY_USER_ID); + filtered.remove(KEY_TIMESTAMP); + this.payload = Collections.unmodifiableMap(filtered); + } + + @Override public @NotNull String getDomain() { return delegate.getDomain(); } + @Override public @NotNull String getType() { return delegate.getType(); } + @Override public long getTimestamp() { return delegate.getTimestamp(); } + @Override public @NotNull Map getPayload() { return payload; } + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java new file mode 100644 index 00000000000..7f33bd220dc --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.whiteboard.AbstractServiceTracker; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Whiteboard-backed registry of {@link AuditEventListener} services. + *

+ * Every predicate ({@link #hasAnyListener()}, {@link #hasListenerFor(String)}) + * and every retrieval ({@link #getListeners()}) goes through a live + * {@code getServices()} call to the underlying {@link + * org.apache.jackrabbit.oak.spi.whiteboard.Tracker Tracker} — no cached + * domain set is held inside this registry. The {@code Tracker} SPI exposes + * no listener add/remove notification, so any cache here would either be + * stale on listener arrival/departure or require polling. The capture-site + * fast path is kept cheap by relying on the underlying Whiteboard's + * own dispatch: {@code DefaultWhiteboard.lookup(...)} returns the singleton + * {@link java.util.Collections#emptyList()} when no services of the type + * are registered, so {@link #hasAnyListener()} is constant-time and + * allocation-free in the no-listener regime. + *

+ * Listener invocation order is determined by + * {@link AuditEventListener#getRank()} (higher first). The registry + * applies a stable sort on every call to {@link #getListeners()} — + * relying on the underlying {@code Whiteboard} is not portable: + * {@code DefaultWhiteboard} does not honor OSGi {@code service.ranking}, + * only {@code OsgiWhiteboard} does. + *

+ * Accessor isolation. {@code getDomain()} and + * {@code getRank()} are listener code just like {@code onEvents()} — a + * consumer bundle with a broken classpath throws {@link LinkageError} from + * whichever method is called first. The per-listener isolation barrier + * documented on {@link AuditEventListener} therefore covers the accessors + * too: a listener whose accessor throws is skipped (logged at WARN once + * per listener identity, then DEBUG) instead of propagating into capture + * gates and dispatch loops, where the throw would either fail the + * user-facing write operation or starve healthy peer listeners. + */ +final class WhiteboardAuditEventListenerRegistry + extends AbstractServiceTracker { + + private static final Logger log = + LoggerFactory.getLogger(WhiteboardAuditEventListenerRegistry.class); + + /** + * Stable comparator descending by the rank snapshotted into + * {@link Ranked}; ties preserve {@code Whiteboard} insertion order + * because {@link List#sort(Comparator)} is stable. Sorting operates on + * the snapshot so a throwing {@code getRank()} can never surface from + * inside the comparator. + */ + private static final Comparator BY_RANK_DESC = + Comparator.comparingInt((Ranked r) -> r.rank).reversed(); + + /** + * Identity keys of listeners already WARN-logged as broken — the skip + * itself is per-call (a listener that stops throwing is picked up + * again), only the WARN is latched. Bounded by the number of distinct + * broken listener instances seen over the registry's lifetime. + */ + private final Set warnedBroken = ConcurrentHashMap.newKeySet(); + + WhiteboardAuditEventListenerRegistry() { + super(AuditEventListener.class); + } + + /** + * Returns the currently registered listeners, sorted by + * {@link AuditEventListener#getRank()} descending (stable). Listeners + * whose {@code getRank()} throws are skipped — see the accessor + * isolation note in the class Javadoc. + * + * @return non-null immutable list of registered listeners (possibly + * empty). + */ + @NotNull + List getListeners() { + List services = getServices(); + if (services.isEmpty()) { + return List.of(); + } + // Snapshot each rank under the per-listener guard BEFORE sorting. + // Every listener is vetted regardless of count — a lone broken + // listener must be skipped too, not returned through a fast path. + List ranked = new ArrayList<>(services.size()); + for (AuditEventListener listener : services) { + try { + ranked.add(new Ranked(listener, listener.getRank())); + } catch (Throwable t) { + logBrokenListener(listener, "getRank()", t); + } + } + ranked.sort(BY_RANK_DESC); + List out = new ArrayList<>(ranked.size()); + for (Ranked r : ranked) { + out.add(r.listener); + } + return List.copyOf(out); + } + + /** + * Cheap predicate: is at least one listener registered (any domain)? + * Read on the capture hot path. + * + * @return {@code true} when at least one listener is currently + * registered. + */ + boolean hasAnyListener() { + return !getServices().isEmpty(); + } + + /** + * Cheap predicate: is at least one listener registered for the + * supplied {@code domain}? Read on the capture hot path. + *

+ * Linear scan of the live listener list. Capture sites typically face + * a listener count in single digits, so iterating is competitive with + * (and simpler than) a maintained domain set. + * + * @param domain the domain to check, non-null. + * @return {@code true} when at least one listener is registered for + * the domain. + */ + boolean hasListenerFor(@NotNull String domain) { + for (AuditEventListener listener : getServices()) { + try { + if (domain.equals(listener.getDomain())) { + return true; + } + } catch (Throwable t) { + logBrokenListener(listener, "getDomain()", t); + } + } + return false; + } + + /** + * Logs a broken-accessor skip at WARN once per listener identity, then + * DEBUG — capture gates poll {@link #hasListenerFor} on every audited + * write, so an unconditional WARN would let one broken bundle flood the + * log. Keyed by instance identity, not class: a re-registered + * replacement instance warns again. + */ + private void logBrokenListener(@NotNull AuditEventListener listener, + @NotNull String accessor, + @NotNull Throwable t) { + String key = listener.getClass().getName() + "@" + + Integer.toHexString(System.identityHashCode(listener)); + if (warnedBroken.add(key)) { + log.warn("Skipping broken AuditEventListener {}: {} threw {}. The listener is" + + " skipped per call until it stops throwing; further occurrences are" + + " logged at DEBUG.", + key, accessor, t.getClass().getSimpleName(), t); + } else { + log.debug("Skipping broken AuditEventListener {}: {} threw {}.", + key, accessor, t.getClass().getSimpleName(), t); + } + } + + /** + * Listener with its rank snapshotted under the accessor guard in + * {@link #getListeners()}. + */ + private static final class Ranked { + final AuditEventListener listener; + final int rank; + + Ranked(@NotNull AuditEventListener listener, int rank) { + this.listener = listener; + this.rank = rank; + } + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java new file mode 100644 index 00000000000..71333198468 --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.user; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; +import org.jetbrains.annotations.NotNull; + +/** + * Factory helpers for user-management audit events. Package-private + * producer-side sugar over {@link AuditEvent#of(String, String, Map)}; + * keeps capture sites in {@link UserManagerImpl} readable. + *

+ * Listener bundles consume {@link AuditEvent} instances and discriminate + * via {@link AuditEvent#getDomain()} + {@link AuditEvent#getType()} — they + * compile against {@link UserAuditTypes} for the type-string vocabulary, + * not against this class. + *

+ * Single and bulk membership changes share the same type string + * ({@link UserAuditTypes#MEMBER_ADDED} / {@link UserAuditTypes#MEMBER_REMOVED}); + * a bulk change is one whose {@link UserAuditTypes#PAYLOAD_MEMBER_IDS} list + * holds more than one entry. Defensive copies of caller-supplied collections + * are centralised here so capture sites can pass mutable inputs without + * leaking them into the resulting {@link AuditEvent}. + */ +final class UserAuditEvents { + + /** + * Builds an event for a single authorizable added to a group. + * + * @param groupPath non-null path of the group being modified. + * @param memberId non-null authorizable id of the member added. + * @param memberPath non-null path of the authorizable added. + * @return non-null {@link AuditEvent} with type + * {@link UserAuditTypes#MEMBER_ADDED}. + */ + @NotNull + static AuditEvent memberAdded(@NotNull String groupPath, + @NotNull String memberId, + @NotNull String memberPath) { + return singleMember(UserAuditTypes.MEMBER_ADDED, groupPath, memberId, memberPath); + } + + /** + * Builds an event for a single authorizable removed from a group. + * + * @param groupPath non-null path of the group being modified. + * @param memberId non-null authorizable id of the member removed. + * @param memberPath non-null path of the authorizable removed. + * @return non-null {@link AuditEvent} with type + * {@link UserAuditTypes#MEMBER_REMOVED}. + */ + @NotNull + static AuditEvent memberRemoved(@NotNull String groupPath, + @NotNull String memberId, + @NotNull String memberPath) { + return singleMember(UserAuditTypes.MEMBER_REMOVED, groupPath, memberId, memberPath); + } + + @NotNull + private static AuditEvent singleMember(@NotNull String type, + @NotNull String groupPath, + @NotNull String memberId, + @NotNull String memberPath) { + // The single-member API resolves an authorizable id (not a content id), + // so isContentId is false. memberIds is present (schema-required) and + // memberPaths carries the resolved node path. + return AuditEvent.of( + SecurityAuditDomain.NAME, + type, + Map.of( + UserAuditTypes.PAYLOAD_GROUP_PATH, groupPath, + UserAuditTypes.PAYLOAD_MEMBER_IDS, List.of(memberId), + UserAuditTypes.PAYLOAD_MEMBER_PATHS, List.of(memberPath), + UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE, UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + UserAuditTypes.PAYLOAD_IS_CONTENT_ID, Boolean.FALSE)); + } + + /** + * Builds an event for multiple authorizables added to a group in a + * single API call. The {@code memberIds} and {@code failedIds} sets + * are defensively copied into {@link List#copyOf immutable lists} + * inside the event payload, so post-construction mutation of the + * source sets does not leak into the event. + * + * @param groupPath non-null path of the group being modified. + * @param memberIds non-empty set of successfully-staged member ids. + * Defensively copied. + * @param isContentId {@code true} when {@code memberIds} are content + * ids (UUIDs from {@code rep:members}); + * {@code false} when they are authorizable ids. + * @param failedIds set of ids that failed to stage. May be empty; + * defensively copied. + * @return non-null {@link AuditEvent} with type + * {@link UserAuditTypes#MEMBER_ADDED}. + * @throws IllegalArgumentException if {@code memberIds} is empty. + * Capture sites MUST pre-check {@code memberIds.isEmpty()} + * before calling this method; an empty bulk event carries no + * semantic meaning, and + * {@code UserManagerImpl.recordBulkMembershipAuditEvent} + * already enforces this gate at its capture site. + */ + @NotNull + static AuditEvent membersAddedBulk(@NotNull String groupPath, + @NotNull Set memberIds, + boolean isContentId, + @NotNull Set failedIds) { + return bulkMembers(UserAuditTypes.MEMBER_ADDED, groupPath, memberIds, isContentId, failedIds); + } + + /** + * Builds an event for multiple authorizables removed from a group in + * a single API call. + * + * @param groupPath non-null path of the group being modified. + * @param memberIds non-empty set of successfully-staged member ids. + * Defensively copied. + * @param isContentId {@code true} when {@code memberIds} are content + * ids (UUIDs from {@code rep:members}); + * {@code false} when they are authorizable ids. + * @param failedIds set of ids that failed to stage. May be empty; + * defensively copied. + * @return non-null {@link AuditEvent} with type + * {@link UserAuditTypes#MEMBER_REMOVED}. + * @throws IllegalArgumentException if {@code memberIds} is empty. + * Capture sites MUST pre-check {@code memberIds.isEmpty()} + * before calling this method (see {@link #membersAddedBulk}). + */ + @NotNull + static AuditEvent membersRemovedBulk(@NotNull String groupPath, + @NotNull Set memberIds, + boolean isContentId, + @NotNull Set failedIds) { + return bulkMembers(UserAuditTypes.MEMBER_REMOVED, groupPath, memberIds, isContentId, failedIds); + } + + @NotNull + private static AuditEvent bulkMembers(@NotNull String type, + @NotNull String groupPath, + @NotNull Set memberIds, + boolean isContentId, + @NotNull Set failedIds) { + if (memberIds.isEmpty()) { + throw new IllegalArgumentException("memberIds must not be empty"); + } + return AuditEvent.of( + SecurityAuditDomain.NAME, + type, + Map.of( + UserAuditTypes.PAYLOAD_GROUP_PATH, groupPath, + UserAuditTypes.PAYLOAD_MEMBER_IDS, List.copyOf(memberIds), + UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE, UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + UserAuditTypes.PAYLOAD_IS_CONTENT_ID, isContentId, + UserAuditTypes.PAYLOAD_FAILED_IDS, List.copyOf(failedIds))); + } + + private UserAuditEvents() { + // utility + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java index 906c77e05cd..5b71bd4c16c 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java @@ -34,8 +34,10 @@ import org.apache.jackrabbit.oak.plugins.value.jcr.PartialValueFactory; import org.apache.jackrabbit.oak.security.user.monitor.UserMonitor; import org.apache.jackrabbit.oak.security.user.query.UserQueryManager; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; import org.apache.jackrabbit.oak.spi.security.SecurityProvider; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.security.principal.EveryonePrincipal; import org.apache.jackrabbit.oak.spi.security.principal.PrincipalConfiguration; import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl; @@ -86,6 +88,13 @@ public class UserManagerImpl implements UserManager { private UserQueryManager queryManager; private ReadOnlyNodeTypeManager ntMgr; + + // Guards against repeated WARN logging when audit-event path resolution + // fails during a successful group update (see recordSingleMembershipAuditEvent + // / recordBulkMembershipAuditEvent). The first occurrence on this + // UserManager is logged at WARN (audit-completeness signal); subsequent + // ones drop to DEBUG to avoid log flooding. + private boolean auditPathResolutionWarned; private final DynamicMembershipService dynamicMembership; private DynamicMembershipProvider dynamicMembershipProvider; @@ -372,6 +381,9 @@ void onImpersonation(@NotNull User user, @NotNull Principal principal, boolean g * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable member) throws RepositoryException { + if (AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)) { + recordSingleMembershipAuditEvent(group, isRemove, member); + } for (GroupAction action : filterGroupActions()) { if (isRemove) { action.onMemberRemoved(group, member, root, namePathMapper); @@ -394,6 +406,9 @@ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, boolean isContentId, @NotNull Set memberIds, @NotNull Set failedIds) throws RepositoryException { + if (AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)) { + recordBulkMembershipAuditEvent(group, isRemove, isContentId, memberIds, failedIds); + } for (GroupAction action : filterGroupActions()) { if (isRemove) { action.onMembersRemoved(group, memberIds, failedIds, root, namePathMapper); @@ -407,6 +422,68 @@ void onGroupUpdate(@NotNull Group group, boolean isRemove, boolean isContentId, } } + /** + * Records a single-member audit event for the given group update. + * Fires only on success — the upstream {@code MembershipWriter} + * does not invoke {@code onGroupUpdate} for the failure path. + */ + private void recordSingleMembershipAuditEvent(@NotNull Group group, boolean isRemove, @NotNull Authorizable member) { + try { + String groupPath = group.getPath(); + String memberId = member.getID(); + String memberPath = member.getPath(); + AuditEvents.record(root, isRemove + ? UserAuditEvents.memberRemoved(groupPath, memberId, memberPath) + : UserAuditEvents.memberAdded(groupPath, memberId, memberPath)); + } catch (RepositoryException e) { + // Path resolution failed — drop the event rather than fail the + // surrounding group update. It is an audit-completeness signal: + // a successful membership change produced no audit event. + warnAuditPathResolutionFailed("failed to resolve path for group membership update", e); + } + } + + /** + * Records a bulk-membership audit event when {@code memberIds} is + * non-empty. {@code memberIds} is the successful subset (failed + * entries already filtered by {@code MembershipWriter}); when every + * entry failed, {@code memberIds} is empty and no event is emitted. + * {@code failedIds} is carried through to the listener for audit + * completeness — listeners can distinguish "happened" vs "rejected". + */ + private void recordBulkMembershipAuditEvent(@NotNull Group group, boolean isRemove, boolean isContentId, @NotNull Set memberIds, @NotNull Set failedIds) { + if (memberIds.isEmpty()) { + return; + } + try { + String groupPath = group.getPath(); + AuditEvents.record(root, isRemove + ? UserAuditEvents.membersRemovedBulk(groupPath, memberIds, isContentId, failedIds) + : UserAuditEvents.membersAddedBulk(groupPath, memberIds, isContentId, failedIds)); + } catch (RepositoryException e) { + warnAuditPathResolutionFailed("failed to resolve group path for bulk membership update", e); + } + } + + /** + * Logs an audit-completeness WARN the first time path resolution fails + * while capturing a membership audit event during an otherwise + * successful group update; subsequent occurrences on this + * {@code UserManagerImpl} drop to DEBUG to avoid log flooding. The + * event is dropped either way — audit capture never fails the + * surrounding group update. + */ + private void warnAuditPathResolutionFailed(@NotNull String detail, @NotNull RepositoryException e) { + if (auditPathResolutionWarned) { + log.debug("Skipping audit event: {} (further occurrences suppressed)", detail, e); + } else { + auditPathResolutionWarned = true; + log.warn("Skipping audit event: {}. A successful group update produced no audit event " + + "because path resolution failed; audit completeness is affected. Further " + + "occurrences on this UserManager are logged at DEBUG.", detail, e); + } + } + //-------------------------------------------------------------------------- @Nullable public Authorizable getAuthorizable(@Nullable Tree tree) throws RepositoryException { diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java new file mode 100644 index 00000000000..840353c1c2d --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.event.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Direct unit tests for {@link AuditBuffer}, the per-thread, per-session + * staging area for commit-attached audit events. Exercises the public + * package-private surface ({@code record} / {@code peek} / {@code drain} / + * {@code clearAll}) plus the {@code AuditBufferLifecycle.Listener} callbacks, + * the defensive-copy contract of {@code peek}, the soft per-session cap, and + * the {@link ThreadLocal} thread-confinement (including the drain-from-wrong- + * thread guard). + */ +public class AuditBufferTest { + + private static final String SESSION = "session-1"; + private static final String OTHER_SESSION = "session-2"; + private static final String DOMAIN = "test.domain"; + + private AuditBuffer buffer; + + @Before + public void setUp() { + buffer = new AuditBuffer(); + } + + @After + public void tearDown() { + // Drop any ThreadLocal residue on the test thread. + buffer.clearAll(); + } + + private static AuditEvent event(String type) { + return AuditEvent.of(DOMAIN, type); + } + + //--------------------------------------------------< record / peek / drain >--- + + @Test + public void recordThenPeekReturnsStagedEventsInOrder() { + buffer.record(SESSION, event("a")); + buffer.record(SESSION, event("b")); + + List staged = buffer.peek(SESSION); + assertEquals(2, staged.size()); + assertEquals("a", staged.get(0).getType()); + assertEquals("b", staged.get(1).getType()); + } + + @Test + public void peekReturnsNullWhenNothingStaged() { + assertNull(buffer.peek(SESSION)); + } + + @Test + public void recordIsPerSession() { + buffer.record(SESSION, event("a")); + buffer.record(OTHER_SESSION, event("x")); + buffer.record(OTHER_SESSION, event("y")); + + assertEquals(1, buffer.peek(SESSION).size()); + assertEquals(2, buffer.peek(OTHER_SESSION).size()); + } + + /** + * {@code peek} returns a defensive copy: mutating the returned list must + * not affect the buffer, and a fresh {@code peek} still observes the + * original staged events. + */ + @Test + public void peekReturnsDefensiveCopy() { + buffer.record(SESSION, event("a")); + List first = buffer.peek(SESSION); + + // The returned list is immutable (List.copyOf) — structural mutation throws. + assertThrows(UnsupportedOperationException.class, () -> first.add(event("injected"))); + + // And it is decoupled from the backing list: recording more does not + // grow the previously-returned snapshot. + buffer.record(SESSION, event("b")); + assertEquals("earlier peek snapshot must be decoupled", 1, first.size()); + assertEquals("buffer itself reflects the new event", 2, buffer.peek(SESSION).size()); + } + + @Test + public void drainReturnsStagedEventsAndEmptiesSession() { + AuditEvent a = event("a"); + AuditEvent b = event("b"); + buffer.record(SESSION, a); + buffer.record(SESSION, b); + + List drained = buffer.drain(SESSION); + assertEquals(2, drained.size()); + assertSame(a, drained.get(0)); + assertSame(b, drained.get(1)); + assertNull("session must be empty after drain", buffer.peek(SESSION)); + } + + @Test + public void drainReturnsNullWhenNothingStaged() { + assertNull(buffer.drain(SESSION)); + } + + @Test + public void drainIsScopedToTheRequestedSession() { + buffer.record(SESSION, event("a")); + buffer.record(OTHER_SESSION, event("x")); + + buffer.drain(SESSION); + assertNull(buffer.peek(SESSION)); + assertEquals("other session must be untouched", 1, buffer.peek(OTHER_SESSION).size()); + } + + //--------------------------------------------------< lifecycle callbacks >--- + + @Test + public void onRefreshDrainsSession() { + buffer.record(SESSION, event("a")); + buffer.onRefresh(SESSION); + assertNull(buffer.peek(SESSION)); + } + + @Test + public void onCommitFailedDrainsSession() { + buffer.record(SESSION, event("a")); + buffer.onCommitFailed(SESSION); + assertNull(buffer.peek(SESSION)); + } + + @Test + public void clearAllRemovesCurrentThreadEvents() { + buffer.record(SESSION, event("a")); + buffer.record(OTHER_SESSION, event("x")); + buffer.clearAll(); + assertNull(buffer.peek(SESSION)); + assertNull(buffer.peek(OTHER_SESSION)); + } + + //--------------------------------------------------< soft per-session cap >--- + + /** + * Once a session reaches {@link AuditBuffer#MAX_EVENTS_PER_SESSION} + * staged events, further events are dropped and a single WARN is logged + * for the session — not one WARN per dropped event. + */ + @Test + public void overflowDropsBeyondCapAndWarnsOncePerSession() { + LogCustomizer log = LogCustomizer.forLogger(AuditBuffer.class) + .enable(Level.WARN).create(); + log.starting(); + try { + for (int i = 0; i < AuditBuffer.MAX_EVENTS_PER_SESSION + 5; i++) { + buffer.record(SESSION, event("e" + i)); + } + assertEquals("buffer must be capped at the maximum", + AuditBuffer.MAX_EVENTS_PER_SESSION, buffer.peek(SESSION).size()); + assertEquals("exactly one WARN must be logged for the overflowing session", + 1, log.getLogs().size()); + assertTrue("WARN must name the session; was: " + log.getLogs().get(0), + log.getLogs().get(0).contains(SESSION)); + } finally { + log.finished(); + } + } + + /** + * The overflow warning re-arms once the session slot is cleared: a second + * overflow episode (after a drain) logs its own WARN. Pins that the + * once-per-session flag lives on the per-session slot, which is recreated + * on the next {@code record} after a drain. + */ + @Test + public void overflowWarningReArmsAfterDrain() { + LogCustomizer log = LogCustomizer.forLogger(AuditBuffer.class) + .enable(Level.WARN).create(); + log.starting(); + try { + for (int i = 0; i <= AuditBuffer.MAX_EVENTS_PER_SESSION; i++) { + buffer.record(SESSION, event("first" + i)); + } + buffer.drain(SESSION); + for (int i = 0; i <= AuditBuffer.MAX_EVENTS_PER_SESSION; i++) { + buffer.record(SESSION, event("second" + i)); + } + assertEquals("a fresh overflow episode after drain must WARN again", + 2, log.getLogs().size()); + } finally { + log.finished(); + } + } + + //--------------------------------------------------< thread confinement >--- + + /** + * The staging area is a {@link ThreadLocal}: another thread neither + * observes ({@code peek}) nor drains this thread's staged events, and a + * drain issued from the wrong thread leaves this thread's events intact + * (the drain-from-wrong-thread guard). + */ + @Test + public void stagedEventsAreThreadConfined() throws InterruptedException { + buffer.record(SESSION, event("a")); + + AtomicReference> otherPeek = new AtomicReference<>(); + AtomicReference> otherDrain = new AtomicReference<>(); + Thread other = new Thread(() -> { + otherPeek.set(buffer.peek(SESSION)); + otherDrain.set(buffer.drain(SESSION)); + }); + other.start(); + other.join(); + + assertNull("another thread must not observe this thread's events", otherPeek.get()); + assertNull("drain from another thread must return null", otherDrain.get()); + // The wrong-thread drain must not have touched this thread's slot. + assertEquals("this thread's events must survive a wrong-thread drain", + 1, buffer.peek(SESSION).size()); + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImplTest.java new file mode 100644 index 00000000000..c23e5449729 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImplTest.java @@ -0,0 +1,436 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.List; +import java.util.Map; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.commit.Observer; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.apache.sling.testing.mock.osgi.MockOsgi; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Behavioural coverage for {@link AuditConfigurationImpl} — both the + * {@link AuditConfigurationImpl#isActive() isActive()} reporter and the + * lifecycle / drain-observer accessor surface. + *

+ * Two test layers: + *

    + *
  • {@code isActive()} state machine — 5 states + * reachable from the wiring lifecycle: + *
      + *
    1. Not initialised — default NOOP sink → {@code false}.
    2. + *
    3. Initialised, toggle OFF — short-circuits on toggle.
    4. + *
    5. Initialised, toggle ON, no listener — short-circuits on + * {@code registry.hasAnyListener()}.
    6. + *
    7. Initialised, toggle ON, listener registered → {@code true}.
    8. + *
    9. After {@code dispose()} — sink reset to NOOP → {@code false}.
    10. + *
  • + *
  • Lifecycle + drain-observer accessor — 7 cases (a)–(g): + *
      + *
    1. (a) {@code @Activate} registers the {@link Observer} service.
    2. + *
    3. (b) {@code @Deactivate} unregisters it.
    4. + *
    5. (c) {@code getDrainObserver()} returns a non-null Observer + * post-{@code initialize}.
    6. + *
    7. (d) {@code getDrainObserver()} returns the SAME instance on + * repeat calls — singleton invariant. Guards against accidental + * factory revert: the {@code AuditBuffer} {@code ThreadLocal} is + * buffer-instance-scoped and the {@code drain(sessionId)} + * contract is destructive, so two observers sharing the same + * buffer would silently turn double-attach into double-dispatch + * under any future non-destructive drain refactor.
    8. + *
    9. (e) {@code getDrainObserver()} throws + * {@link IllegalStateException} pre-{@code initialize}.
    10. + *
    11. (f) {@code getDrainObserver()} throws ISE post-{@code dispose}.
    12. + *
    13. (g) {@code dispose()} throws ISE when {@code observerRegistration} + * is still non-null — defense-in-depth precondition guard. + * The @{@code Deactivate} path zeros the field + * before invoking {@code dispose()}; misuse paths (a test + * that calls {@code dispose()} directly without invoking + * {@code @Deactivate}) get a loud failure with an actionable + * message rather than a silent leak.
    14. + *
  • + *
  • Tear-down ordering — Mockito {@link InOrder} + * verifies that {@code @Deactivate} runs + * {@code observerRegistration.unregister() → featureToggle.close() + * → registry.stop() → buffer.clearAll()} in that exact order. The + * "detach first, internals second" policy mirrors + * {@code ChangeProcessor.java:289-295}; the precondition guard in + * {@code dispose()} catches the misuse case separately (case (g)).
  • + *
+ * MockOsgi (org.apache.sling.testing.osgi-mock.junit4) drives the + * {@code @Activate}/{@code @Deactivate} flow for cases (a)/(b). Unit-level + * cases (c)–(g) call {@code initialize}/{@code dispose} directly. The + * InOrder test injects spies via the package-private fields exposed for + * test access (production callers MUST NOT touch those fields). + */ +public class AuditConfigurationImplTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + private Whiteboard whiteboard; + private AuditConfigurationImpl config; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + config = new AuditConfigurationImpl(); + } + + @After + public void tearDown() { + // Always dispose to reset the static AuditEvents.sink to NOOP — keeps + // tests isolated from each other even though they share the static + // façade. Safe to call even if initialize() was never invoked + // (each step in dispose() guards against null state). + // + // Special-case the (g) misuse test: dispose() throws if + // observerRegistration is still non-null. The test's local + // AuditConfigurationImpl instance is a different object — the + // @Before-created config is untouched and disposes cleanly. + config.dispose(); + } + + @Test + public void isActiveReturnsFalseWhenNotInitialized() { + // No initialize() call — the default NOOP sink reports isEnabled() == false. + assertFalse("uninitialised pipeline must report inactive", config.isActive()); + } + + @Test + public void isActiveReturnsFalseWhenToggleOff() { + config.initialize(whiteboard); + // Toggle defaults to disabled (FT_OAK-12331 is OFF by default per AGENTS.md). + // BufferSink.isEnabled() short-circuits on toggle.isEnabled(). + assertFalse("toggle OFF must report inactive", config.isActive()); + } + + @Test + public void isActiveReturnsFalseWhenToggleOnButNoListener() { + config.initialize(whiteboard); + setToggle(true); + // Toggle ON but no AuditEventListener registered yet — + // BufferSink.isEnabled() short-circuits on registry.hasAnyListener(). + assertFalse("toggle ON without listener must report inactive", config.isActive()); + } + + @Test + public void isActiveReturnsTrueWhenToggleOnAndListenerRegistered() { + config.initialize(whiteboard); + setToggle(true); + registerTestListener(); + // Both AND clauses satisfied — pipeline is active. + assertTrue("toggle ON + listener registered must report active", config.isActive()); + } + + @Test + public void isActiveReturnsFalseAfterDispose() { + // First bring the pipeline up so it's known-active... + config.initialize(whiteboard); + setToggle(true); + registerTestListener(); + assertTrue("precondition: pipeline must be active before dispose", config.isActive()); + + // ...then dispose, which resets the static sink to NOOP. + config.dispose(); + assertFalse("disposed pipeline must report inactive", config.isActive()); + } + + //----------------------------------< lifecycle + drain-observer accessor >--- + + /** + * Case (a) — {@code @Activate} registers an {@link Observer} service + * via {@code BundleContext.registerService(Observer.class, ...)}. + * After activation the {@code Observer} service is discoverable through + * the {@code BundleContext}. {@code ObserverTracker} (in production, + * instantiated per-NodeStoreService) is what then subscribes it to the + * root NodeStore; we don't run {@code ObserverTracker} here — verifying + * the service registration suffices for this case. + */ + @Test + public void activateRegistersObserverService() { + AuditConfigurationImpl audit = osgiContext.registerInjectActivateService( + new AuditConfigurationImpl()); + try { + Observer registered = osgiContext.getService(Observer.class); + assertNotNull("@Activate must register an Observer service", registered); + // The service IS the singleton drain observer. + assertSame("registered Observer must be the singleton drain observer", + audit.getDrainObserver(), registered); + } finally { + MockOsgi.deactivate(audit, osgiContext.bundleContext()); + } + } + + /** + * Case (b) — {@code @Deactivate} unregisters the {@link Observer} + * service. After deactivation the service is no longer discoverable + * through the {@code BundleContext}. + */ + @Test + public void deactivateUnregistersObserverService() { + AuditConfigurationImpl audit = osgiContext.registerInjectActivateService( + new AuditConfigurationImpl()); + assertNotNull("precondition: Observer service must be registered", + osgiContext.getService(Observer.class)); + + MockOsgi.deactivate(audit, osgiContext.bundleContext()); + + assertNull("@Deactivate must unregister the Observer service", + osgiContext.getService(Observer.class)); + } + + /** + * Case (c) — {@link AuditConfigurationImpl#getDrainObserver()} returns + * a non-null {@link Observer} after {@code initialize(...)} ran. + */ + @Test + public void getDrainObserverReturnsObserverAfterInitialize() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + try { + audit.initialize(whiteboard); + Observer observer = audit.getDrainObserver(); + assertNotNull("getDrainObserver() must return non-null post-initialize", + observer); + } finally { + audit.dispose(); + } + } + + /** + * Case (d) — {@link AuditConfigurationImpl#getDrainObserver()} returns + * the SAME instance on repeat calls (singleton invariant). Regression + * guard against accidental factory revert: the + * {@link AuditBuffer#drain(String)} contract is destructive, so two + * Observer instances sharing the same buffer would silently turn + * double-attach into double-dispatch under any future non-destructive + * drain refactor. + */ + @Test + public void getDrainObserverReturnsSameInstanceOnRepeatCalls() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + try { + audit.initialize(whiteboard); + Observer first = audit.getDrainObserver(); + Observer second = audit.getDrainObserver(); + Observer third = audit.getDrainObserver(); + assertSame("getDrainObserver() must return the same singleton on repeat calls", + first, second); + assertSame("getDrainObserver() must be stable across multiple calls", + first, third); + } finally { + audit.dispose(); + } + } + + /** + * Case (e) — {@link AuditConfigurationImpl#getDrainObserver()} throws + * {@link IllegalStateException} when called before + * {@link AuditConfigurationImpl#initialize(Whiteboard)}. + */ + @Test + public void getDrainObserverThrowsBeforeInitialize() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + try { + audit.getDrainObserver(); + fail("getDrainObserver() must throw IllegalStateException pre-initialize"); + } catch (IllegalStateException expected) { + // Pinned: message must include actionable hint about initialize(). + assertTrue("ISE message must mention initialize() so the misuse is actionable; was: " + + expected.getMessage(), + expected.getMessage().contains("initialize")); + } + } + + /** + * Case (f) — {@link AuditConfigurationImpl#getDrainObserver()} throws + * {@link IllegalStateException} when called after + * {@link AuditConfigurationImpl#dispose()}. The singleton field is + * zeroed by {@code dispose()}'s step 5, so the same null-check that + * pins case (e) covers this state too. + */ + @Test + public void getDrainObserverThrowsAfterDispose() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + audit.initialize(whiteboard); + // Confirm precondition — pre-dispose call must succeed. + assertNotNull(audit.getDrainObserver()); + audit.dispose(); + try { + audit.getDrainObserver(); + fail("getDrainObserver() must throw IllegalStateException post-dispose"); + } catch (IllegalStateException expected) { + // ISE shape identical to the pre-init case — same field-null check, + // same message. That symmetry is the contract. + assertTrue("ISE message must mention initialize() (same shape as pre-init); was: " + + expected.getMessage(), + expected.getMessage().contains("initialize")); + } + } + + /** + * Case (g) — {@link AuditConfigurationImpl#dispose()} throws + * {@link IllegalStateException} when called with + * {@code observerRegistration} still non-null. Defense-in-depth + * precondition guard: + *
    + *
  • OSGi {@code @Deactivate} unregisters and zeros + * {@code observerRegistration} BEFORE invoking {@code dispose()} — + * precondition trivially satisfied.
  • + *
  • Embedded callers never set {@code observerRegistration} + * (no {@code @Activate} path) — precondition trivially satisfied.
  • + *
  • Misuse case (e.g. test calls {@code dispose()} directly after + * {@code @Activate} without going through {@code @Deactivate}): + * caught here, ISE with actionable message.
  • + *
+ */ + @Test + public void disposeThrowsIfObserverRegistrationStillSet() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + audit.initialize(whiteboard); + // Simulate the OSGi misuse path: observerRegistration was set by + // @Activate but @Deactivate's unregister step never ran. The field + // is package-private specifically so this test can set it directly + // without invoking the full @Activate / OsgiContext flow. + audit.observerRegistration = Mockito.mock(ServiceRegistration.class); + try { + audit.dispose(); + fail("dispose() must throw IllegalStateException when observerRegistration is still set"); + } catch (IllegalStateException expected) { + assertTrue("ISE message must mention 'unregister' so the misuse is actionable; was: " + + expected.getMessage(), + expected.getMessage().contains("unregister")); + } finally { + // Cleanup — clear the registration field so the next dispose() (in + // any subsequent state) passes the precondition. Don't dispose + // here; the misuse-case path already left fields half-initialized + // and another dispose call would compound the test's leak. Letting + // the AuditConfigurationImpl instance go out of scope is enough — + // the static AuditEvents/AuditBufferLifecycle sinks need explicit + // cleanup though. + audit.observerRegistration = null; + audit.dispose(); + } + } + + //--------------------------------------< InOrder dispose sequence pin >--- + + /** + * Pins the "detach first, internals second" sequence in + * {@code @Deactivate}: {@code observerRegistration.unregister() → + * featureToggle.close() → registry.stop() → buffer.clearAll()}. + * Verifies the exact call order via Mockito {@link InOrder}. + *

+ * Method-level analogue of {@code ChangeProcessor.java:289-295}'s + * teardown precedent ({@code filteringObserver.close()} then + * {@code executor.stop()}). + */ + @Test + public void deactivateRunsTearDownStepsInOrder() { + AuditConfigurationImpl audit = new AuditConfigurationImpl(); + audit.initialize(whiteboard); + + // Replace internal collaborators with spies/mocks so InOrder can + // verify the exact sequence of calls. The package-private fields + // make this clean — no reflection needed. + Feature toggleSpy = Mockito.spy(audit.featureToggle); + AuditBuffer bufferSpy = Mockito.spy(audit.buffer); + WhiteboardAuditEventListenerRegistry registrySpy = Mockito.spy(audit.registry); + ServiceRegistration regMock = Mockito.mock(ServiceRegistration.class); + + audit.featureToggle = toggleSpy; + audit.buffer = bufferSpy; + audit.registry = registrySpy; + audit.observerRegistration = regMock; + + InOrder inOrder = Mockito.inOrder(regMock, toggleSpy, registrySpy, bufferSpy); + audit.deactivate(); + + // Pinned sequence: detach the Observer service FIRST so + // ObserverTracker closes its subscription on the root NodeStore + // before we tear down the internals it points at. + inOrder.verify(regMock).unregister(); + inOrder.verify(toggleSpy).close(); + inOrder.verify(registrySpy).stop(); + inOrder.verify(bufferSpy).clearAll(); + + // No further interactions on the ServiceRegistration mock — we don't + // expect deactivate to touch it again. + Mockito.verifyNoMoreInteractions(regMock); + } + + //----------------------------------------------------------< fixtures >--- + + /** + * Flips the FT_OAK-12331 feature toggle by locating the {@link FeatureToggle} + * service that {@link AuditConfigurationImpl#initialize(Whiteboard) + * initialize} registered on the whiteboard. + */ + private void setToggle(boolean enabled) { + Tracker tracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : tracker.getServices()) { + if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + tracker.stop(); + } + } + + private void registerTestListener() { + AuditEventListener listener = new AuditEventListener() { + @NotNull + @Override + public String getDomain() { + return "test.isActive.coverage"; + } + + @Override + public void onEvents(@NotNull List events) { + // not exercised — isActive() only needs the listener to be + // registered, not invoked. + } + }; + whiteboard.register(AuditEventListener.class, listener, Map.of()); + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java new file mode 100644 index 00000000000..00ace1102bd --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java @@ -0,0 +1,660 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState; +import org.mockito.Mockito; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.event.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link AuditDrainObserver}, the {@code Observer} that + * drains the per-thread {@link AuditBuffer} on commit success and dispatches + * the captured events to registered {@link AuditEventListener}s. + *

+ * The tests construct {@code AuditDrainObserver} directly with real + * {@link Feature}, {@link AuditBuffer}, and + * {@link WhiteboardAuditEventListenerRegistry} collaborators — no + * {@code Oak} builder, no {@code Observable.addObserver(...)} chain. The + * observer's contract is pure: given a {@link NodeState} and a + * {@link CommitInfo}, drain the buffer and dispatch. Direct invocation of + * {@code contentChanged(root, info)} exercises every branch reachable from + * the production wiring while keeping the test fixture minimal. + *

+ * The OUTER {@code catch (Throwable)} barrier in + * {@link AuditDrainObserver#contentChanged} is unreachable by construction + * in production OSGi — the pipeline's @{@code Activate} sequence + * guarantees {@code featureToggle}, {@code buffer}, and {@code registry} + * are all non-null, and {@code BufferSink} ensures only well-formed + * {@link AuditEvent} instances enter the buffer. The barrier exists as + * defense in depth (a misbehaving event from a buggy producer must not + * masquerade as a commit failure to the merge thread). To exercise + * that barrier in a test, {@link #poisonedEventGetDomainThrowsCaughtByOuterBarrier} + * stages an event whose {@code getDomain()} throws. If this WARN ever + * fires in CI on a non-test path, treat it as a bug. + */ +public class AuditDrainObserverTest { + + private static final String SESSION_ID = "test-session-1"; + private static final String USER_ID = "alice"; + private static final NodeState ROOT = EmptyNodeState.EMPTY_NODE; + private static final String DOMAIN_A = "test.domain.a"; + private static final String DOMAIN_B = "test.domain.b"; + + private DefaultWhiteboard whiteboard; + private Feature featureToggle; + private AuditBuffer buffer; + private WhiteboardAuditEventListenerRegistry registry; + private AuditDrainObserver observer; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + featureToggle = Feature.newFeature(AuditConfigurationImpl.FEATURE_TOGGLE_NAME, whiteboard); + buffer = new AuditBuffer(); + registry = new WhiteboardAuditEventListenerRegistry(); + registry.start(whiteboard); + observer = new AuditDrainObserver(featureToggle, buffer, registry); + } + + @After + public void tearDown() { + if (featureToggle != null) { + featureToggle.close(); + } + if (registry != null) { + registry.stop(); + } + if (buffer != null) { + buffer.clearAll(); + } + } + + //----------------------------------------------------< short-circuits >--- + + /** + * {@code isExternal()} short-circuit: external commits (cluster sync, + * the synthetic {@code addObserver}-time bootstrap with + * {@link CommitInfo#EMPTY_EXTERNAL}, segment external head movement) + * never carry locally-captured events. The observer returns immediately; + * the buffer is NOT drained — events stay for a future local commit. + */ + @Test + public void externalCommitShortCircuitsBeforeDrain() { + setToggle(true); + CapturingListener listener = registerCapturingListener(DOMAIN_A); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + + observer.contentChanged(ROOT, externalCommit()); + + assertTrue("external commit must not invoke listeners", + listener.received.isEmpty()); + assertNotNull("buffer must retain events through an external commit", + buffer.peek(SESSION_ID)); + } + + /** + * Toggle-off behavior (CORRECTED — intentional, explicitly-requested + * change): the observer now drains the buffer UNCONDITIONALLY and gates + * only the dispatch on the toggle. With the toggle disabled it drains + * (so the staged event is discarded) but invokes no listener. + *

+ * This is the fix for the toggle-flicker leak (see + * {@link #toggleFlipMidFlightDoesNotLeakStaleEvent} and the class + * Javadoc). Previously the observer returned BEFORE the drain, leaving + * the event staged to be misattributed to a later commit. The buffer is + * therefore now empty (drained), not retained. + */ + @Test + public void toggleDisabledDrainsButDoesNotDispatch() { + // Toggle defaults to disabled — explicit setToggle(false) for clarity. + setToggle(false); + CapturingListener listener = registerCapturingListener(DOMAIN_A); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + + observer.contentChanged(ROOT, localCommit()); + + assertTrue("toggle-off must not invoke listeners", + listener.received.isEmpty()); + assertNull("toggle-off must STILL drain the buffer (no toggle-flicker leak)", + buffer.peek(SESSION_ID)); + } + + /** + * Empty buffer short-circuit: when {@code buffer.drain(sessionId)} + * returns {@code null} (no events for this session on this thread) + * the observer returns without invoking listeners. + */ + @Test + public void emptyBufferIsNoOp() { + setToggle(true); + CapturingListener listener = registerCapturingListener(DOMAIN_A); + // No buffer.record(...) — drain returns null. + + observer.contentChanged(ROOT, localCommit()); + + assertTrue("empty buffer must not invoke listeners", + listener.received.isEmpty()); + } + + /** + * No-listeners short-circuit AFTER drain: when no + * {@link AuditEventListener} is registered, the observer drains the + * buffer (so the events are no longer staged) but does not invoke + * any listener. Pins the {@code listeners.isEmpty()} branch. + */ + @Test + public void noListenersShortCircuitsAfterDrain() { + setToggle(true); + // No listener registered. + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + + observer.contentChanged(ROOT, localCommit()); + + // The drain still happens — events are removed from the buffer to + // prevent leaking into a subsequent commit. Otherwise a buffer-full + // session could re-dispatch the same events the next time a listener + // got registered. + assertNull("drain must run even when no listeners are registered", + buffer.peek(SESSION_ID)); + } + + //----------------------------------------------------------< grouping >--- + + /** + * {@code groupByDomain} correctness: events on multiple domains are + * partitioned per listener-domain. Each listener receives ONLY events + * for its own domain, in capture order. + */ + @Test + public void groupByDomainSendsEachListenerOnlyItsDomain() { + setToggle(true); + CapturingListener listenerA = registerCapturingListener(DOMAIN_A); + CapturingListener listenerB = registerCapturingListener(DOMAIN_B); + + // Interleave A and B events to verify capture-order preservation. + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "a-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_B, "b-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "a-2")); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals("listener-A must receive 2 events in capture order", + 2, listenerA.received.size()); + assertEquals("a-1", listenerA.received.get(0).getType()); + assertEquals("a-2", listenerA.received.get(1).getType()); + + assertEquals("listener-B must receive 1 event", + 1, listenerB.received.size()); + assertEquals("b-1", listenerB.received.get(0).getType()); + } + + /** + * Listener invocation order is determined by + * {@link AuditEventListener#getRank()} (higher first). With two + * listeners on the same domain, the higher-rank listener is dispatched + * before the lower-rank one — pins the {@code BY_RANK_DESC} stable + * sort in {@link WhiteboardAuditEventListenerRegistry#getListeners}. + */ + @Test + public void multipleListenersPerDomainDispatchedInRankOrder() { + setToggle(true); + List timeline = new ArrayList<>(); + AuditEventListener high = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public int getRank() { return 100; } + @Override public void onEvents(@NotNull List events) { + timeline.add("high"); + } + }; + AuditEventListener low = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public int getRank() { return 1; } + @Override public void onEvents(@NotNull List events) { + timeline.add("low"); + } + }; + // Register in REVERSE order on purpose — to verify the sort, not insertion order. + whiteboard.register(AuditEventListener.class, low, Map.of()); + whiteboard.register(AuditEventListener.class, high, Map.of()); + + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + observer.contentChanged(ROOT, localCommit()); + + assertEquals("both listeners must receive", List.of("high", "low"), timeline); + } + + //------------------------------< per-listener (inner) Throwable isolation >--- + + /** + * Per-listener {@code dispatchOne} {@link RuntimeException} isolation: + * a listener whose {@code onEvents} throws does not stop other + * listeners on the same domain from receiving the event. + */ + @Test + public void listenerRuntimeExceptionDoesNotPreventOtherListeners() { + verifyListenerErrorIsolated(new RuntimeException("synthetic")); + } + + /** + * Per-listener {@link LinkageError} isolation. A misconfigured consumer + * bundle that emits a {@code LinkageError} from its listener must not + * cascade. + */ + @Test + public void listenerLinkageErrorDoesNotPreventOtherListeners() { + verifyListenerErrorIsolated(new LinkageError("synthetic")); + } + + /** + * Per-listener {@link NoClassDefFoundError} isolation. A consumer-bundle + * classpath misconfiguration must not cascade either. + */ + @Test + public void listenerNoClassDefFoundErrorDoesNotPreventOtherListeners() { + verifyListenerErrorIsolated(new NoClassDefFoundError("synthetic")); + } + + /** + * Shared shape for the per-listener isolation tests. Registers one + * throwing listener (higher rank → dispatched first) and one capturing + * listener on the same domain; asserts the capturing listener still + * receives the event despite the throwing listener's failure. + */ + private void verifyListenerErrorIsolated(@NotNull Throwable thrown) { + setToggle(true); + AuditEventListener throwingFirst = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public int getRank() { return 100; } // dispatched first + @Override public void onEvents(@NotNull List events) { + rethrow(thrown); + } + }; + whiteboard.register(AuditEventListener.class, throwingFirst, Map.of()); + CapturingListener okSecond = registerCapturingListener(DOMAIN_A); + + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + observer.contentChanged(ROOT, localCommit()); + + assertEquals("second listener must receive despite first listener throwing " + + thrown.getClass().getSimpleName(), + 1, okSecond.received.size()); + } + + /** + * Per-listener isolation must cover the {@code getDomain()} ACCESSOR, + * not just {@code onEvents()}: routing consults each listener's domain + * to pick its per-domain event slice, and a listener whose + * {@code getDomain()} throws at that point must be skipped — not allowed + * to escape into the outer barrier, which would silently starve every + * remaining listener of an already-drained (hence unrecoverable) batch. + */ + @Test + public void listenerGetDomainThrowableDoesNotStarvePeerListeners() { + setToggle(true); + AtomicBoolean brokenInvoked = new AtomicBoolean(); + AuditEventListener brokenDomain = new AuditEventListener() { + @Override public @NotNull String getDomain() { + throw new LinkageError("synthetic-getDomain"); + } + @Override public int getRank() { return 100; } // routed first + @Override public void onEvents(@NotNull List events) { + brokenInvoked.set(true); + } + }; + whiteboard.register(AuditEventListener.class, brokenDomain, Map.of()); + CapturingListener okSecond = registerCapturingListener(DOMAIN_A); + + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + observer.contentChanged(ROOT, localCommit()); + + assertEquals("healthy listener must receive despite peer's broken getDomain()", + 1, okSecond.received.size()); + assertFalse("a listener with a broken getDomain() must never receive events", + brokenInvoked.get()); + } + + //------------------------< outer (whole-method) Throwable barrier >--- + + /** + * OUTER Throwable barrier: a poisoned {@link AuditEvent} whose + * {@code getDomain()} throws would, without the barrier, propagate + * through {@link AuditDrainObserver#contentChanged} into Oak's commit + * dispatch — surfacing as a fake commit failure to the merge caller + * despite a successful durable commit (audit never masquerades as a + * commit failure). + *

+ * The outer {@code try { doContentChanged(info); } catch (Throwable t)} + * in {@code contentChanged} catches this, logs WARN with the session + * id for diagnostics, and returns normally. The merge thread sees no + * exception. + */ + @Test + public void poisonedEventGetDomainThrowsCaughtByOuterBarrier() { + setToggle(true); + CapturingListener listener = registerCapturingListener(DOMAIN_A); + + // Poison: an AuditEvent whose getDomain() throws. The Decorator + // wraps it lazily so the throw lands in groupByDomain — the first + // call site that invokes event.getDomain() to use it as a HashMap key. + // The exception propagates out of doContentChanged into the outer + // catch in contentChanged. + AuditEvent poison = new AuditEvent() { + @Override public @NotNull String getDomain() { + throw new RuntimeException("synthetic-poisoned-event"); + } + @Override public @NotNull String getType() { return "type-poison"; } + @Override public long getTimestamp() { return 0L; } + }; + buffer.record(SESSION_ID, poison); + + LogCustomizer log = LogCustomizer.forLogger(AuditDrainObserver.class) + .enable(Level.WARN).create(); + log.starting(); + try { + // The call MUST return normally — no exception to the merge thread. + observer.contentChanged(ROOT, localCommit()); + + // Outer barrier logged exactly one WARN with the session id. + List logs = log.getLogs(); + assertEquals("outer Throwable barrier must log exactly one WARN line", + 1, logs.size()); + assertTrue("WARN must include the session id for diagnostics; was: " + logs.get(0), + logs.get(0).contains(SESSION_ID)); + + // Listener never received the poisoned event — groupByDomain threw + // before any dispatch could happen. + assertTrue("listener must not have received the poisoned event", + listener.received.isEmpty()); + } finally { + log.finished(); + } + } + + /** + * Stronger isolation variant of {@link #externalCommitShortCircuitsBeforeDrain}: + * uses a mock {@link AuditBuffer} and asserts via Mockito + * {@code verifyNoInteractions(...)} that the buffer is never touched on + * an external commit. The behavioral variant proves the buffer remains + * un-drained via state; this one proves the buffer is not even + * consulted — the short-circuit fires before any buffer method + * call. Belt-and-braces. + */ + @Test + public void externalCommitShortCircuitsWithoutTouchingBuffer() { + setToggle(true); + AuditBuffer mockBuffer = Mockito.mock(AuditBuffer.class); + AuditDrainObserver observerWithMockBuffer = + new AuditDrainObserver(featureToggle, mockBuffer, registry); + + observerWithMockBuffer.contentChanged(ROOT, externalCommit()); + + Mockito.verifyNoInteractions(mockBuffer); + } + + /** + * Additional probe of the OUTER Throwable barrier — complements + * {@link #poisonedEventGetDomainThrowsCaughtByOuterBarrier} by reaching + * the barrier through a different code path: the {@code buffer.drain} + * call itself throws (e.g., would model a regression where the buffer's + * ThreadLocal state machine was corrupted). The poison-event variant + * exercises the {@code groupByDomain} → {@code event.getDomain()} throw + * site; this one exercises the {@code buffer.drain(sessionId)} throw + * site. Both must be caught and logged WARN without propagation. + */ + @Test + public void bufferDrainThrowsCaughtByOuterBarrier() { + setToggle(true); + registerCapturingListener(DOMAIN_A); + + AuditBuffer throwingBuffer = Mockito.mock(AuditBuffer.class); + Mockito.doThrow(new RuntimeException("synthetic-drain-failure")) + .when(throwingBuffer).drain(SESSION_ID); + AuditDrainObserver observerWithThrowingBuffer = + new AuditDrainObserver(featureToggle, throwingBuffer, registry); + + LogCustomizer log = LogCustomizer.forLogger(AuditDrainObserver.class) + .enable(Level.WARN).create(); + log.starting(); + try { + // Must return normally — outer barrier swallows the drain failure. + observerWithThrowingBuffer.contentChanged(ROOT, localCommit()); + + List logs = log.getLogs(); + assertEquals("outer Throwable barrier must log exactly one WARN line", + 1, logs.size()); + assertTrue("WARN must include the session id for diagnostics; was: " + logs.get(0), + logs.get(0).contains(SESSION_ID)); + } finally { + log.finished(); + } + } + + //-------------------------------< happy path: decorator + dispatch >--- + + /** + * Happy path: with the toggle ON, a registered listener for the event's + * domain, and a non-external commit, the observer drains the buffer, + * decorates the events with commit metadata, and dispatches to the + * listener. Pins {@link CommitMetadataDecorator#decorate} runs and + * the listener receives the decorated payload. + */ + @Test + public void successfulCommitDrainsAndDecoratesAndDispatches() { + setToggle(true); + CapturingListener listener = registerCapturingListener(DOMAIN_A); + + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1", Map.of("k", "v"))); + observer.contentChanged(ROOT, localCommit()); + + assertEquals("listener must receive exactly one event", 1, listener.received.size()); + AuditEvent received = listener.received.get(0); + assertEquals("event type must round-trip", "type-1", received.getType()); + + Map payload = received.getPayload(); + // Original payload is preserved. + assertEquals("v", payload.get("k")); + // Decorator adds commit metadata. + assertEquals(SESSION_ID, payload.get(CommitMetadataDecorator.KEY_SESSION_ID)); + assertEquals(USER_ID, payload.get(CommitMetadataDecorator.KEY_USER_ID)); + assertTrue("commit.timestamp must be present", + payload.containsKey(CommitMetadataDecorator.KEY_TIMESTAMP)); + + // Buffer drained — events no longer staged for this session. + assertNull("buffer must be drained on successful dispatch", + buffer.peek(SESSION_ID)); + } + + //------------------------------------< immutable dispatch list >--- + + /** + * Each listener must receive an IMMUTABLE view of its per-domain event + * list: an attempt to structurally mutate the supplied list throws + * {@link UnsupportedOperationException}. Guards the + * {@code Collections.unmodifiableList(...)} wrapping in + * {@code doContentChanged} so one misbehaving listener cannot corrupt + * the list another listener (on the same domain) will see. + */ + @Test + public void listenerReceivesImmutableEventList() { + setToggle(true); + AtomicReference caught = new AtomicReference<>(); + AuditEventListener mutating = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public void onEvents(@NotNull List events) { + try { + events.add(AuditEvent.of(DOMAIN_A, "injected")); + } catch (Throwable t) { + caught.set(t); + } + } + }; + whiteboard.register(AuditEventListener.class, mutating, Map.of()); + + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + observer.contentChanged(ROOT, localCommit()); + + assertNotNull("listener's mutation attempt must have been rejected", caught.get()); + assertTrue("mutation must throw UnsupportedOperationException; was: " + caught.get(), + caught.get() instanceof UnsupportedOperationException); + } + + //------------------------------------< toggle-flicker (corrected) >--- + + /** + * Toggle-flicker corrected behavior (intentional, explicitly-requested + * change): an event captured with the toggle ON, then drained by a commit + * whose observer fires with the toggle OFF, is DISCARDED + * (drained-without-dispatch) and does NOT leak into a subsequent commit. + * The subsequent commit (toggle back ON) delivers only its OWN event — + * no misattribution of the stale event to the later commit's metadata. + *

+ * Before the fix the observer returned BEFORE draining on toggle-off, so + * E1 survived in the buffer and was dispatched on the next commit + * decorated with E2's {@code commit.*} metadata. The unconditional drain + * now discards E1 during the toggle-OFF window. This directly pins the + * behavior change retuned in the {@code AuditPipelineTest} rebase tests. + */ + @Test + public void toggleFlipMidFlightDoesNotLeakStaleEvent() { + CapturingListener listener = registerCapturingListener(DOMAIN_A); + + // Commit #1: capture E1 with toggle ON, observer fires with toggle OFF. + setToggle(true); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "E1", Map.of("trace.id", "E1"))); + setToggle(false); + observer.contentChanged(ROOT, localCommit()); + + assertTrue("toggle-off observer-fire must not dispatch", listener.received.isEmpty()); + assertNull("E1 must be drained (not stranded) during the toggle-off window", + buffer.peek(SESSION_ID)); + + // Commit #2: toggle back ON, capture E2, observer fires. Only E2. + setToggle(true); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "E2", Map.of("trace.id", "E2"))); + observer.contentChanged(ROOT, localCommit()); + + assertEquals("exactly one event must be delivered on commit #2", + 1, listener.received.size()); + assertEquals("delivered event must be E2, not the stale E1", + "E2", listener.received.get(0).getType()); + assertEquals("E2", listener.received.get(0).getPayload().get("trace.id")); + } + + //----------------------------------------------------------< fixtures >--- + + /** + * Local commit with the test session and user id. {@code external=false} + * by construction — the four-arg ctor used here is the only way to set + * it explicitly, and we deliberately use the two-arg one for the + * common-case test commits. + */ + private static CommitInfo localCommit() { + return new CommitInfo(SESSION_ID, USER_ID); + } + + /** + * External commit — used by {@link #externalCommitShortCircuitsBeforeDrain} + * to drive the {@code isExternal()} short-circuit. The session id is the + * same as local commits so that the buffer would otherwise be drained + * if the short-circuit failed to fire. + */ + private static CommitInfo externalCommit() { + return new CommitInfo(SESSION_ID, USER_ID, Map.of(), true); + } + + /** + * Flips the {@code FT_OAK-12331} feature toggle. Locates the + * {@link FeatureToggle} service that {@link Feature#newFeature} registered + * on the test whiteboard. + */ + private void setToggle(boolean enabled) { + Tracker tracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : tracker.getServices()) { + if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + tracker.stop(); + } + } + + private CapturingListener registerCapturingListener(@NotNull String domain) { + CapturingListener l = new CapturingListener(domain); + whiteboard.register(AuditEventListener.class, l, Map.of()); + return l; + } + + /** + * Rethrows {@code t} unchecked — the {@link AuditEventListener#onEvents} + * signature is unchecked, so we need to coerce the caller-supplied + * Throwable through Java's checked-exception machinery. Uses the + * unsafe-generic-cast trick. + */ + @SuppressWarnings("unchecked") + private static void rethrow(@NotNull Throwable t) throws T { + throw (T) t; + } + + private static final class CapturingListener implements AuditEventListener { + + private final String domain; + final List received = new ArrayList<>(); + + CapturingListener(@NotNull String domain) { + this.domain = domain; + } + + @Override + public @NotNull String getDomain() { + return domain; + } + + @Override + public void onEvents(@NotNull List events) { + received.addAll(events); + } + } + +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java new file mode 100644 index 00000000000..5d1421ea4e1 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class AuditEventEmitterImplTest { + + private AtomicReference dispatched; + + @Before + public void installSink() { + dispatched = new AtomicReference<>(); + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return true; } + @Override public boolean isEnabledFor(@NotNull String domain) { return "yes".equals(domain); } + @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* unused */ } + @Override public void dispatch(@NotNull AuditEvent event) { dispatched.set(event); } + }); + } + + @After + public void tearDown() { + AuditEvents.install(null); + } + + private static AuditEvent fixedEvent(@NotNull String domain) { + return new AuditEvent() { + @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull String getType() { return "t"; } + @Override public long getTimestamp() { return 0L; } + @Override public @NotNull Map getPayload() { return Collections.emptyMap(); } + }; + } + + @Test + public void emitRoutesToFacadeDispatch() { + AuditEventEmitterImpl impl = new AuditEventEmitterImpl(); + AuditEvent e = fixedEvent("yes"); + impl.emit(e); + assertSame(e, dispatched.get()); + } + + @Test + public void isEnabledForRoutesToFacade() { + AuditEventEmitterImpl impl = new AuditEventEmitterImpl(); + assertTrue(impl.isEnabledFor("yes")); + assertFalse(impl.isEnabledFor("no")); + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java new file mode 100644 index 00000000000..3f394c55649 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.io.Closeable; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.jcr.Credentials; +import javax.jcr.SimpleCredentials; +import javax.security.auth.login.Configuration; + +import org.apache.jackrabbit.oak.InitialContentHelper; +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.api.ContentRepository; +import org.apache.jackrabbit.oak.api.ContentSession; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.commit.Observable; +import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; +import org.apache.jackrabbit.oak.spi.security.SecurityProvider; +import org.apache.jackrabbit.oak.spi.security.authentication.ConfigurationUtil; +import org.apache.jackrabbit.oak.spi.state.NodeStore; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import static org.junit.Assert.assertEquals; + +/** + * Fixture-parameterized end-to-end test of the commit-attached audit + * pipeline: record an audit event on a session, commit, and assert the + * registered {@link AuditEventListener} fires with the correct + * {@code commit.sessionId}. + *

+ * Fixture coverage note (for turing). This module + * ({@code oak-core}) only has in-process access to {@link MemoryNodeStore} + * via {@code oak-store-spi}. It deliberately does not depend on + * {@code oak-segment-tar} or {@code oak-store-document} — those modules + * depend on {@code oak-core}, so adding them here would create a dependency + * cycle. The test is therefore written as a {@link Parameterized} harness + * with {@code MEMORY_NS} as the only in-module fixture, but structured so + * additional fixtures slot in trivially (add a row to {@link #fixtures()}). + *

+ * The {@code SEGMENT_TAR} (synchronous, in-process) and {@code DOCUMENT_NS} + * (asynchronous observation, MongoDB-backed, must be guarded behind a + * Mongo-available check) variants belong in a module that already depends on + * those stores — {@code oak-jcr} or {@code oak-it}. The audit wiring used + * here is fully public ({@link AuditConfigurationImpl#initialize}, + * {@link AuditConfigurationImpl#getDrainObserver}, {@link AuditEvents}, + * {@link AuditEventListener}), so this class can be lifted there as-is and + * the extra fixtures added to {@link #fixtures()}. The DOCUMENT_NS row will + * additionally need to await asynchronous dispatch (the drain observer is + * synchronous, but external/async observation on DocumentNodeStore is not), + * e.g. poll {@code received} with a bounded timeout. + */ +@RunWith(Parameterized.class) +public class AuditFixtureTest { + + private static final String DOMAIN = "test.domain"; + private static final String FEATURE_TOGGLE_NAME = AuditConfigurationImpl.FEATURE_TOGGLE_NAME; + + /** + * Supplies a fresh {@link NodeStore} for each test run of a fixture. + */ + @FunctionalInterface + interface NodeStoreFactory { + @NotNull NodeStore create(); + } + + @Parameterized.Parameters(name = "{0}") + public static Collection fixtures() { + // MEMORY_NS only — see the class Javadoc "Fixture coverage note". + return Arrays.asList(new Object[][]{ + {"MEMORY_NS", + (NodeStoreFactory) () -> new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT)} + }); + } + + private final String fixtureName; + private final NodeStoreFactory storeFactory; + + private Whiteboard whiteboard; + private AuditConfigurationImpl auditConfig; + private Closeable drainObserverSubscription; + private List received; + private ContentRepository repository; + private SecurityProvider securityProvider; + + public AuditFixtureTest(@NotNull String fixtureName, @NotNull NodeStoreFactory storeFactory) { + this.fixtureName = fixtureName; + this.storeFactory = storeFactory; + } + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + received = new CopyOnWriteArrayList<>(); + + auditConfig = new AuditConfigurationImpl(); + auditConfig.initialize(whiteboard); + securityProvider = SecurityProviderBuilder.newBuilder() + .withWhiteboard(whiteboard) + .build(); + + Configuration.setConfiguration( + ConfigurationUtil.getDefaultConfiguration(ConfigurationParameters.EMPTY)); + + setToggle(true); + + AuditEventListener listener = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }; + whiteboard.register(AuditEventListener.class, listener, Map.of()); + + NodeStore store = storeFactory.create(); + // The drain observer is attached directly to the store's Observable — + // .with(whiteboard) below replaces Oak's default whiteboard and bypasses + // the auto-attach at Oak.java:300-302 (see AuditPipelineTest Javadoc). + drainObserverSubscription = ((Observable) store).addObserver(auditConfig.getDrainObserver()); + + repository = new Oak(store) + .with(securityProvider) + .with(whiteboard) + .createContentRepository(); + } + + @After + public void tearDown() throws Exception { + try { + if (drainObserverSubscription != null) { + drainObserverSubscription.close(); + } + if (auditConfig != null) { + auditConfig.dispose(); + } + if (repository instanceof Closeable) { + ((Closeable) repository).close(); + } + } finally { + Configuration.setConfiguration(null); + } + } + + private void setToggle(boolean enabled) { + Tracker toggleTracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : toggleTracker.getServices()) { + if (FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + toggleTracker.stop(); + } + } + + private static Credentials adminCredentials() { + return new SimpleCredentials("admin", "admin".toCharArray()); + } + + private static AuditEvent eventFor(@NotNull String type, @NotNull Map payload) { + return new AuditEvent() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull String getType() { return type; } + @Override public long getTimestamp() { return System.currentTimeMillis(); } + @Override public @NotNull Map getPayload() { return payload; } + }; + } + + @Test + public void recordedEventReachesListenerWithCommitSessionId() throws Exception { + try (ContentSession session = repository.login(adminCredentials(), null)) { + Root root = session.getLatestRoot(); + AuditEvents.record(root, eventFor("commit.type", Map.of("note", "v"))); + root.getTree("/").setProperty("scratch", "value"); + root.commit(); + + assertEquals("[" + fixtureName + "] exactly one event must reach the listener", + 1, received.size()); + AuditEvent e = received.get(0); + assertEquals("commit.type", e.getType()); + assertEquals("[" + fixtureName + "] commit.sessionId must match the committing session", + session.toString(), e.getPayload().get("commit.sessionId")); + assertEquals("v", e.getPayload().get("note")); + } + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java new file mode 100644 index 00000000000..e1ad24e9e39 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java @@ -0,0 +1,1212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.io.Closeable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; + +import javax.jcr.Credentials; +import javax.jcr.SimpleCredentials; +import javax.security.auth.login.Configuration; + +import org.apache.jackrabbit.oak.InitialContentHelper; +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.api.CommitFailedException; +import org.apache.jackrabbit.oak.api.ContentRepository; +import org.apache.jackrabbit.oak.api.ContentSession; +import org.apache.jackrabbit.oak.api.PropertyState; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.apache.jackrabbit.oak.spi.commit.DefaultValidator; +import org.apache.jackrabbit.oak.spi.commit.EmptyHook; +import org.apache.jackrabbit.oak.spi.commit.Validator; +import org.apache.jackrabbit.oak.spi.commit.ValidatorProvider; +import org.apache.jackrabbit.oak.spi.state.NodeBuilder; +import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; +import org.apache.jackrabbit.oak.spi.security.SecurityProvider; +import org.apache.jackrabbit.oak.spi.security.authentication.ConfigurationUtil; +import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Registration; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.event.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * End-to-end integration test exercising both audit pipelines through a + * direct {@link AuditConfigurationImpl} install. Uses {@link MemoryNodeStore} + * for a real but in-process Oak instance. + *

+ * Wiring path: + *

    + *
  1. {@link AuditConfigurationImpl#initialize(Whiteboard)} installs the + * audit feature toggle, listener registry, buffer and capture-time + * sink onto the whiteboard.
  2. + *
  3. {@code store.addObserver(audit.getDrainObserver())} attaches the + * {@link AuditDrainObserver} directly to the {@code MemoryNodeStore}. + * We don't use {@code Oak.with(Observer)} because we pass a custom + * whiteboard via {@code Oak.with(Whiteboard)}, which replaces Oak's + * default anonymous-override whiteboard and bypasses the auto-attach + * at {@code Oak.java:300-302}.
  4. + *
  5. Teardown closes the {@code Observable.addObserver} {@code Closeable} + * and calls {@link AuditConfigurationImpl#dispose()} — the same code + * path that OSGi {@code @Deactivate} uses.
  6. + *
+ * No test mirror of {@code BufferSink} or observer wiring exists in this + * class; a bug in either pipeline will surface here. + */ +public class AuditPipelineTest { + + private static final String DOMAIN = "test.domain"; + private static final String OTHER_DOMAIN = "other.domain"; + private static final String FEATURE_TOGGLE_NAME = AuditConfigurationImpl.FEATURE_TOGGLE_NAME; + + private Whiteboard whiteboard; + private AuditConfigurationImpl auditConfig; + private Closeable drainObserverSubscription; + private Registration listenerRegistration; + private List received; + private ContentRepository repository; + private AuditEventEmitter emitter; + private SecurityProvider securityProvider; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + received = new CopyOnWriteArrayList<>(); + + auditConfig = new AuditConfigurationImpl(); + // initialize() installs sinks/registry/buffer/toggle; the drain Observer + // is attached per-store below via Observable.addObserver(...). + auditConfig.initialize(whiteboard); + securityProvider = SecurityProviderBuilder.newBuilder() + .withWhiteboard(whiteboard) + .build(); + + // JAAS — wire the default authentication configuration from the + // SecurityProvider's params so repository.login(adminCreds) succeeds. + Configuration.setConfiguration( + ConfigurationUtil.getDefaultConfiguration(ConfigurationParameters.EMPTY)); + + // Flip the feature toggle ON via the FeatureToggle service the + // AuditConfigurationImpl.initialize() call registered on the + // whiteboard. + setToggle(true); + + // Register a domain-scoped listener that captures events for + // verification. Single listener tests use DOMAIN; multi-listener + // tests register additional listeners inline. + AuditEventListener listener = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }; + listenerRegistration = whiteboard.register(AuditEventListener.class, listener, Map.of()); + + MemoryNodeStore store = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + // Bare-metal embedded wiring: attach the drain observer directly to the + // Observable. We can't use Oak.with(Observer) here because the test + // replaces Oak's default whiteboard via .with(whiteboard), which bypasses + // the auto-attach at Oak.java:300-302. See AuditPipelineTest class Javadoc. + drainObserverSubscription = store.addObserver(auditConfig.getDrainObserver()); + + repository = new Oak(store) + .with(securityProvider) + .with(whiteboard) + .createContentRepository(); + + emitter = new AuditEventEmitterImpl(); + } + + @After + public void tearDown() throws Exception { + try { + if (drainObserverSubscription != null) { + drainObserverSubscription.close(); + } + if (listenerRegistration != null) { + listenerRegistration.unregister(); + } + if (auditConfig != null) { + auditConfig.dispose(); + } + if (repository instanceof Closeable) { + ((Closeable) repository).close(); + } + } finally { + Configuration.setConfiguration(null); + } + } + + private static Credentials adminCredentials() { + return new SimpleCredentials("admin", "admin".toCharArray()); + } + + private void setToggle(boolean enabled) { + Tracker toggleTracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : toggleTracker.getServices()) { + if (FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + toggleTracker.stop(); + } + } + + private static AuditEvent eventFor(@NotNull String domain, + @NotNull String type, + @NotNull Map payload) { + return new AuditEvent() { + @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull String getType() { return type; } + @Override public long getTimestamp() { return System.currentTimeMillis(); } + @Override public @NotNull Map getPayload() { return payload; } + }; + } + + private ContentSession login() throws Exception { + return repository.login(adminCredentials(), null); + } + + //--------------------------------------------------------< original 3 >--- + + @Test + public void fireAndForgetEventCarriesNoCommitMetadata() { + emitter.emit(eventFor(DOMAIN, "forget", Map.of("key", "v"))); + assertEquals(1, received.size()); + AuditEvent e = received.get(0); + assertEquals("forget", e.getType()); + assertFalse("fire-and-forget event must not carry commit.sessionId", + e.getPayload().containsKey("commit.sessionId")); + assertFalse(e.getPayload().containsKey("commit.userId")); + assertEquals("v", e.getPayload().get("key")); + } + + @Test + public void emitNoListenerForDomainIsNoOp() { + emitter.emit(eventFor(OTHER_DOMAIN, "x", Map.of())); + assertTrue(received.isEmpty()); + } + + @Test + public void commitAttachedEventCarriesCommitMetadata() throws Exception { + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record( + root, eventFor(DOMAIN, "commit.type", Map.of("note", "v"))); + root.getTree("/").setProperty("scratch", "value"); + root.commit(); + + assertEquals(1, received.size()); + AuditEvent e = received.get(0); + assertEquals("commit.type", e.getType()); + Map p = e.getPayload(); + assertTrue("commit-attached event must carry commit.sessionId", + p.containsKey("commit.sessionId")); + assertTrue(p.containsKey("commit.userId")); + assertTrue(p.containsKey("commit.timestamp")); + assertEquals("v", p.get("note")); + } + } + + //--------------------------< fire-and-forget attestation-key strip >--- + + /** + * Trust-contract regression (fire-and-forget half): an emitter that + * pre-populates the three Oak-attested keys ({@code commit.sessionId}, + * {@code commit.userId}, {@code commit.timestamp}) must NOT get them + * delivered to listeners — {@code BufferSink.dispatch} strips exactly + * those three so their presence in a dispatched payload is a reliable + * "Oak-attested commit-attached event" signal (see + * {@link AuditEvent#getPayload()}). Without the strip, any bundle could + * forge commit identity in audit logs (CWE-345). + *

+ * Only the three reserved keys are stripped: an arbitrary + * {@code commit.*}-prefixed passenger key and ordinary payload entries + * are forwarded verbatim — mirrors + * {@code CommitMetadataDecoratorTest#decoratorDoesNotProtectOtherCommitPrefixedKeys} + * on the commit-attached half. + */ + @Test + public void fireAndForgetStripsForgedCommitAttestationKeys() { + emitter.emit(eventFor(DOMAIN, "forged", Map.of( + "commit.sessionId", "forged-session", + "commit.userId", "forged-admin", + "commit.timestamp", 99999999L, + "commit.custom", "passenger", + "key", "v"))); + + assertEquals(1, received.size()); + Map p = received.get(0).getPayload(); + assertFalse("forged commit.sessionId must be stripped on fire-and-forget dispatch", + p.containsKey("commit.sessionId")); + assertFalse("forged commit.userId must be stripped on fire-and-forget dispatch", + p.containsKey("commit.userId")); + assertFalse("forged commit.timestamp must be stripped on fire-and-forget dispatch", + p.containsKey("commit.timestamp")); + assertEquals("non-reserved commit.* keys are forwarded verbatim (untrusted)", + "passenger", p.get("commit.custom")); + assertEquals("ordinary payload entries are forwarded verbatim", "v", p.get("key")); + assertEquals("domain must survive the strip", DOMAIN, received.get(0).getDomain()); + assertEquals("type must survive the strip", "forged", received.get(0).getType()); + } + + /** + * Conditional-wrap pin (passes before and after the strip fix; guards + * the GREEN implementation shape): an event WITHOUT any reserved + * {@code commit.*} key is dispatched as the SAME instance — no + * defensive wrapping, no payload copy. Emitters relying on concrete + * event subtypes (typed accessors) keep working on the + * fire-and-forget path as long as their payloads are clean. + */ + @Test + public void fireAndForgetCleanPayloadDispatchesSameEventInstance() { + AuditEvent clean = eventFor(DOMAIN, "clean", Map.of("key", "v")); + emitter.emit(clean); + + assertEquals(1, received.size()); + assertSame("clean payloads must not be re-wrapped — concrete event type preserved", + clean, received.get(0)); + } + + //------------------------------------------------< new — discard tests >--- + + /** + * After a failed commit, the events staged in the per-session buffer + * must be discarded. The strongest assertion is end-to-end: do a + * SUBSEQUENT successful commit on the same session and verify only the + * fresh event arrives. A naive "buffer empty after failure" assertion + * would pass under a regression that drained the wrong session's slot. + */ + @Test + public void commitFailureDiscardsStagedEvents() throws Exception { + // Build a separate Oak instance with an injected throwing validator + // — the main fixture's repository can't carry the validator without + // breaking the success-path tests. The audit pipeline state on the + // whiteboard is shared, which is what we want to exercise. + MemoryNodeStore store2 = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + Closeable observer2 = store2.addObserver(auditConfig.getDrainObserver()); + ContentRepository repo2 = new Oak(store2) + .with(securityProvider) + .with(whiteboard) + .with(new ThrowingValidatorProvider("trigger-failure")) + .createContentRepository(); + try (ContentSession session = repo2.login(adminCredentials(), null)) { + // Stage E1, then force commit failure via the trigger property. + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "discarded", + Map.of("trace.id", "E1-from-failed-commit"))); + r1.getTree("/").setProperty("trigger-failure", "boom"); + try { + r1.commit(); + fail("Expected CommitFailedException from injected validator"); + } catch (CommitFailedException expected) { + // expected + } + + // Subsequent successful commit on the SAME session. + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered", + Map.of("trace.id", "E2-from-successful-commit"))); + r2.getTree("/").setProperty("scratch", "value"); + r2.commit(); + + assertEquals("only E2 must be delivered", 1, received.size()); + AuditEvent d = received.get(0); + assertEquals("event type is E2's", "delivered", d.getType()); + assertEquals("payload is E2's, not E1's or merged", + "E2-from-successful-commit", d.getPayload().get("trace.id")); + assertEquals("commit.sessionId decorates with current session", + session.toString(), d.getPayload().get("commit.sessionId")); + } finally { + observer2.close(); + if (repo2 instanceof Closeable) { + ((Closeable) repo2).close(); + } + } + } + + /** + * After {@code root.refresh()} the staged events for the session must + * be discarded — mirrors {@link #commitFailureDiscardsStagedEvents()}. + */ + @Test + public void refreshDiscardsStagedEvents() throws Exception { + try (ContentSession session = login()) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "discarded", + Map.of("trace.id", "E1-discarded-by-refresh"))); + r1.refresh(); + + // After refresh, dispatch a fresh event and commit. + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered", + Map.of("trace.id", "E2-after-refresh"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("only E2 must be delivered", 1, received.size()); + AuditEvent d = received.get(0); + assertEquals("delivered", d.getType()); + assertEquals("E2-after-refresh", d.getPayload().get("trace.id")); + } + } + + /** + * After {@code root.rebase()} the staged events for the session must be + * PRESERVED — rebase keeps the session's transient changes (they are + * replayed on the new base), so the audit events captured alongside them + * must survive too and be dispatched on the eventual commit. + *

+ * Intentional behavior change (review CONCERN): a prior + * iteration drained the buffer on rebase via {@code onRefresh}; that + * dropped audit events for changes that survived the rebase. The drain + * was removed from {@code MutableRoot.rebase()}. Contrast with + * {@link #refreshDiscardsStagedEvents()} — refresh discards transient + * changes and so still drains. + */ + @Test + public void rebasePreservesStagedEvents() throws Exception { + try (ContentSession session = login()) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "preserved", + Map.of("trace.id", "E1-survives-rebase"))); + r1.rebase(); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered", + Map.of("trace.id", "E2-after-rebase"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + // Both events delivered, in capture order — E1 survived the rebase. + assertEquals("rebase must PRESERVE staged events; E1 and E2 both delivered", + 2, received.size()); + assertEquals("E1-survives-rebase", + received.get(0).getPayload().get("trace.id")); + assertEquals("E2-after-rebase", + received.get(1).getPayload().get("trace.id")); + } + } + + //------------------------------< gate-transition tests >--- + // These tests pin how the {@code MutableRoot} lifecycle callouts behave + // across a gate transition: the audit gate flips OFF between capture and + // the lifecycle event, then ON again before the next commit. The gate + // factors as {@code featureToggle.isEnabled() && registry.hasAnyListener()} + // (see {@code AuditConfigurationImpl.BufferSink.isEnabled}) so it can flip + // OFF via two functionally identical sources: + // 1. Toggle flicker — {@code FT_OAK-12331} flipped off at runtime. + // 2. Listener churn — the only registered listener deregisters. + // + // refresh() and commit-failure MUST drain even when the gate is OFF at + // callout time — otherwise a stale event survives the gate-OFF window and + // is later dispatched against a LATER commit's metadata (misattribution). + // Those callouts are therefore UNCONDITIONAL in MutableRoot. → 4 tests. + // + // rebase() is different: it PRESERVES the session's transient changes, so + // it intentionally does NOT drain (the audit events captured alongside + // those surviving changes must survive too). The 2 rebase variants below + // therefore assert PRESERVATION across the same gate transitions. → 2 tests. + // (Six gate-transition regression tests total.) + + /** + * Toggle flips OFF between capture and refresh: the lifecycle + * callout MUST still fire and drain the buffer. Pins + * {@code MutableRoot.refresh()} ALWAYS calling + * {@code AuditBufferLifecycle.onRefresh(sessionId)} — without the + * gate that an earlier iteration added on + * {@code AuditEvents.isEnabled()}. + */ + @Test + public void refreshDiscardsStagedEventsAcrossToggleFlicker() throws Exception { + try (ContentSession session = login()) { + // Capture E1 with gate=ON. + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-r1", + Map.of("trace.id", "E1-must-not-leak-via-toggle"))); + + // Flip gate OFF via toggle. + setToggle(false); + + // Refresh: the callout must drain the buffer despite the + // gate being off. Without this guarantee, E1 survives. + r1.refresh(); + + // Flip gate ON for the subsequent commit + dispatch. + setToggle(true); + + // Capture E2 and commit on the SAME session. + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + // Strict: exactly one event, and it must be E2. If the + // lifecycle drain was skipped, received would carry both + // E1 (decorated with r2's commit metadata — the integrity + // violation) and E2. + assertEquals("only E2 must be delivered; E1 must NOT survive the toggle-flicker", + 1, received.size()); + AuditEvent d = received.get(0); + assertEquals("delivered-by-r2", d.getType()); + assertEquals("E2-current", d.getPayload().get("trace.id")); + } + } + + /** + * Rebase variant of {@link #refreshDiscardsStagedEventsAcrossToggleFlicker} + * — but INVERTED: rebase PRESERVES staged events (it does not drain). The + * event captured before the rebase survives the toggle flicker and the + * rebase, and is delivered (alongside the post-rebase event) on the + * eventual commit, each keeping its own payload. + */ + @Test + public void rebasePreservesStagedEventsAcrossToggleFlicker() throws Exception { + try (ContentSession session = login()) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-r1", + Map.of("trace.id", "E1-survives-rebase-toggle"))); + + setToggle(false); + r1.rebase(); + setToggle(true); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("rebase preserves E1 across the toggle flicker; E1 and E2 both delivered", + 2, received.size()); + assertEquals("E1-survives-rebase-toggle", + received.get(0).getPayload().get("trace.id")); + assertEquals("E2-current", + received.get(1).getPayload().get("trace.id")); + } + } + + /** + * Commit-failure variant of {@link #refreshDiscardsStagedEventsAcrossToggleFlicker}. + * Pins the {@code finally if (!merged) onCommitFailed(...)} branch + * in {@code MutableRoot.commit()} firing even when the gate is off. + * Uses a separate Oak instance with the same throwing validator as + * {@link #commitFailureDiscardsStagedEvents()} for a deterministic + * commit failure. + */ + @Test + public void commitFailureDiscardsStagedEventsAcrossToggleFlicker() throws Exception { + MemoryNodeStore store2 = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + Closeable observer2 = store2.addObserver(auditConfig.getDrainObserver()); + ContentRepository repo2 = new Oak(store2) + .with(securityProvider) + .with(whiteboard) + .with(new ThrowingValidatorProvider("trigger-failure")) + .createContentRepository(); + try (ContentSession session = repo2.login(adminCredentials(), null)) { + // Capture E1 with gate=ON. + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-failed-r1", + Map.of("trace.id", "E1-must-not-leak-via-toggle-failure"))); + + // Flip gate OFF, then trigger a deterministic commit failure. + // The failing commit must still drain the buffer via the + // finally-block callout — that's the invariant under test. + setToggle(false); + r1.getTree("/").setProperty("trigger-failure", "boom"); + try { + r1.commit(); + fail("Expected CommitFailedException from injected validator"); + } catch (CommitFailedException expected) { + // expected + } + + // Flip gate ON for the subsequent successful commit + dispatch. + setToggle(true); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("only E2 must be delivered; E1 must NOT survive the toggle-flicker around commit-failure", + 1, received.size()); + assertEquals("E2-current", + received.get(0).getPayload().get("trace.id")); + } finally { + observer2.close(); + if (repo2 instanceof Closeable) { + ((Closeable) repo2).close(); + } + } + } + + /** + * Listener-churn variant of {@link #refreshDiscardsStagedEventsAcrossToggleFlicker}. + * The sole registered listener deregisters between capture and + * refresh, flipping {@code AuditEvents.isEnabled()} via the + * {@code registry.hasAnyListener()} factor. A fresh listener + * re-registers (writing to the same {@code received} collection) + * before the next commit. Verifies the gate-OFF source doesn't + * matter — only that the callout always fires. + */ + @Test + public void refreshDiscardsStagedEventsAcrossListenerChurn() throws Exception { + try (ContentSession session = login()) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-r1", + Map.of("trace.id", "E1-must-not-leak-via-listener-churn"))); + + // Flip gate OFF via listener deregistration. + listenerRegistration.unregister(); + + r1.refresh(); + + // Re-register a fresh listener writing to the same `received` + // collection — the test only cares whether the leaked event + // is observable downstream, not which listener instance sees it. + listenerRegistration = whiteboard.register(AuditEventListener.class, + new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }, Map.of()); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("only E2 must be delivered; E1 must NOT survive the listener-churn around refresh", + 1, received.size()); + assertEquals("E2-current", + received.get(0).getPayload().get("trace.id")); + } + } + + /** + * Listener-churn variant for {@code rebase} — INVERTED like + * {@link #rebasePreservesStagedEventsAcrossToggleFlicker}: rebase + * preserves E1 across the listener churn; both E1 and E2 are delivered + * to the re-registered listener on commit. + */ + @Test + public void rebasePreservesStagedEventsAcrossListenerChurn() throws Exception { + try (ContentSession session = login()) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-r1", + Map.of("trace.id", "E1-survives-rebase-churn"))); + + listenerRegistration.unregister(); + r1.rebase(); + listenerRegistration = whiteboard.register(AuditEventListener.class, + new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }, Map.of()); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("rebase preserves E1 across the listener churn; E1 and E2 both delivered", + 2, received.size()); + assertEquals("E1-survives-rebase-churn", + received.get(0).getPayload().get("trace.id")); + assertEquals("E2-current", + received.get(1).getPayload().get("trace.id")); + } + } + + /** + * Listener-churn variant for commit-failure. Same separate-Oak + * pattern as {@link #commitFailureDiscardsStagedEventsAcrossToggleFlicker}. + */ + @Test + public void commitFailureDiscardsStagedEventsAcrossListenerChurn() throws Exception { + MemoryNodeStore store2 = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + Closeable observer2 = store2.addObserver(auditConfig.getDrainObserver()); + ContentRepository repo2 = new Oak(store2) + .with(securityProvider) + .with(whiteboard) + .with(new ThrowingValidatorProvider("trigger-failure")) + .createContentRepository(); + try (ContentSession session = repo2.login(adminCredentials(), null)) { + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "staged-by-failed-r1", + Map.of("trace.id", "E1-must-not-leak-via-listener-churn-failure"))); + + listenerRegistration.unregister(); + r1.getTree("/").setProperty("trigger-failure", "boom"); + try { + r1.commit(); + fail("Expected CommitFailedException from injected validator"); + } catch (CommitFailedException expected) { + // expected + } + listenerRegistration = whiteboard.register(AuditEventListener.class, + new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }, Map.of()); + + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "delivered-by-r2", + Map.of("trace.id", "E2-current"))); + r2.getTree("/").setProperty("scratch", "v"); + r2.commit(); + + assertEquals("only E2 must be delivered; E1 must NOT survive the listener-churn around commit-failure", + 1, received.size()); + assertEquals("E2-current", + received.get(0).getPayload().get("trace.id")); + } finally { + observer2.close(); + if (repo2 instanceof Closeable) { + ((Closeable) repo2).close(); + } + } + } + + //----------------------------------------< toggle, grouping, isolation >--- + + /** + * With the feature toggle disabled, neither pipeline emits to listeners. + * Pins the {@code if (!featureToggle.isEnabled()) return} early-return + * in {@code AuditDrainObserver} as well as the toggle gate in + * {@code BufferSink}. + */ + @Test + public void toggleDisabledShortCircuitsEntirePipeline() throws Exception { + setToggle(false); + + // Fire-and-forget path: + emitter.emit(eventFor(DOMAIN, "forget", Map.of())); + assertTrue("fire-and-forget must short-circuit with toggle disabled", + received.isEmpty()); + + // Commit-attached path: + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record( + root, eventFor(DOMAIN, "commit.type", Map.of())); + root.getTree("/").setProperty("scratch", "v"); + root.commit(); + assertTrue("commit-attached must short-circuit with toggle disabled", + received.isEmpty()); + } + } + + /** + * Three events recorded on two domains: listener-A (DOMAIN) receives the + * two for its domain in capture order; listener-B (OTHER_DOMAIN) receives + * only its one. Pins {@code groupByDomain} fan-out. + */ + @Test + public void multipleEventsAcrossDomainsGroupedCorrectly() throws Exception { + List otherReceived = new CopyOnWriteArrayList<>(); + AuditEventListener otherListener = new AuditEventListener() { + @Override public @NotNull String getDomain() { return OTHER_DOMAIN; } + @Override public void onEvents(@NotNull List events) { + otherReceived.addAll(events); + } + }; + Registration otherReg = whiteboard.register(AuditEventListener.class, + otherListener, Map.of()); + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record( + root, eventFor(DOMAIN, "a-1", Map.of())); + AuditEvents.record( + root, eventFor(OTHER_DOMAIN, "b-1", Map.of())); + AuditEvents.record( + root, eventFor(DOMAIN, "a-2", Map.of())); + root.getTree("/").setProperty("scratch", "v"); + root.commit(); + + assertEquals("DOMAIN listener receives 2 events in capture order", + 2, received.size()); + assertEquals("a-1", received.get(0).getType()); + assertEquals("a-2", received.get(1).getType()); + + assertEquals("OTHER_DOMAIN listener receives 1 event", + 1, otherReceived.size()); + assertEquals("b-1", otherReceived.get(0).getType()); + } finally { + otherReg.unregister(); + } + } + + /** + * After a successful commit, the per-thread {@link AuditBuffer}'s slot + * for the session must be drained. Asserted behaviorally via a + * second commit on the SAME session — if drain didn't run after the + * first commit, the second snapshot would re-include E1 and we'd see + * three deliveries total (E1 dispatched by commit#1, then E1+E2 + * re-dispatched by commit#2) instead of two. + */ + @Test + public void bufferDrainedAfterSuccessfulCommit() throws Exception { + try (ContentSession session = login()) { + // Commit #1: record E1, commit. + Root r1 = session.getLatestRoot(); + AuditEvents.record( + r1, eventFor(DOMAIN, "e1", Map.of("trace.id", "E1"))); + r1.getTree("/").setProperty("scratch1", "v"); + r1.commit(); + + // Commit #2 on the same session: record E2, commit. + Root r2 = session.getLatestRoot(); + AuditEvents.record( + r2, eventFor(DOMAIN, "e2", Map.of("trace.id", "E2"))); + r2.getTree("/").setProperty("scratch2", "v"); + r2.commit(); + + // Exactly two deliveries — E1 first, then E2. If drain were + // broken after commit#1, we'd see [E1, E1, E2] = 3 events. + assertEquals("buffer must be drained between commits", 2, received.size()); + assertEquals("first received is E1", "e1", received.get(0).getType()); + assertEquals("second received is E2", "e2", received.get(1).getType()); + // The marker is the regression-guard: a re-dispatch would + // duplicate "E1" at position 1, not produce a fresh "E2". + assertNotEquals("position 1 must not be a stale E1", + "E1", received.get(1).getPayload().get("trace.id")); + } + } + + /** + * Listener that throws {@code RuntimeException} from {@code onEvents} + * must not prevent other listeners on the same domain from receiving + * the event. Pins per-listener isolation in + * {@code AuditDrainObserver.dispatchOne} (commit-attached). + */ + @Test + public void listenerRuntimeExceptionDoesNotPreventOtherListeners() throws Exception { + List bReceived = new CopyOnWriteArrayList<>(); + AuditEventListener throwingA = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 10; } // dispatched first + @Override public void onEvents(@NotNull List events) { + throw new RuntimeException("synthetic-A"); + } + }; + AuditEventListener okB = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 5; } + @Override public void onEvents(@NotNull List events) { + bReceived.addAll(events); + } + }; + Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); + Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record( + root, eventFor(DOMAIN, "x", Map.of())); + root.getTree("/").setProperty("scratch", "v"); + root.commit(); + + assertEquals("listener-B must receive despite listener-A throwing", + 1, bReceived.size()); + } finally { + regA.unregister(); + regB.unregister(); + } + } + + /** + * Listener that throws {@code NoClassDefFoundError} (an + * {@link Error}, not an {@link Exception}) from {@code onEvents} + * must not prevent other listeners from receiving the event. Pins + * the catch-{@code Throwable} contract: any {@link Throwable} + * subtype out of {@code onEvents} is isolated to the misbehaving + * listener, never escaping into the dispatch loop or the surrounding + * commit. + */ + @Test + public void listenerNoClassDefFoundErrorIsIsolated() throws Exception { + List bReceived = new CopyOnWriteArrayList<>(); + AuditEventListener throwingA = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 10; } + @Override public void onEvents(@NotNull List events) { + throw new NoClassDefFoundError("synthetic-A"); + } + }; + AuditEventListener okB = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 5; } + @Override public void onEvents(@NotNull List events) { + bReceived.addAll(events); + } + }; + Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); + Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record( + root, eventFor(DOMAIN, "x", Map.of())); + root.getTree("/").setProperty("scratch", "v"); + root.commit(); + + assertEquals("listener-B must receive despite listener-A throwing NoClassDefFoundError", + 1, bReceived.size()); + } finally { + regA.unregister(); + regB.unregister(); + } + } + + /** + * Fire-and-forget variant of the runtime-exception isolation test. + * Pins the same property in {@code BufferSink.dispatch}. + */ + @Test + public void fireAndForgetListenerRuntimeExceptionDoesNotPreventOthers() { + List bReceived = new CopyOnWriteArrayList<>(); + AuditEventListener throwingA = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 10; } + @Override public void onEvents(@NotNull List events) { + throw new RuntimeException("synthetic-A"); + } + }; + AuditEventListener okB = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 5; } + @Override public void onEvents(@NotNull List events) { + bReceived.addAll(events); + } + }; + Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); + Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); + try { + emitter.emit(eventFor(DOMAIN, "x", Map.of())); + assertEquals("fire-and-forget: listener-B must receive despite A's RuntimeException", + 1, bReceived.size()); + } finally { + regA.unregister(); + regB.unregister(); + } + } + + /** + * Fire-and-forget variant of the Error isolation test. + */ + @Test + public void fireAndForgetListenerNoClassDefFoundErrorIsIsolated() { + List bReceived = new CopyOnWriteArrayList<>(); + AuditEventListener throwingA = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 10; } + @Override public void onEvents(@NotNull List events) { + throw new NoClassDefFoundError("synthetic-A"); + } + }; + AuditEventListener okB = new AuditEventListener() { + @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public int getRank() { return 5; } + @Override public void onEvents(@NotNull List events) { + bReceived.addAll(events); + } + }; + Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); + Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); + try { + emitter.emit(eventFor(DOMAIN, "x", Map.of())); + assertEquals("fire-and-forget: listener-B must receive despite A's NoClassDefFoundError", + 1, bReceived.size()); + } finally { + regA.unregister(); + regB.unregister(); + } + } + + /** + * Accessor variant of fire-and-forget isolation, with the published + * {@link AuditEventEmitter#emit} contract at stake: listener failures + * "never propagate back to the caller" — and {@code getDomain()} is + * listener code just like {@code onEvents()}. A listener whose + * {@code getDomain()} throws {@link LinkageError} (broken consumer-bundle + * classpath) must neither escape {@code emit()} into the calling write + * operation nor prevent the healthy listener from receiving the event. + */ + @Test + public void fireAndForgetBrokenGetDomainListenerDoesNotThrowToEmitter() { + AuditEventListener brokenDomain = new AuditEventListener() { + @Override public @NotNull String getDomain() { + throw new LinkageError("synthetic-getDomain"); + } + @Override public int getRank() { return 10; } // consulted first + @Override public void onEvents(@NotNull List events) { + fail("a listener with a broken getDomain() must never receive events"); + } + }; + Registration reg = whiteboard.register(AuditEventListener.class, brokenDomain, Map.of()); + try { + emitter.emit(eventFor(DOMAIN, "x", Map.of("key", "v"))); + assertEquals("healthy listener must receive despite peer's broken getDomain()", + 1, received.size()); + } finally { + reg.unregister(); + } + } + + /** + * Capture-gate variant: {@link AuditEventEmitter#isEnabledFor} — the + * probe capture sites consult BEFORE staging an event — must tolerate a + * broken listener too. Without the registry-level guard, the throwing + * {@code getDomain()} escapes through {@code BufferSink.isEnabledFor} + * into the capture site and fails the user-facing write operation. + */ + @Test + public void captureGateIsEnabledForToleratesBrokenListener() { + AuditEventListener brokenDomain = new AuditEventListener() { + @Override public @NotNull String getDomain() { + throw new LinkageError("synthetic-getDomain"); + } + @Override public void onEvents(@NotNull List events) { + fail("a listener with a broken getDomain() must never receive events"); + } + }; + Registration reg = whiteboard.register(AuditEventListener.class, brokenDomain, Map.of()); + try { + // Unserved domain first — the full registry scan must consult + // (and skip) the broken listener, never propagate its throw. + assertFalse("gate must return false (not throw) for an unserved domain", + emitter.isEnabledFor("no.such.domain")); + assertTrue("gate must find the healthy fixture listener despite the broken peer", + emitter.isEnabledFor(DOMAIN)); + } finally { + reg.unregister(); + } + } + + //------------------------< masquerade-prevention (sage invariant I8) >--- + + /** + * End-to-end verification of the design rule that audit never + * masquerades as a commit failure to the merge caller. + *

+ * A poisoned {@link AuditEvent} whose {@code getDomain()} throws on the + * SECOND call (i.e. at drain time, after capture-time + * {@code BufferSink.record} successfully consulted it) drives the + * {@link AuditDrainObserver} into its outer {@code catch (Throwable)} + * barrier. The barrier swallows the throw and logs WARN; the merge + * thread sees no exception and {@link Root#commit()} returns normally. + *

+ * Without the outer barrier, this throw would propagate out of + * {@code AuditDrainObserver.contentChanged} → through + * {@code CompositeObserver.contentChanged} (no per-observer isolation + * at {@code CompositeObserver.java:46-53}) → into the NodeStore impl's + * post-merge observer dispatch → surfacing as a RuntimeException to + * the merge caller despite the durable commit having succeeded. On + * DocumentNodeStore, the inner catch at + * {@code DocumentNodeStore.java:1130-1139} would even suppress + * unrelated commit-apply failures. + */ + @Test + public void poisonedEventDoesNotMaskCommitAsFailure() throws Exception { + LogCustomizer log = LogCustomizer.forLogger(AuditDrainObserver.class) + .enable(Level.WARN).create(); + log.starting(); + try { + // Counter-based poison: getDomain() returns DOMAIN on the FIRST + // call (BufferSink.record's isEnabledFor probe — must succeed so + // the event enters the buffer) and throws on all subsequent calls + // (groupByDomain at drain time). Models a producer-side bug that + // surfaces only at dispatch. + AtomicInteger calls = new AtomicInteger(); + AuditEvent counterPoison = new AuditEvent() { + @Override public @NotNull String getDomain() { + if (calls.incrementAndGet() <= 1) { + return DOMAIN; + } + throw new RuntimeException("synthetic-drain-time-poison"); + } + @Override public @NotNull String getType() { return "poison.type"; } + @Override public long getTimestamp() { return 0L; } + @Override public @NotNull Map getPayload() { + return Map.of(); + } + }; + + try (ContentSession session = login()) { + Root root = session.getLatestRoot(); + AuditEvents.record(root, counterPoison); + root.getTree("/").setProperty("scratch-masquerade", "v"); + + // The CORE assertion — Root.commit() MUST return normally. + // A regression that removed the outer barrier would surface + // the drain-time throw here as a CommitFailedException. + root.commit(); + + // Listener never received the poisoned event — groupByDomain + // threw before dispatch. + assertTrue("listener must not see the poisoned event", + received.isEmpty()); + + // WARN log fired exactly once with the session id for + // diagnostics. If this WARN ever fires in CI on a non-test + // path, treat as a bug — the barrier is a safety net for + // producer-side bugs, not a steady-state code path. + List logs = log.getLogs(); + assertEquals("outer Throwable barrier must log exactly one WARN line", + 1, logs.size()); + assertTrue("WARN must include session id; was: " + logs.get(0), + logs.get(0).contains(session.toString())); + } + } finally { + log.finished(); + } + } + + //---------------------------------------------< migration-path no-op >--- + + /** + * Migration commits — the path {@code RepositoryUpgrade.java:549} and + * {@code :569} use — drive {@code NodeStore.merge(...)} directly with + * {@link CommitInfo#EMPTY}, bypassing {@code MutableRoot}. None of the + * capture sites ({@code UserManagerImpl.recordSingleMembershipAuditEvent}, + * fire-and-forget {@link AuditEvents#dispatch}) are reached by such + * commits, so the per-session buffer remains empty for the migration's + * synthetic {@code CommitInfo.OAK_UNKNOWN} session id. + *

+ * The drain observer is invoked by the NodeStore (the migration commit + * succeeds) but its short-circuit at + * {@code AuditDrainObserver.doContentChanged} — {@code buffer.drain(sessionId)} + * returns {@code null}, drives no dispatch. Pins the migration-path + * no-op behaviour. + */ + @Test + public void directMergeWithEmptyCommitInfoProducesNoListenerCalls() throws Exception { + // Fresh MemoryNodeStore — the fixture's `store` is already wired + // to the singleton drain observer for the @Before-driven Oak. + // Using a separate Observable here keeps the test isolated and + // makes the migration semantic explicit: this store is being + // driven without MutableRoot in the picture. + MemoryNodeStore migrationStore = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + Closeable observerHandle = migrationStore.addObserver(auditConfig.getDrainObserver()); + try { + // Drive a direct merge — the RepositoryUpgrade pattern. + NodeBuilder builder = migrationStore.getRoot().builder(); + builder.setProperty("migration.marker", "test-value"); + migrationStore.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY); + + assertTrue("migration commit (CommitInfo.EMPTY) must produce no listener invocations", + received.isEmpty()); + } finally { + observerHandle.close(); + } + } + + //--------------------------------------------------< validator support >--- + + /** + * {@link ValidatorProvider} that injects a {@link Validator} which + * fails the commit when it observes a specific marker property added + * to the root. Used by {@link #commitFailureDiscardsStagedEvents()} + * to force a deterministic commit failure. + */ + private static final class ThrowingValidatorProvider extends ValidatorProvider { + + private final String triggerPropertyName; + + ThrowingValidatorProvider(@NotNull String triggerPropertyName) { + this.triggerPropertyName = triggerPropertyName; + } + + @NotNull + @Override + public Validator getRootValidator(NodeState before, NodeState after, + CommitInfo info) { + return new ThrowingValidator(triggerPropertyName); + } + } + + private static final class ThrowingValidator extends DefaultValidator { + + private final String triggerPropertyName; + + ThrowingValidator(@NotNull String triggerPropertyName) { + this.triggerPropertyName = triggerPropertyName; + } + + @Override + public void propertyAdded(PropertyState after) throws CommitFailedException { + if (triggerPropertyName.equals(after.getName())) { + throw new CommitFailedException(CommitFailedException.CONSTRAINT, 1, + "Injected validator failure: " + triggerPropertyName); + } + } + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java new file mode 100644 index 00000000000..3cb92155328 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.io.Closeable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.jcr.SimpleCredentials; +import javax.security.auth.login.Configuration; + +import org.apache.jackrabbit.api.security.user.Group; +import org.apache.jackrabbit.api.security.user.User; +import org.apache.jackrabbit.api.security.user.UserManager; +import org.apache.jackrabbit.oak.InitialContentHelper; +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.security.audit.AuditConfigurationImpl; +import org.apache.jackrabbit.oak.api.ContentRepository; +import org.apache.jackrabbit.oak.api.ContentSession; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.namepath.NamePathMapper; +import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; +import org.apache.jackrabbit.oak.spi.security.SecurityProvider; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; +import org.apache.jackrabbit.oak.spi.security.authentication.ConfigurationUtil; +import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * End-to-end integration test for the AUDIT-SPI production wiring path. + *

+ * Unlike {@link AuditPipelineTest} which records events directly via + * {@code AuditEvents.record}, this test exercises the path that real Oak + * consumers traverse: + *

    + *
  1. JCR {@link UserManager#createGroup(String)} → {@link Group#addMember(org.apache.jackrabbit.api.security.user.Authorizable)}.
  2. + *
  3. {@code UserManagerImpl.recordSingleMembershipAuditEvent} → + * {@code AuditEvents.record(root, UserAuditEvents.memberAdded(...))}.
  4. + *
  5. {@code AuditDrainObserver} (fires on commit success) → the registered + * listener.
  6. + *
+ * Asserts the entire chain: capture-site → buffer → observer drain → + * decorator → listener. + */ +public class AuditWiringTest { + + private Whiteboard whiteboard; + private AuditConfigurationImpl auditConfig; + private Closeable drainObserverSubscription; + private List received; + private ContentRepository repository; + private SecurityProvider securityProvider; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + received = new CopyOnWriteArrayList<>(); + + auditConfig = new AuditConfigurationImpl(); + // initialize() installs sinks/registry/buffer/toggle. The drain Observer + // is attached to the MemoryNodeStore directly below; we can't rely on + // Oak.with(Observer)'s auto-attach because .with(whiteboard) replaces + // Oak's default whiteboard and bypasses the auto-attach at + // Oak.java:300-302. + auditConfig.initialize(whiteboard); + securityProvider = SecurityProviderBuilder.newBuilder() + .withWhiteboard(whiteboard) + .build(); + + Configuration.setConfiguration( + ConfigurationUtil.getDefaultConfiguration(ConfigurationParameters.EMPTY)); + + setToggle(true); + + // Listener for the security domain — that's where the member-added event lands. + AuditEventListener securityListener = new AuditEventListener() { + @Override public @NotNull String getDomain() { return SecurityAuditDomain.NAME; } + @Override public void onEvents(@NotNull List events) { + received.addAll(events); + } + }; + whiteboard.register(AuditEventListener.class, securityListener, Map.of()); + + MemoryNodeStore store = new MemoryNodeStore(InitialContentHelper.INITIAL_CONTENT); + drainObserverSubscription = store.addObserver(auditConfig.getDrainObserver()); + + repository = new Oak(store) + .with(securityProvider) + .with(whiteboard) + .createContentRepository(); + } + + @After + public void tearDown() throws Exception { + try { + if (drainObserverSubscription != null) { + drainObserverSubscription.close(); + } + if (auditConfig != null) { + auditConfig.dispose(); + } + if (repository instanceof Closeable) { + ((Closeable) repository).close(); + } + } finally { + Configuration.setConfiguration(null); + } + } + + private void setToggle(boolean enabled) { + Tracker toggleTracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : toggleTracker.getServices()) { + if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + toggleTracker.stop(); + } + } + + private ContentSession adminLogin() throws Exception { + return repository.login(new SimpleCredentials("admin", "admin".toCharArray()), null); + } + + private UserManager userManager(@NotNull Root root) { + return securityProvider.getConfiguration(UserConfiguration.class) + .getUserManager(root, NamePathMapper.DEFAULT); + } + + /** + * The capture-site in {@code UserManagerImpl.addMember} fires an audit + * event with domain {@link SecurityAuditDomain#NAME} and type + * {@link UserAuditTypes#MEMBER_ADDED} on successful group + * update; the event must traverse the entire pipeline to the + * registered listener with the commit metadata decorated. + */ + @Test + public void groupAddMemberFiresUserMemberAddedEndToEnd() throws Exception { + try (ContentSession session = adminLogin()) { + Root root = session.getLatestRoot(); + UserManager um = userManager(root); + + Group testGroup = um.createGroup("auditTestGroup"); + User testUser = um.createUser("auditTestUser", "pwd"); + root.commit(); + // The createGroup/createUser commits above do not emit member + // events. Reset received and exercise addMember below. + received.clear(); + + // Re-fetch from a fresh root post-commit. + root = session.getLatestRoot(); + um = userManager(root); + testGroup = (Group) um.getAuthorizable("auditTestGroup"); + testUser = (User) um.getAuthorizable("auditTestUser"); + assertNotNull(testGroup); + assertNotNull(testUser); + String groupPath = testGroup.getPath(); + String memberId = testUser.getID(); + String memberPath = testUser.getPath(); + + assertTrue("addMember must succeed", testGroup.addMember(testUser)); + root.commit(); + + // Exactly one membership.added event must have traversed the + // entire pipeline. + assertEquals("exactly one member-added audit event must arrive", + 1, received.size()); + AuditEvent event = received.get(0); + assertEquals(SecurityAuditDomain.NAME, event.getDomain()); + assertEquals(UserAuditTypes.MEMBER_ADDED, event.getType()); + + Map payload = event.getPayload(); + // Commit metadata decorated by AuditDrainObserver (via CommitMetadataDecorator). + assertTrue("commit.sessionId must be decorated", + payload.containsKey("commit.sessionId")); + assertTrue("commit.userId must be decorated", + payload.containsKey("commit.userId")); + assertTrue("commit.timestamp must be decorated", + payload.containsKey("commit.timestamp")); + // Event-specific payload — values, not just key presence, + // so a future refactor that left the keys but lost the values + // (e.g. wrong getPath() variable in the capture site) is caught. + assertEquals(groupPath, payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(List.of(memberId), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(memberPath), payload.get(UserAuditTypes.PAYLOAD_MEMBER_PATHS)); + } + } + + /** + * Symmetric to {@link #groupAddMemberFiresUserMemberAddedEndToEnd()}: + * the capture-site in {@code UserManagerImpl.removeMember} fires an + * audit event with type + * {@link UserAuditTypes#MEMBER_REMOVED} on successful group + * update. Exercises the {@code isRemove=true} branch of + * {@code recordSingleMembershipAuditEvent} end-to-end through the + * entire pipeline. + */ + @Test + public void groupRemoveMemberFiresUserMemberRemovedEndToEnd() throws Exception { + try (ContentSession session = adminLogin()) { + Root root = session.getLatestRoot(); + UserManager um = userManager(root); + + // Setup: create group + user, add user as member, commit. + Group testGroup = um.createGroup("auditRemoveGroup"); + User testUser = um.createUser("auditRemoveUser", "pwd"); + root.commit(); + root = session.getLatestRoot(); + um = userManager(root); + testGroup = (Group) um.getAuthorizable("auditRemoveGroup"); + testUser = (User) um.getAuthorizable("auditRemoveUser"); + assertNotNull(testGroup); + assertNotNull(testUser); + assertTrue("addMember setup must succeed", testGroup.addMember(testUser)); + root.commit(); + + // Clear received — the setup-commit emits membership.added, + // not the event we want to pin here. + received.clear(); + root = session.getLatestRoot(); + um = userManager(root); + testGroup = (Group) um.getAuthorizable("auditRemoveGroup"); + testUser = (User) um.getAuthorizable("auditRemoveUser"); + assertNotNull(testGroup); + assertNotNull(testUser); + String groupPath = testGroup.getPath(); + String memberId = testUser.getID(); + String memberPath = testUser.getPath(); + + // Act: remove the member and commit. + assertTrue("removeMember must succeed", testGroup.removeMember(testUser)); + root.commit(); + + assertEquals("exactly one member-removed audit event must arrive", + 1, received.size()); + AuditEvent event = received.get(0); + assertEquals(SecurityAuditDomain.NAME, event.getDomain()); + assertEquals(UserAuditTypes.MEMBER_REMOVED, event.getType()); + + Map payload = event.getPayload(); + assertTrue("commit.sessionId must be decorated", + payload.containsKey("commit.sessionId")); + assertEquals(groupPath, payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(List.of(memberId), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(memberPath), payload.get(UserAuditTypes.PAYLOAD_MEMBER_PATHS)); + } + } + + /** + * With the feature toggle disabled, the capture-site + * {@code AuditEvents.isEnabled()} check in + * {@code UserManagerImpl.recordSingleMembershipAuditEvent} short-circuits; + * no event is delivered even though the group update succeeds. + */ + @Test + public void toggleDisabledSkipsCaptureSite() throws Exception { + setToggle(false); + try (ContentSession session = adminLogin()) { + Root root = session.getLatestRoot(); + UserManager um = userManager(root); + Group testGroup = um.createGroup("auditOffGroup"); + User testUser = um.createUser("auditOffUser", "pwd"); + root.commit(); + received.clear(); + + root = session.getLatestRoot(); + um = userManager(root); + testGroup = (Group) um.getAuthorizable("auditOffGroup"); + testUser = (User) um.getAuthorizable("auditOffUser"); + assertTrue(testGroup.addMember(testUser)); + root.commit(); + + assertTrue("no event must be delivered with toggle disabled", + received.isEmpty()); + } + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java new file mode 100644 index 00000000000..79b64a7c6ec --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java @@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.event.Level; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class CommitMetadataDecoratorTest { + + @Before + public void resetStripWarnLatch() { + // The strip WARN is once-per-JVM; reset so each test observes a + // deterministic latch state regardless of suite ordering. + CommitMetadataDecorator.STRIP_WARNED.set(false); + } + + private static AuditEvent original(@NotNull String domain, @NotNull String type, @NotNull Map payload) { + return new AuditEvent() { + @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull String getType() { return type; } + @Override public long getTimestamp() { return 12345L; } + @Override public @NotNull Map getPayload() { return payload; } + }; + } + + @Test + public void decoratesPayloadWithCommitMetadata() { + AuditEvent in = original("oak.security", "member.added", Map.of("group", "/g", "member", "/m")); + CommitInfo info = new CommitInfo("session-1", "alice", Map.of(), false); + List out = CommitMetadataDecorator.decorate(List.of(in), info); + assertEquals(1, out.size()); + AuditEvent decorated = out.get(0); + Map p = decorated.getPayload(); + assertEquals("session-1", p.get("commit.sessionId")); + assertEquals("alice", p.get("commit.userId")); + assertTrue(p.containsKey("commit.timestamp")); + // original payload entries preserved + assertEquals("/g", p.get("group")); + assertEquals("/m", p.get("member")); + // original event passed in not mutated + assertEquals(Map.of("group", "/g", "member", "/m"), in.getPayload()); + assertNotSame(in, decorated); + } + + @Test + public void emptyEventsReturnsEmpty() { + CommitInfo info = new CommitInfo("session-1", "alice", Map.of(), false); + // Semantic check, not identity check — couples to behavior, not impl. + assertTrue(CommitMetadataDecorator.decorate(Collections.emptyList(), info).isEmpty()); + } + + @Test + public void preservesDomainTypeTimestamp() { + AuditEvent in = original("aem.content", "fragment.published", Map.of("path", "/p")); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + assertEquals("aem.content", decorated.getDomain()); + assertEquals("fragment.published", decorated.getType()); + assertEquals(12345L, decorated.getTimestamp()); + } + + @Test + public void systemCommitUserIdIsOakUnknown() { + AuditEvent in = original("oak.security", "system.event", Map.of()); + // CommitInfo with null userId resolves to OAK_UNKNOWN + CommitInfo info = CommitInfo.EMPTY; + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + // CommitInfo.OAK_UNKNOWN exposed via getUserId() for empty/system commits + assertEquals(CommitInfo.OAK_UNKNOWN, decorated.getPayload().get("commit.userId")); + } + + @Test + public void preservesOrderOfEvents() { + AuditEvent a = original("oak.security", "a", Map.of()); + AuditEvent b = original("oak.security", "b", Map.of()); + AuditEvent c = original("oak.security", "c", Map.of()); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + List out = CommitMetadataDecorator.decorate(asList(a, b, c), info); + assertEquals("a", out.get(0).getType()); + assertEquals("b", out.get(1).getType()); + assertEquals("c", out.get(2).getType()); + } + + //--------------------------------------------< overwrite invariant tests >--- + // Security-critical regression guards. The "the three reserved commit.* + // keys are present iff the event came from Oak's commit-attached + // pipeline" trust-model property (normative statement on + // AuditEvent#getPayload(); see also audit-design.md) rests on + // TWO halves: the decorator UNCONDITIONALLY overwriting caller-supplied + // values on the commit path (pinned here) and the dispatch-time strip on + // the fire-and-forget path (pinned in the strip section below and + // end-to-end in AuditPipelineTest). A future refactor that swaps .put() + // for .putIfAbsent() / contains-check would silently let bundles spoof + // commit identity in audit logs. Each test pins one independent property + // a single-line regression could break. + + @Test + public void decoratorOverwritesCallerProvidedSessionId() { + AuditEvent in = original("oak.security", "x", + Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "spoofed-session")); + CommitInfo info = new CommitInfo("real-session", "alice", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + assertEquals("real-session", decorated.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID)); + assertNotEquals("spoofed value must not survive decoration", + "spoofed-session", decorated.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID)); + } + + @Test + public void decoratorOverwritesCallerProvidedUserId() { + AuditEvent in = original("oak.security", "x", + Map.of(CommitMetadataDecorator.KEY_USER_ID, "admin")); + CommitInfo info = new CommitInfo("s", "alice", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + assertEquals("alice", decorated.getPayload().get(CommitMetadataDecorator.KEY_USER_ID)); + assertNotEquals("spoofed userId must not survive decoration", + "admin", decorated.getPayload().get(CommitMetadataDecorator.KEY_USER_ID)); + } + + @Test + public void decoratorOverwritesCallerProvidedTimestamp() { + AuditEvent in = original("oak.security", "x", + Map.of(CommitMetadataDecorator.KEY_TIMESTAMP, 99999999L)); + // CommitInfo's date is set internally to System.currentTimeMillis() + // at construction; we read the actual value via getDate() to + // compare against the decorated payload. + CommitInfo info = new CommitInfo("s", "alice", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + assertEquals(info.getDate(), decorated.getPayload().get(CommitMetadataDecorator.KEY_TIMESTAMP)); + assertNotEquals("spoofed timestamp must not survive decoration", + 99999999L, decorated.getPayload().get(CommitMetadataDecorator.KEY_TIMESTAMP)); + } + + /** + * Type-variance check: caller submits {@code commit.timestamp} as a + * {@code String}, decorator overwrites with the real {@code long}. + * Guards against future "type-aware merge" pseudo-smartening that + * would skip the overwrite when types differ. + */ + @Test + public void decoratorOverwritesAcrossValueTypes() { + AuditEvent in = original("oak.security", "x", + Map.of(CommitMetadataDecorator.KEY_TIMESTAMP, "definitely-a-string-not-a-long")); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + Object timestamp = decorated.getPayload().get(CommitMetadataDecorator.KEY_TIMESTAMP); + // CommitInfo.getDate() returns long; merged.put(..., commitTimestamp) + // auto-boxes to Long. The original String value is gone. + assertTrue("timestamp must be Long, not the caller-provided String", + timestamp instanceof Long); + } + + /** + * Trust-contract regression (inverse form): when a single caller-supplied + * payload spoofs ALL THREE Oak-attested keys at once + * ({@code commit.sessionId}, {@code commit.userId}, {@code commit.timestamp}), + * the decorator overwrites every one of them with the {@code CommitInfo} + * values. Pins the security property documented on + * {@link org.apache.jackrabbit.oak.spi.audit.AuditEvent#getPayload()}: + * exactly these three keys are Oak-attested and cannot be forged by the + * caller. Complements the per-key overwrite tests above by proving all + * three are protected within one event — a partial-overwrite regression + * that fixed only some keys would slip past single-key tests. + */ + @Test + public void decoratorOverwritesAllThreeOakAttestedKeysSimultaneously() { + AuditEvent in = original("oak.security", "x", Map.of( + CommitMetadataDecorator.KEY_SESSION_ID, "spoofed-session", + CommitMetadataDecorator.KEY_USER_ID, "spoofed-user", + CommitMetadataDecorator.KEY_TIMESTAMP, 1L)); + CommitInfo info = new CommitInfo("real-session", "real-user", Map.of(), false); + Map p = CommitMetadataDecorator.decorate(List.of(in), info).get(0).getPayload(); + + assertEquals("real-session", p.get(CommitMetadataDecorator.KEY_SESSION_ID)); + assertEquals("real-user", p.get(CommitMetadataDecorator.KEY_USER_ID)); + assertEquals(info.getDate(), p.get(CommitMetadataDecorator.KEY_TIMESTAMP)); + assertNotEquals("spoofed-session", p.get(CommitMetadataDecorator.KEY_SESSION_ID)); + assertNotEquals("spoofed-user", p.get(CommitMetadataDecorator.KEY_USER_ID)); + assertNotEquals(1L, p.get(CommitMetadataDecorator.KEY_TIMESTAMP)); + } + + /** + * Pins the negative half of the trust contract: a {@code commit.*} key + * that is NOT one of the three Oak-attested keys is forwarded verbatim + * from the caller (it is untrusted). A listener must not treat the + * {@code commit.} prefix as a blanket attestation — only the three named + * keys are protected. See + * {@link org.apache.jackrabbit.oak.spi.audit.AuditEvent#getPayload()}. + */ + @Test + public void decoratorDoesNotProtectOtherCommitPrefixedKeys() { + AuditEvent in = original("oak.security", "x", Map.of("commit.custom", "caller-supplied")); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + Map p = CommitMetadataDecorator.decorate(List.of(in), info).get(0).getPayload(); + // The three Oak-attested keys are added/overwritten... + assertEquals("s", p.get(CommitMetadataDecorator.KEY_SESSION_ID)); + // ...but an arbitrary commit.* key is left exactly as the caller set it. + assertEquals("caller-supplied", p.get("commit.custom")); + } + + /** + * Symmetric to the overwrite tests: when the input payload omits the + * commit.* keys entirely, the decorator ADDS them. A regression that + * turned {@code .put()} into {@code if (containsKey) .put()} would + * pass the overwrite-when-present tests but break add-when-absent. + * Mixed coverage with {@link #decoratesPayloadWithCommitMetadata()} + * is insufficient — that test conflates the add-when-absent half + * with non-commit-key preservation. + */ + @Test + public void decoratorAddsCommitKeysWhenAbsent() { + AuditEvent in = original("oak.security", "x", Map.of()); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + assertTrue(decorated.getPayload().containsKey(CommitMetadataDecorator.KEY_SESSION_ID)); + assertTrue(decorated.getPayload().containsKey(CommitMetadataDecorator.KEY_USER_ID)); + assertTrue(decorated.getPayload().containsKey(CommitMetadataDecorator.KEY_TIMESTAMP)); + assertEquals("s", decorated.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID)); + assertEquals("u", decorated.getPayload().get(CommitMetadataDecorator.KEY_USER_ID)); + } + + @Test + public void decoratedPayloadIsUnmodifiable() { + AuditEvent in = original("oak.security", "x", Map.of("k", "v")); + CommitInfo info = new CommitInfo("s", "u", Map.of(), false); + AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); + try { + decorated.getPayload().put("newkey", "newvalue"); + fail("decorated payload must be unmodifiable — caller mutation must throw"); + } catch (UnsupportedOperationException expected) { + // expected — Collections.unmodifiableMap wrapping + } + } + + //-----------------------------------< reserved-key strip (f-a-f path) >--- + // stripReservedCommitKeys is the fire-and-forget counterpart of the + // overwrite invariant above: the commit-attached path OVERWRITES the + // three Oak-attested keys, the fire-and-forget path STRIPS them. Both + // enforce the same trust contract on AuditEvent#getPayload(): a + // dispatched payload carries those keys iff Oak put them there. + + @Test + public void stripRemovesAllThreeReservedKeysAndKeepsTheRest() { + AuditEvent in = original("oak.security", "x", Map.of( + CommitMetadataDecorator.KEY_SESSION_ID, "forged-session", + CommitMetadataDecorator.KEY_USER_ID, "forged-user", + CommitMetadataDecorator.KEY_TIMESTAMP, 1L, + "commit.custom", "passenger", + "k", "v")); + AuditEvent stripped = CommitMetadataDecorator.stripReservedCommitKeys(in); + + Map p = stripped.getPayload(); + assertFalse(p.containsKey(CommitMetadataDecorator.KEY_SESSION_ID)); + assertFalse(p.containsKey(CommitMetadataDecorator.KEY_USER_ID)); + assertFalse(p.containsKey(CommitMetadataDecorator.KEY_TIMESTAMP)); + // Only the three reserved keys are stripped — commit.-prefixed + // passengers and ordinary entries are forwarded verbatim. + assertEquals("passenger", p.get("commit.custom")); + assertEquals("v", p.get("k")); + // Domain/type/capture-timestamp survive: the wrapper delegates, it + // does NOT rebuild via AuditEvent.of() (which would reset the + // timestamp to wall-clock now). + assertEquals("oak.security", stripped.getDomain()); + assertEquals("x", stripped.getType()); + assertEquals(12345L, stripped.getTimestamp()); + // Input event untouched. + assertEquals("forged-session", in.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID)); + } + + @Test + public void stripWithSingleReservedKeyStripsIt() { + AuditEvent in = original("oak.security", "x", Map.of( + CommitMetadataDecorator.KEY_USER_ID, "forged-user", + "k", "v")); + Map p = CommitMetadataDecorator.stripReservedCommitKeys(in).getPayload(); + assertFalse("any one reserved key must trigger the strip", + p.containsKey(CommitMetadataDecorator.KEY_USER_ID)); + assertEquals("v", p.get("k")); + } + + @Test + public void stripReturnsSameInstanceWhenNoReservedKeyPresent() { + AuditEvent in = original("oak.security", "x", Map.of("commit.custom", "passenger", "k", "v")); + assertSame("clean payloads must not be wrapped — concrete event type preserved", + in, CommitMetadataDecorator.stripReservedCommitKeys(in)); + } + + /** + * No silent data deletion: the first strip in the JVM logs a WARN + * naming the stripped keys plus the event's domain and type — and + * NEVER the forged values (they are attacker-controlled and would + * poison the log). Subsequent strips stay at DEBUG so a persistent + * emitter cannot use the pipeline as a WARN-flood vector. + */ + @Test + public void stripLogsWarnOnceWithKeyNamesButNeverValues() { + LogCustomizer log = LogCustomizer.forLogger(CommitMetadataDecorator.class) + .enable(Level.WARN).create(); + log.starting(); + try { + CommitMetadataDecorator.stripReservedCommitKeys(original("oak.security", "strip.warn.type", + Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "forged-session-value", "k", "v"))); + CommitMetadataDecorator.stripReservedCommitKeys(original("oak.security", "strip.other.type", + Map.of(CommitMetadataDecorator.KEY_USER_ID, "forged-user-value"))); + + List logs = log.getLogs(); + assertEquals("strip must WARN exactly once, then drop to DEBUG", 1, logs.size()); + String warn = logs.get(0); + assertTrue("WARN must name the stripped key; was: " + warn, + warn.contains(CommitMetadataDecorator.KEY_SESSION_ID)); + assertTrue("WARN must name the event domain; was: " + warn, + warn.contains("oak.security")); + assertTrue("WARN must name the event type; was: " + warn, + warn.contains("strip.warn.type")); + assertFalse("WARN must never echo the forged value; was: " + warn, + warn.contains("forged-session-value")); + } finally { + log.finished(); + } + } + + @Test + public void strippedPayloadIsUnmodifiable() { + // Mutable input payload on purpose: a stub that returned the input + // event (or its map) unchanged would let the put() succeed. + Map mutable = new HashMap<>(); + mutable.put(CommitMetadataDecorator.KEY_SESSION_ID, "forged"); + mutable.put("k", "v"); + AuditEvent stripped = CommitMetadataDecorator.stripReservedCommitKeys( + original("oak.security", "x", mutable)); + try { + stripped.getPayload().put("newkey", "newvalue"); + fail("stripped payload must be unmodifiable — caller mutation must throw"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + /** + * TOCTOU regression: the strip consults {@code getPayload()} EXACTLY + * once and snapshots the filtered result eagerly in the wrapper + * constructor (mirrors {@code DecoratedAuditEvent}). {@link AuditEvent} + * is directly implementable, so a hostile implementation could return a + * clean map when checked and a forged one when the listener later reads + * it — lazy filtering or a second consult would re-open the hole this + * strip closes. + */ + @Test + public void stripSnapshotsPayloadEagerlyFromSingleConsult() { + AtomicInteger consults = new AtomicInteger(); + AuditEvent hostile = new AuditEvent() { + @Override public @NotNull String getDomain() { return "oak.security"; } + @Override public @NotNull String getType() { return "x"; } + @Override public long getTimestamp() { return 12345L; } + @Override public @NotNull Map getPayload() { + if (consults.incrementAndGet() == 1) { + return Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "forged", "k", "v"); + } + return Map.of("toctou.marker", "swapped-after-check"); + } + }; + AuditEvent stripped = CommitMetadataDecorator.stripReservedCommitKeys(hostile); + assertEquals("strip must consult the delegate payload exactly once", + 1, consults.get()); + + Map p = stripped.getPayload(); + assertEquals("reading the stripped payload must not re-consult the delegate", + 1, consults.get()); + assertFalse(p.containsKey(CommitMetadataDecorator.KEY_SESSION_ID)); + assertFalse("payload swapped in after the check must be invisible", + p.containsKey("toctou.marker")); + assertEquals("v", p.get("k")); + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java new file mode 100644 index 00000000000..3e217f1c350 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java @@ -0,0 +1,379 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Registration; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.jetbrains.annotations.NotNull; +import org.junit.Test; +import org.slf4j.event.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class WhiteboardAuditEventListenerRegistryTest { + + private static final class StubListener implements AuditEventListener { + private final String domain; + private final int rank; + StubListener(String domain, int rank) { + this.domain = domain; + this.rank = rank; + } + @Override public @NotNull String getDomain() { return domain; } + @Override public int getRank() { return rank; } + @Override public void onEvents(@NotNull List events) { /* not exercised here */ } + } + + @Test + public void emptyByDefault() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + assertEquals(0, reg.getListeners().size()); + assertFalse(reg.hasAnyListener()); + assertFalse(reg.hasListenerFor("oak.security")); + } finally { + reg.stop(); + } + } + + @Test + public void registeredListenerIsListed() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); + assertEquals(1, reg.getListeners().size()); + assertTrue(reg.hasAnyListener()); + assertTrue(reg.hasListenerFor("oak.security")); + } finally { + reg.stop(); + } + } + + @Test + public void listenersSortedByRankDescending() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); + wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener("d", 5), Map.of()); + List sorted = reg.getListeners(); + assertEquals(3, sorted.size()); + assertEquals(10, sorted.get(0).getRank()); + assertEquals(5, sorted.get(1).getRank()); + assertEquals(1, sorted.get(2).getRank()); + } finally { + reg.stop(); + } + } + + /** + * Pins the live-lookup contract — {@code hasListenerFor} must reflect + * the current Whiteboard state, not a snapshot taken at start time. + * Regression guard for the stale-cache bug fixed in commit + * {@code 3a60d60309}. + */ + @Test + public void hasListenerForReflectsLiveRegistrations() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + // First call when no listener for "oak.security" exists. + assertFalse(reg.hasListenerFor("oak.security")); + // Register and re-check — must observe the new registration. + wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); + assertTrue(reg.hasListenerFor("oak.security")); + // Different domain must still return false. + assertFalse(reg.hasListenerFor("aem.content")); + } finally { + reg.stop(); + } + } + + /** + * Listener ordering with a mix of distinct and equal ranks: + *
    + *
  • Strict descending order where ranks differ — the highest-rank + * listener comes first, the lowest-rank last.
  • + *
  • Equal-rank entries appear as a contiguous block; their internal + * order is determined by the underlying {@link Whiteboard} (and + * must be stable across repeat {@code getListeners()} calls per + * the registry Javadoc).
  • + *
+ * Note: {@link DefaultWhiteboard} stores services in an identity-hash + * set, so it does NOT preserve registration order for equal-rank + * entries — only OSGi's {@code OsgiWhiteboard} honors registration + * order via {@code service.ranking}. This test therefore asserts set + * equality (not list equality) on the equal-rank block, plus + * determinism on repeat calls. + */ + @Test + public void stableOrderAmongEqualRanks() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + StubListener high = new StubListener("d", 10); + StubListener midA = new StubListener("d", 5); + StubListener midB = new StubListener("d", 5); + StubListener midC = new StubListener("d", 5); + StubListener low = new StubListener("d", 1); + wb.register(AuditEventListener.class, high, Map.of()); + wb.register(AuditEventListener.class, midA, Map.of()); + wb.register(AuditEventListener.class, midB, Map.of()); + wb.register(AuditEventListener.class, midC, Map.of()); + wb.register(AuditEventListener.class, low, Map.of()); + + List sorted = reg.getListeners(); + assertEquals(5, sorted.size()); + + // Strict ordering where ranks differ. + assertSame("highest rank must be first", high, sorted.get(0)); + assertSame("lowest rank must be last", low, sorted.get(4)); + + // Equal-rank entries (rank 5) form a contiguous block in + // positions 1..3 — set equality, not list equality, because + // DefaultWhiteboard does not preserve registration order. + Set middle = Set.copyOf(sorted.subList(1, 4)); + assertEquals("middle three positions must hold all rank-5 entries", + Set.of(midA, midB, midC), middle); + + // Stable sort: repeat call must return identical order. An + // unstable sort would reorder the equal-rank entries on the + // second call even with the same input. + assertEquals("stable sort — repeat call returns identical order", + sorted, reg.getListeners()); + } finally { + reg.stop(); + } + } + + //------------------------< broken-listener accessor isolation >------- + + /** + * Listener whose {@code getDomain()} throws — models a consumer bundle + * with a broken classpath ({@link LinkageError} is exactly what a + * missing transitive dependency produces at first call). + */ + private static final class ThrowingDomainListener implements AuditEventListener { + @Override public @NotNull String getDomain() { + throw new LinkageError("synthetic-getDomain"); + } + @Override public int getRank() { return 100; } + @Override public void onEvents(@NotNull List events) { + fail("a listener with a broken getDomain() must never receive events"); + } + } + + /** + * Listener whose {@code getRank()} throws while {@code getDomain()} + * works — exercises the rank-snapshot guard in {@code getListeners()} + * independently of the domain guard. + */ + private static final class ThrowingRankListener implements AuditEventListener { + private final String domain; + ThrowingRankListener(String domain) { + this.domain = domain; + } + @Override public @NotNull String getDomain() { return domain; } + @Override public int getRank() { + throw new RuntimeException("synthetic-getRank"); + } + @Override public void onEvents(@NotNull List events) { + fail("a listener with a broken getRank() must never receive events"); + } + } + + /** + * {@code getDomain()}/{@code getRank()} are listener code just like + * {@code onEvents()} — the per-listener isolation barrier documented on + * {@link AuditEventListener} must cover them too. A lone broken + * listener must make {@code hasListenerFor} return {@code false}, not + * propagate the {@link LinkageError} into the capture gate. + */ + @Test + public void hasListenerForToleratesThrowingGetDomain() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); + assertFalse("broken listener must be skipped, not propagated", + reg.hasListenerFor("oak.security")); + } finally { + reg.stop(); + } + } + + /** + * A broken peer must not mask a healthy listener: the registry skips + * the throwing listener and keeps scanning. + */ + @Test + public void hasListenerForFindsHealthyListenerDespiteBrokenPeer() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); + wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); + assertTrue("healthy listener must be found despite broken peer", + reg.hasListenerFor("oak.security")); + } finally { + reg.stop(); + } + } + + /** + * A throwing {@code getRank()} must exclude that listener from the + * sorted view instead of blowing up the sort — the rank comparator + * would otherwise rethrow from {@code List.sort} and abort dispatch + * for every listener. + */ + @Test + public void getListenersSkipsListenerWhoseGetRankThrows() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new ThrowingRankListener("d"), Map.of()); + wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); + List out = reg.getListeners(); + assertEquals("broken-rank listener must be skipped", 2, out.size()); + assertEquals(10, out.get(0).getRank()); + assertEquals(1, out.get(1).getRank()); + } finally { + reg.stop(); + } + } + + /** + * The skip semantics must not depend on listener count: a broken + * listener that happens to be the only registration must be skipped + * too, not returned unvetted through a single-element fast path. + */ + @Test + public void getListenersSkipsThrowingGetRankEvenWhenAlone() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new ThrowingRankListener("d"), Map.of()); + assertTrue("a lone broken listener must be skipped, not returned", + reg.getListeners().isEmpty()); + } finally { + reg.stop(); + } + } + + /** + * Skipping a broken listener is logged at WARN exactly once per + * listener identity across all registry methods — repeated capture-gate + * polling must not flood the log on behalf of a broken bundle. + */ + @Test + public void brokenListenerIsLoggedAtWarnOncePerListener() { + LogCustomizer logs = LogCustomizer + .forLogger(WhiteboardAuditEventListenerRegistry.class.getName()) + .enable(Level.WARN) + .create(); + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + logs.starting(); + try { + wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); + reg.hasListenerFor("oak.security"); + reg.hasListenerFor("oak.security"); + reg.getListeners(); + assertEquals("exactly one WARN per broken listener identity", + 1, logs.getLogs().size()); + } finally { + logs.finished(); + reg.stop(); + } + } + + /** + * {@code getListeners()} promises an immutable snapshot in its Javadoc; + * the multi-listener (sorted) path must honor it like the other paths. + */ + @Test + public void getListenersReturnsImmutableList() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); + List out = reg.getListeners(); + try { + out.add(new StubListener("d", 0)); + fail("getListeners() must return an immutable list"); + } catch (UnsupportedOperationException expected) { + // contract honored + } + } finally { + reg.stop(); + } + } + + /** + * Unregistering a listener via the {@link Registration#unregister()} + * handle must remove it from {@link WhiteboardAuditEventListenerRegistry#getListeners()} + * and from {@link WhiteboardAuditEventListenerRegistry#hasListenerFor(String)}. + */ + @Test + public void unregisterRemovesListener() { + Whiteboard wb = new DefaultWhiteboard(); + WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); + reg.start(wb); + try { + Registration r = wb.register(AuditEventListener.class, + new StubListener("oak.security", 0), Map.of()); + assertEquals(1, reg.getListeners().size()); + assertTrue(reg.hasListenerFor("oak.security")); + + r.unregister(); + + assertEquals(0, reg.getListeners().size()); + assertFalse(reg.hasListenerFor("oak.security")); + assertFalse(reg.hasAnyListener()); + } finally { + reg.stop(); + } + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java index 677106825a5..dd516f05aa7 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java @@ -210,6 +210,10 @@ public void testActivateWithoutPreconditions() { SecurityProvider service = context.getService(SecurityProvider.class); assertNotNull(service); + // 6 SecurityConfigurations: authentication, authorization, user, + // privilege, principal, token. Audit is not a SecurityConfiguration — + // it's owned by AuditConfigurationImpl and registered separately as + // an Observer service. assertEquals(6, IterableUtils.size(IterableUtils.filter(service.getConfigurations(), x -> x != null))); } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java new file mode 100644 index 00000000000..cc5b6e49398 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.user; + +import java.lang.reflect.Constructor; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +public class UserAuditEventsTest { + + private static final String GROUP = "/rep:security/groups/g1"; + private static final String MEMBER = "/rep:security/users/u1"; + private static final String MEMBER_ID = "u1"; + + //-------------------------------------------------< memberAdded / Removed >--- + + @Test + public void memberAddedReturnsExpectedDomainTypeAndPayload() { + AuditEvent e = UserAuditEvents.memberAdded(GROUP, MEMBER_ID, MEMBER); + + assertNotNull(e); + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); + assertEquals( + Map.of( + UserAuditTypes.PAYLOAD_GROUP_PATH, GROUP, + UserAuditTypes.PAYLOAD_MEMBER_IDS, List.of(MEMBER_ID), + UserAuditTypes.PAYLOAD_MEMBER_PATHS, List.of(MEMBER), + UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE, UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + UserAuditTypes.PAYLOAD_IS_CONTENT_ID, Boolean.FALSE), + e.getPayload()); + } + + @Test + public void memberRemovedReturnsExpectedDomainTypeAndPayload() { + AuditEvent e = UserAuditEvents.memberRemoved(GROUP, MEMBER_ID, MEMBER); + + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); + assertEquals( + Map.of( + UserAuditTypes.PAYLOAD_GROUP_PATH, GROUP, + UserAuditTypes.PAYLOAD_MEMBER_IDS, List.of(MEMBER_ID), + UserAuditTypes.PAYLOAD_MEMBER_PATHS, List.of(MEMBER), + UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE, UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + UserAuditTypes.PAYLOAD_IS_CONTENT_ID, Boolean.FALSE), + e.getPayload()); + } + + //---------------------------------------< membersAddedBulk / RemovedBulk >--- + + @Test + public void membersAddedBulkCarriesMemberIdsAndFailedIds() { + Set members = Set.of("m1", "m2", "m3"); + Set failed = Set.of("bad-id"); + AuditEvent e = UserAuditEvents.membersAddedBulk(GROUP, members, false, failed); + + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); + assertEquals(GROUP, e.getPayload().get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + e.getPayload().get(UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE)); + assertEquals(Boolean.FALSE, e.getPayload().get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + + // Payload uses List (insertion-order, serializer-friendly). + // Set semantics preserved via Set.copyOf below. + @SuppressWarnings("unchecked") + List payloadMembers = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_MEMBER_IDS); + @SuppressWarnings("unchecked") + List payloadFailed = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_FAILED_IDS); + assertEquals(members, Set.copyOf(payloadMembers)); + assertEquals(failed, Set.copyOf(payloadFailed)); + } + + @Test + public void membersAddedBulkRespectsIsContentIdFlag() { + AuditEvent e = UserAuditEvents.membersAddedBulk(GROUP, Set.of("uuid"), true, Set.of()); + assertEquals(Boolean.TRUE, e.getPayload().get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + } + + @Test + public void membersAddedBulkSupportsEmptyFailedIds() { + AuditEvent e = UserAuditEvents.membersAddedBulk(GROUP, Set.of("m1"), false, Set.of()); + @SuppressWarnings("unchecked") + List failed = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_FAILED_IDS); + assertEquals(List.of(), failed); + } + + @Test + public void membersAddedBulkRejectsEmptyMemberIds() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> UserAuditEvents.membersAddedBulk(GROUP, Set.of(), false, Set.of())); + assertEquals("memberIds must not be empty", ex.getMessage()); + } + + @Test + public void membersAddedBulkDecouplesCallerSet() { + // Mutate the source after the factory call — payload must not reflect it. + Set mutable = new HashSet<>(); + mutable.add("m1"); + AuditEvent e = UserAuditEvents.membersAddedBulk(GROUP, mutable, false, Set.of()); + mutable.add("m2"); + + @SuppressWarnings("unchecked") + List payloadMembers = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_MEMBER_IDS); + assertEquals(List.of("m1"), payloadMembers); + } + + @Test + public void membersRemovedBulkCarriesMemberIdsAndFailedIds() { + Set members = Set.of("m1"); + Set failed = Set.of(); + AuditEvent e = UserAuditEvents.membersRemovedBulk(GROUP, members, true, failed); + + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); + assertEquals(GROUP, e.getPayload().get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(Boolean.TRUE, e.getPayload().get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + + @SuppressWarnings("unchecked") + List payloadMembers = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_MEMBER_IDS); + @SuppressWarnings("unchecked") + List payloadFailed = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_FAILED_IDS); + assertEquals(members, Set.copyOf(payloadMembers)); + assertEquals(List.of(), payloadFailed); + } + + @Test + public void membersRemovedBulkRejectsEmptyMemberIds() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> UserAuditEvents.membersRemovedBulk(GROUP, Set.of(), false, Set.of())); + assertEquals("memberIds must not be empty", ex.getMessage()); + } + + @Test + public void membersRemovedBulkDecouplesCallerSet() { + Set mutable = new HashSet<>(); + mutable.add("m1"); + AuditEvent e = UserAuditEvents.membersRemovedBulk(GROUP, mutable, false, Set.of()); + mutable.add("m2"); + + @SuppressWarnings("unchecked") + List payloadMembers = (List) e.getPayload().get(UserAuditTypes.PAYLOAD_MEMBER_IDS); + assertEquals(List.of("m1"), payloadMembers); + } + + //-------------------------------------------------< private constructor >--- + + @Test + public void privateConstructorIsReachableForCoverage() throws Exception { + Constructor ctor = UserAuditEvents.class.getDeclaredConstructor(); + ctor.setAccessible(true); + assertNotNull(ctor.newInstance()); + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java new file mode 100644 index 00000000000..3974a5ed955 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java @@ -0,0 +1,381 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.user; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.jcr.RepositoryException; + +import org.apache.jackrabbit.api.security.user.Authorizable; +import org.apache.jackrabbit.api.security.user.Group; +import org.apache.jackrabbit.api.security.user.User; +import org.apache.jackrabbit.oak.AbstractSecurityTest; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.slf4j.event.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Branch-coverage tests for the audit-event capture sites in + * {@link UserManagerImpl#onGroupUpdate}. + *

+ * End-to-end audit dispatch (commit-attached drain + listener invocation) + * is exercised by {@code AuditWiringTest}/{@code AuditPipelineTest} which build + * a SecurityProvider with audit hooks wired into the commit chain. Those + * tests live in {@code org.apache.jackrabbit.oak.security.audit}, so they do + * not satisfy the unit-test coverage gate that applies to + * {@code org.apache.jackrabbit.oak.security.user}. + *

+ * This test installs a stub {@link AuditEvents.Sink} so the capture sites + * exercise their on-path branches (toggle-on, isRemove true/false, single + * vs bulk, RepositoryException catch) directly. No commit hooks needed. + *

+ * The sink captures the actual {@link AuditEvent} instances — every "happy + * path" test asserts {@code event.getDomain()} against + * {@link SecurityAuditDomain#NAME} and {@code event.getType()} against the + * matching {@link UserAuditTypes} constant. This guards against a + * silent type-string rename in {@code UserAuditEvents.member*} factories + * or in {@code UserAuditTypes}: a typo would break the assertion here + * before downstream listeners would see broken events in production. + */ +public class UserManagerImplAuditTest extends AbstractSecurityTest { + + private final List recordedEvents = new CopyOnWriteArrayList<>(); + + @Before + public void installStubSink() { + recordedEvents.clear(); + AuditEvents.install(new AuditEvents.Sink() { + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean isEnabledFor(@NotNull String domain) { + return true; + } + + @Override + public void record(@NotNull Root r, @NotNull AuditEvent event) { + recordedEvents.add(event); + } + + @Override + public void dispatch(@NotNull AuditEvent event) { + // not used by the capture sites under test + } + }); + } + + @After + public void resetSink() { + AuditEvents.install(null); + } + + @Test + public void singleMemberAddRecordsEvent() throws Exception { + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + User user = getTestUser(); + Group group = userMgr.createGroup("auditTestGroup1"); + try { + userMgr.onGroupUpdate(group, false, user); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); + Map payload = e.getPayload(); + assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(List.of(user.getID()), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(user.getPath()), payload.get(UserAuditTypes.PAYLOAD_MEMBER_PATHS)); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void singleMemberRemoveRecordsEvent() throws Exception { + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + User user = getTestUser(); + Group group = userMgr.createGroup("auditTestGroup2"); + try { + userMgr.onGroupUpdate(group, true, user); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); + Map payload = e.getPayload(); + assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(List.of(user.getID()), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(user.getPath()), payload.get(UserAuditTypes.PAYLOAD_MEMBER_PATHS)); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void bulkMemberAddRecordsEvent() throws Exception { + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup3"); + try { + userMgr.onGroupUpdate(group, false, false, + new HashSet<>(Collections.singleton("memberId")), + Collections.emptySet()); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); + Map payload = e.getPayload(); + assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, + payload.get(UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE)); + assertEquals(Boolean.FALSE, payload.get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + assertEquals(List.of("memberId"), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(), payload.get(UserAuditTypes.PAYLOAD_FAILED_IDS)); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void bulkMemberRemoveRecordsEvent() throws Exception { + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup4"); + try { + userMgr.onGroupUpdate(group, true, false, + new HashSet<>(Collections.singleton("memberId")), + Collections.emptySet()); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); + Map payload = e.getPayload(); + assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); + assertEquals(Boolean.FALSE, payload.get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + assertEquals(List.of("memberId"), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of(), payload.get(UserAuditTypes.PAYLOAD_FAILED_IDS)); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void auditDisabledShortCircuitsCapture() throws Exception { + // Sink reports disabled — capture sites must short-circuit before record(). + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return false; } + @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { + recordedEvents.add(event); + } + @Override public void dispatch(@NotNull AuditEvent event) { /* unused */ } + }); + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + User user = getTestUser(); + Group group = userMgr.createGroup("auditTestGroup5"); + try { + userMgr.onGroupUpdate(group, false, user); + userMgr.onGroupUpdate(group, false, false, + new HashSet<>(Collections.singleton("memberId")), + Collections.emptySet()); + assertEquals("toggle-off must short-circuit before record()", 0, recordedEvents.size()); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void domainPreciseGateSkipsCaptureWhenNoSecurityListener() throws Exception { + // A4 regression guard: isEnabled()==true (a listener exists for SOME + // domain) but isEnabledFor("oak.security")==false (none for the security + // domain). The capture guard is domain-precise (isEnabledFor), so it must + // skip entirely — no event built, no path resolution, no record(). + // Reverting the guard to the coarse isEnabled() would capture here, so + // this test fails on such a regression. + AuditEvents.install(new AuditEvents.Sink() { + @Override public boolean isEnabled() { return true; } + @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { + recordedEvents.add(event); + } + @Override public void dispatch(@NotNull AuditEvent event) { /* unused */ } + }); + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + User user = getTestUser(); + Group group = userMgr.createGroup("auditTestGroupDomainGate"); + try { + userMgr.onGroupUpdate(group, false, user); + userMgr.onGroupUpdate(group, false, false, + new HashSet<>(Collections.singleton("memberId")), + Collections.emptySet()); + assertEquals("domain-precise gate must skip capture when no security-domain listener", + 0, recordedEvents.size()); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void singleMemberPathResolutionFailureSwallowsEvent() throws Exception { + // Force RepositoryException from member.getPath() to exercise the catch + // branch in recordSingleMembershipAuditEvent. record() must never be called. + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup6"); + Authorizable failing = Mockito.mock(Authorizable.class); + Mockito.when(failing.getPath()).thenThrow(new RepositoryException("boom")); + try { + userMgr.onGroupUpdate(group, false, failing); + assertEquals("RepositoryException must not produce an audit event", + 0, recordedEvents.size()); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void repeatedPathResolutionFailureWarnsOnceThenSuppresses() throws Exception { + // Two failures on the SAME UserManagerImpl: the first logs a WARN + // (audit-completeness signal); the second is suppressed to DEBUG. Pins + // the rate-limit branch in UserManagerImpl.warnAuditPathResolutionFailed. + // Both still swallow the event — capture never fails the group update. + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroupRepeat"); + Authorizable failing = Mockito.mock(Authorizable.class); + Mockito.when(failing.getPath()).thenThrow(new RepositoryException("boom")); + LogCustomizer logCustomizer = LogCustomizer.forLogger(UserManagerImpl.class) + .enable(Level.WARN).create(); + logCustomizer.starting(); + try { + userMgr.onGroupUpdate(group, false, failing); + userMgr.onGroupUpdate(group, false, failing); + assertEquals("path-resolution failure must produce no audit events", + 0, recordedEvents.size()); + assertEquals("exactly one WARN — the second occurrence is suppressed to DEBUG", + 1, logCustomizer.getLogs().size()); + } finally { + logCustomizer.finished(); + group.remove(); + root.commit(); + } + } + + @Test + public void bulkEmptyMemberIdsShortCircuitsCapture() throws Exception { + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup7"); + try { + // memberIds empty (e.g. all member adds failed upstream) — early-return. + userMgr.onGroupUpdate(group, false, false, + Collections.emptySet(), + new HashSet<>(Collections.singleton("failed-id"))); + assertEquals("empty memberIds must not produce a bulk audit event", + 0, recordedEvents.size()); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void bulkPathResolutionFailureSwallowsEvent() throws Exception { + // Force RepositoryException from group.getPath() to exercise the bulk + // catch branch. The mocked Group also fails the GroupAction iteration, + // which propagates — but the audit-record path's catch is exercised first. + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group failingGroup = Mockito.mock(Group.class); + Mockito.when(failingGroup.getPath()).thenThrow(new RepositoryException("boom")); + try { + userMgr.onGroupUpdate(failingGroup, false, false, + new HashSet<>(Collections.singleton("memberId")), + Collections.emptySet()); + } catch (RepositoryException expected) { + // GroupAction.onMemberAdded may also propagate after audit's catch handled. + } + assertTrue("audit must have swallowed before any record() call", + recordedEvents.isEmpty()); + } + + @Test + public void bulkContentIdFlagPropagatesToPayload() throws Exception { + // Pins the isContentId=true branch of recordBulkMembershipAuditEvent: + // capture sites in MembershipWriter pass isContentId=true when member IDs + // are content IDs (rep:members UUIDs) rather than authorizable IDs. The + // flag must surface in the event payload so listeners can interpret + // PAYLOAD_MEMBER_IDS correctly. + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup8"); + try { + userMgr.onGroupUpdate(group, false, true, + new HashSet<>(Collections.singleton("content-id-1")), + Collections.emptySet()); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); + assertEquals(Boolean.TRUE, + e.getPayload().get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); + } finally { + group.remove(); + root.commit(); + } + } + + @Test + public void bulkFailedIdsCarryThroughToPayload() throws Exception { + // Pins that non-empty failedIds surface in the event payload — listeners + // need this to distinguish "happened" vs "rejected" entries for audit + // completeness. Per the contract in UserAuditEvents.membersAddedBulk + // Javadoc, failedIds is defensively copied into an immutable List in the + // event payload. + UserManagerImpl userMgr = (UserManagerImpl) getUserManager(root); + Group group = userMgr.createGroup("auditTestGroup9"); + try { + userMgr.onGroupUpdate(group, false, false, + new HashSet<>(Collections.singleton("ok-id")), + new HashSet<>(Collections.singleton("failed-id"))); + assertEquals(1, recordedEvents.size()); + AuditEvent e = recordedEvents.get(0); + Map payload = e.getPayload(); + assertEquals(List.of("ok-id"), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); + assertEquals(List.of("failed-id"), payload.get(UserAuditTypes.PAYLOAD_FAILED_IDS)); + } finally { + group.remove(); + root.commit(); + } + } +} diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java index 9747655e5ec..a3791bf4e51 100644 --- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java +++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java @@ -16,13 +16,16 @@ */ package org.apache.jackrabbit.oak.fixture; +import java.io.Closeable; import java.io.File; +import java.io.IOException; import java.lang.management.ManagementFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; @@ -46,11 +49,22 @@ import org.apache.jackrabbit.oak.plugins.document.rdb.RDBOptions; import org.apache.jackrabbit.oak.plugins.document.util.MongoConnection; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.security.audit.AuditConfigurationImpl; +import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; import org.apache.jackrabbit.oak.segment.Segment; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.blob.BlobStore; import org.apache.jackrabbit.oak.spi.filter.PathFilter; +import org.apache.jackrabbit.oak.spi.security.SecurityProvider; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.state.NodeStore; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,8 +76,11 @@ public abstract class OakFixture { + private static final Logger LOG = LoggerFactory.getLogger(OakFixture.class); + public static final String OAK_MEMORY = "Oak-Memory"; public static final String OAK_MEMORY_NS = "Oak-MemoryNS"; + public static final String OAK_MEMORY_NS_AUDIT = "Oak-MemoryNS-Audit"; public static final String OAK_MONGO = "Oak-Mongo"; public static final String OAK_MONGO_DS = "Oak-Mongo-DS"; @@ -82,6 +99,28 @@ public abstract class OakFixture { public static final String OAK_COMPOSITE_MEMORY_STORE = "Oak-Composite-Memory-Store"; public static final String OAK_COMPOSITE_MONGO_STORE = "Oak-Composite-Mongo-Store"; + /** + * System property that, when set to {@code true}, enables the audit + * pipeline on the in-memory fixtures produced by {@link #getMemory(long)}, + * {@link #getMemoryNS(long)}, and {@link #getMemory(String, long)}. + * Defaults to {@code false} (audit-OFF) — the existing behaviour for + * every consumer that doesn't explicitly opt in. + *

+ * When the property is set, the fixture wires an + * {@link AuditConfigurationImpl} on a fresh {@link DefaultWhiteboard}, + * flips the {@code FT_OAK-12331} feature toggle ON, registers a no-op + * {@link AuditEventListener} for the {@code oak.security} domain so + * Oak's security capture sites actually allocate / buffer / dispatch, + * and attaches the drain observer to every {@link MemoryNodeStore} + * the fixture builds. Tear-down detaches the observers and disposes + * the pipeline. + *

+ * {@link #getMemoryNSWithAudit(long)} always returns an audit-enabled + * fixture regardless of this property — that's the entry point used + * by benchmark comparisons that need both an audit-OFF and an + * audit-ON fixture in the same JVM. + */ + public static final String AUDIT_ENABLED_PROPERTY = "oak.audit.enabled"; private final String name; protected final String unique; @@ -114,30 +153,123 @@ public static OakFixture getMemoryNS(long cacheSize) { return getMemory(OAK_MEMORY_NS, cacheSize); } - public static OakFixture getMemory(String name, final long cacheSize) { + /** + * In-memory fixture. When the {@link #AUDIT_ENABLED_PROPERTY + * oak.audit.enabled} system property is set to {@code true} the audit + * pipeline is wired (see {@link #AUDIT_ENABLED_PROPERTY} for details); + * otherwise the fixture is audit-OFF and identical to the historical + * shape. Callers don't need to switch methods to opt into audit — just + * set the property at JVM startup. + */ + public static OakFixture getMemory(String name, long cacheSize) { + return getMemory(name, cacheSize, Boolean.getBoolean(AUDIT_ENABLED_PROPERTY)); + } + + /** + * Always-audit-enabled in-memory fixture. Use this when you need both + * an audit-OFF fixture (from {@link #getMemoryNS(long)}) and an + * audit-ON fixture in the same JVM — the typical shape for benchmark + * comparisons like {@code benchmark Test Oak-MemoryNS Oak-MemoryNS-Audit}. + * Ignores the {@link #AUDIT_ENABLED_PROPERTY} property. + */ + public static OakFixture getMemoryNSWithAudit(long cacheSize) { + return getMemory(OAK_MEMORY_NS_AUDIT, cacheSize, true); + } + + private static OakFixture getMemory(String name, final long cacheSize, final boolean withAudit) { return new OakFixture(name) { - @Override - public Oak getOak(int clusterId) throws Exception { - Oak oak; - oak = newOak(new MemoryNodeStore()); + private Whiteboard whiteboard; + private SecurityProvider securityProvider; + private AuditConfigurationImpl auditConfig; + private final List drainObserverSubscriptions = new ArrayList<>(); + + private synchronized void initAuditPipelineIfNeeded() { + if (!withAudit || auditConfig != null) { + return; + } + whiteboard = new DefaultWhiteboard(); + auditConfig = new AuditConfigurationImpl(); + auditConfig.initialize(whiteboard); + // Drain observer is attached per-store below via + // store.addObserver(...). Oak.with(Observer) auto-attaches only + // against Oak's default whiteboard (Oak.java:300-302); our + // .with(whiteboard) call replaces it. + securityProvider = SecurityProviderBuilder.newBuilder() + .withWhiteboard(whiteboard) + .build(); + + Tracker tracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : tracker.getServices()) { + if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(true); + } + } + } finally { + tracker.stop(); + } + + whiteboard.register(AuditEventListener.class, + new BenchmarkNoopListener(SecurityAuditDomain.NAME), + Map.of()); + } + + private synchronized Oak buildOak() { + MemoryNodeStore store = new MemoryNodeStore(); + Oak oak = newOak(store); + if (withAudit) { + drainObserverSubscriptions.add(store.addObserver(auditConfig.getDrainObserver())); + oak = oak.with(securityProvider).with(whiteboard); + } return oak; } @Override - public Oak[] setUpCluster(int n, StatisticsProvider statsProvider) throws Exception { + public Oak getOak(int clusterId) { + initAuditPipelineIfNeeded(); + return buildOak(); + } + + @Override + public Oak[] setUpCluster(int n, StatisticsProvider statsProvider) { + initAuditPipelineIfNeeded(); Oak[] cluster = new Oak[n]; for (int i = 0; i < cluster.length; i++) { - Oak oak; - oak = newOak(new MemoryNodeStore()); - cluster[i] = oak; + cluster[i] = buildOak(); } return cluster; } @Override public void tearDownCluster() { - // nothing to do + if (!withAudit) { + return; + } + // Close observer subscriptions first so the drain observer + // detaches from each MemoryNodeStore before dispose() tears + // down the pipeline sinks behind it. + synchronized (this) { + for (Closeable subscription : drainObserverSubscriptions) { + try { + subscription.close(); + } catch (IOException e) { + LOG.warn("Audit drain-observer subscription close failed during fixture teardown; continuing.", e); + } + } + drainObserverSubscriptions.clear(); + } + if (auditConfig != null) { + try { + auditConfig.dispose(); + } catch (RuntimeException e) { + LOG.warn("Audit pipeline dispose() failed during fixture teardown; continuing.", e); + } finally { + auditConfig = null; + securityProvider = null; + whiteboard = null; + } + } } }; } @@ -563,4 +695,34 @@ static Oak newOak(NodeStore nodeStore) { return new Oak(nodeStore).with(ManagementFactory.getPlatformMBeanServer()); } + /** + * Domain-scoped no-op listener used by the audit-enabled benchmark + * fixture ({@link #getMemoryNSWithAudit(long)}). Returning a real + * listener (rather than relying on the JVM-static NOOP sink) is what + * flips {@code AuditEvents.isEnabledFor(domain)} to {@code true} and + * causes capture sites to allocate and buffer events — see the + * {@code BufferSink.isEnabledFor} short-circuit in + * {@code AuditConfigurationImpl}. + */ + private static final class BenchmarkNoopListener implements AuditEventListener { + + private final String domain; + + BenchmarkNoopListener(@NotNull String domain) { + this.domain = domain; + } + + @NotNull + @Override + public String getDomain() { + return domain; + } + + @Override + public void onEvents(@NotNull List events) { + // intentional no-op: the benchmark wants to exercise the + // capture + buffer + dispatch path, not the listener's work + } + } + } diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakRepositoryFixture.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakRepositoryFixture.java index 0f9de72217c..5f3be92593f 100644 --- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakRepositoryFixture.java +++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakRepositoryFixture.java @@ -31,6 +31,17 @@ public static RepositoryFixture getMemoryNS(long cacheSize) { return getMemory(OakFixture.OAK_MEMORY_NS, cacheSize); } + /** + * Audit-enabled sibling of {@link #getMemoryNS(long)}: an in-memory + * fixture that wires the audit pipeline with {@code FT_OAK-12331} + * enabled and a noop listener for the {@code security} domain. + * Use to measure audit-ON overhead against the audit-OFF baseline + * produced by {@link #getMemoryNS(long)}. + */ + public static RepositoryFixture getMemoryNSWithAudit(long cacheSize) { + return new OakRepositoryFixture(OakFixture.getMemoryNSWithAudit(cacheSize)); + } + private static RepositoryFixture getMemory(String name, long cacheSize) { return new OakRepositoryFixture(OakFixture.getMemory(name, cacheSize)); } diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java new file mode 100644 index 00000000000..2c0381c89d7 --- /dev/null +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.fixture; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.junit.After; +import org.junit.Test; + +/** + * Sanity coverage for {@link OakFixture#getMemoryNSWithAudit(long)}. + *

+ * Phase 3 of the audit-SPI work measured the wrong shape because the + * default {@code Oak-MemoryNS} fixture does not wire the audit pipeline + * — capture sites silently routed to {@code AuditEvents.NOOP}. This test + * fails the build if the audit-enabled fixture ever regresses into the + * same silent-audit-OFF state. + */ +public class MemoryNSWithAuditFixtureTest { + + private OakFixture fixture; + + @After + public void tearDown() { + if (fixture != null) { + fixture.tearDownCluster(); + fixture = null; + } + } + + @Test + public void getOakWiresAuditPipelineOn() throws Exception { + fixture = OakFixture.getMemoryNSWithAudit(0); + Oak oak = fixture.getOak(0); + assertNotNull(oak); + + assertTrue("FT_OAK-12331 toggle + a 'security'-domain listener must be live; " + + "AuditEvents.isEnabled() must return true", + AuditEvents.isEnabled()); + assertTrue("Capture sites in UserManagerImpl gate on isEnabledFor('security'); " + + "must return true so audit-ON capture exercise the buffer path", + AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + } + + @Test + public void setUpClusterAlsoWiresAuditPipeline() throws Exception { + fixture = OakFixture.getMemoryNSWithAudit(0); + Oak[] cluster = fixture.setUpCluster(2, StatisticsProvider.NOOP); + assertNotNull(cluster); + assertTrue("cluster must contain the requested number of Oak instances", + cluster.length == 2); + + assertTrue(AuditEvents.isEnabled()); + assertTrue(AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + } + + @Test + public void tearDownClusterDisposesAuditPipeline() throws Exception { + fixture = OakFixture.getMemoryNSWithAudit(0); + fixture.getOak(0); + assertTrue(AuditEvents.isEnabled()); + + fixture.tearDownCluster(); + fixture = null; + + assertFalse("After tearDownCluster, AuditEvents must route to NOOP", + AuditEvents.isEnabled()); + assertFalse("Domain-scoped probe must also revert to NOOP", + AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + } + + @Test + public void fixtureNameIsStable() { + fixture = OakFixture.getMemoryNSWithAudit(0); + assertTrue("fixture name must match OAK_MEMORY_NS_AUDIT constant", + OakFixture.OAK_MEMORY_NS_AUDIT.equals(fixture.toString())); + } +} diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java new file mode 100644 index 00000000000..77c209e8345 --- /dev/null +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.fixture; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; +import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Coverage for the {@code -Doak.audit.enabled=true} opt-in on + * {@link OakFixture#getMemoryNS(long)} (and, transitively, the shared + * {@code getMemory(name, cacheSize)} entry point). The dedicated + * {@link OakFixture#getMemoryNSWithAudit(long)} entry point is covered by + * {@link MemoryNSWithAuditFixtureTest}; this class pins the property-driven + * branch that lets existing benchmarks opt into audit without switching + * methods. + *

+ * Both {@link AuditEvents#install} and {@link AuditBufferLifecycle#install} + * mutate JVM-static state, so the tests defensively reset both in + * {@link #before()} / {@link #after()} on top of the per-test + * {@code fixture.tearDownCluster()} call (which calls + * {@code AuditConfigurationImpl.dispose()} — the production path that + * NOOPs both façades). That keeps the OFF assertions honest even if a + * prior test in the same JVM leaked state. + */ +public class OakFixturePropertyTest { + + private OakFixture fixture; + private String originalProperty; + + @Before + public void before() { + originalProperty = System.getProperty(OakFixture.AUDIT_ENABLED_PROPERTY); + System.clearProperty(OakFixture.AUDIT_ENABLED_PROPERTY); + // Hermetic baseline: NOOP both global façades before each test, + // regardless of any upstream test's cleanup quality. + AuditEvents.install(null); + AuditBufferLifecycle.install(null); + } + + @After + public void after() { + try { + if (fixture != null) { + fixture.tearDownCluster(); + fixture = null; + } + } finally { + // Always restore the property to the JVM-startup value so a + // run-with-property invocation doesn't bleed into other tests. + if (originalProperty == null) { + System.clearProperty(OakFixture.AUDIT_ENABLED_PROPERTY); + } else { + System.setProperty(OakFixture.AUDIT_ENABLED_PROPERTY, originalProperty); + } + // Belt-and-braces: even if tearDownCluster() somehow left a + // façade installed, force both back to NOOP. + AuditEvents.install(null); + AuditBufferLifecycle.install(null); + } + } + + /** + * Default (no property set): {@code getMemoryNS} must keep its + * historical audit-OFF shape. Existing consumers see no behavior + * change unless they explicitly opt in. + */ + @Test + public void propertyAbsentLeavesGetMemoryNSAuditOff() throws Exception { + // before() already cleared the property. + fixture = OakFixture.getMemoryNS(0); + Oak oak = fixture.getOak(0); + assertNotNull(oak); + + assertFalse("default getMemoryNS(0) without the property must be audit-OFF", + AuditEvents.isEnabled()); + assertFalse("audit must remain OFF for every domain probe", + AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + } + + /** + * With {@code -Doak.audit.enabled=true}, {@code getMemoryNS} must + * wire the same audit pipeline that {@code getMemoryNSWithAudit} + * does — FT_OAK-12331 toggle ON and a security-domain listener live. + * If this assertion regresses, callers that pass + * {@code -Doak.audit.enabled=true} from {@code mvn -D...} or + * benchmark scripts will silently measure audit-OFF code paths. + */ + @Test + public void propertySetTrueEnablesAuditOnGetMemoryNS() throws Exception { + System.setProperty(OakFixture.AUDIT_ENABLED_PROPERTY, "true"); + + fixture = OakFixture.getMemoryNS(0); + Oak oak = fixture.getOak(0); + assertNotNull(oak); + + assertTrue("getMemoryNS(0) with -Doak.audit.enabled=true must wire the audit pipeline; " + + "AuditEvents.isEnabled() must return true", + AuditEvents.isEnabled()); + assertTrue("the security-domain listener must be live so capture sites in " + + "UserManagerImpl actually allocate / buffer / dispatch events", + AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + } + + /** + * Property explicitly set to {@code "false"} must behave identically + * to no property set — audit OFF. Pins the {@code Boolean.getBoolean} + * contract against a future regression that defaulted to true on any + * property presence. + */ + @Test + public void propertySetFalseLeavesGetMemoryNSAuditOff() throws Exception { + System.setProperty(OakFixture.AUDIT_ENABLED_PROPERTY, "false"); + + fixture = OakFixture.getMemoryNS(0); + Oak oak = fixture.getOak(0); + assertNotNull(oak); + + assertFalse("getMemoryNS(0) with -Doak.audit.enabled=false must stay audit-OFF", + AuditEvents.isEnabled()); + } + + /** + * The property is read at fixture-construction time, not at + * {@code getOak} time. Mutating the property AFTER construction + * must NOT flip the fixture's audit mode — otherwise a process that + * toggles the property mid-run could end up with a fixture whose + * teardown contract no longer matches its construction-time wiring. + */ + @Test + public void propertyIsReadAtConstructionNotAtGetOak() throws Exception { + // Construct with the property OFF. + System.clearProperty(OakFixture.AUDIT_ENABLED_PROPERTY); + fixture = OakFixture.getMemoryNS(0); + + // Flip the property AFTER construction. + System.setProperty(OakFixture.AUDIT_ENABLED_PROPERTY, "true"); + + Oak oak = fixture.getOak(0); + assertNotNull(oak); + + assertFalse("property mutation after construction must not retroactively " + + "enable audit on this fixture", + AuditEvents.isEnabled()); + } +} diff --git a/oak-security-spi/pom.xml b/oak-security-spi/pom.xml index ec6923f2740..8c8fee3871a 100644 --- a/oak-security-spi/pom.xml +++ b/oak-security-spi/pom.xml @@ -48,6 +48,7 @@ org.apache.jackrabbit.oak.plugins.tree, org.apache.jackrabbit.oak.spi.security, + org.apache.jackrabbit.oak.spi.security.audit, org.apache.jackrabbit.oak.spi.security.authentication, org.apache.jackrabbit.oak.spi.security.authentication.callback, org.apache.jackrabbit.oak.spi.security.authentication.credentials, diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java new file mode 100644 index 00000000000..ef6b788e3c9 --- /dev/null +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.security.audit; + +/** + * Domain constant for events produced by Oak security modules. + *

+ * Sub-domains under {@code oak.security} (user, ACL, principal, token) + * share this single domain string and discriminate via the event + * {@code type} field — see {@link org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes} + * for the user-management type vocabulary. Other Oak areas (e.g. + * indexing, query, blob) declare their own domain-constant classes in + * their respective SPI modules — not here. + */ +public final class SecurityAuditDomain { + + /** + * Domain name for events produced by Oak security modules + * (user management, ACLs, principal management, tokens, etc.). + * Namespaced with the {@code oak.} prefix so listeners hosted in + * mixed environments (Sling, AEM, third-party bundles) can + * disambiguate Oak's security events from same-named domains + * defined by other layers. + */ + public static final String NAME = "oak.security"; + + private SecurityAuditDomain() { + // constants class + } +} diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/package-info.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/package-info.java new file mode 100644 index 00000000000..79939c62d60 --- /dev/null +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/package-info.java @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Version("1.0.0") +package org.apache.jackrabbit.oak.spi.security.audit; + +import org.osgi.annotation.versioning.Version; diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java new file mode 100644 index 00000000000..d7eba32fe06 --- /dev/null +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.security.user; + +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; + +/** + * Stable type-string constants and payload keys for user-management audit + * events. All events declared here share the + * {@link SecurityAuditDomain#NAME oak.security} domain. + *

+ * Listener bundles discriminate user-management events by combining + * {@code event.getDomain().equals(SecurityAuditDomain.NAME)} with + * {@code event.getType().equals(UserAuditTypes.MEMBER_ADDED)} (or another + * constant declared here). + *

+ * A single membership add or remove and a bulk one share the same type + * string ({@link #MEMBER_ADDED} / {@link #MEMBER_REMOVED}); a bulk change is + * simply an event whose {@link #PAYLOAD_MEMBER_IDS} list holds more than one + * entry. Consumers that need a bulk/single flag derive it from the list size + * rather than from a distinct type. + *

+ * Future sub-domains under {@code oak.security} (ACL, principal, token) + * declare their own type-string classes alongside their respective + * configuration packages. + *

+ * Asymmetric exposure. This class is the read-side + * vocabulary; the producer-side factories ({@code UserAuditEvents} in + * {@code oak-core}) are package-private by design. The partition is + * defense-in-depth — it raises the bar for casual forging of + * Oak-user-management events but does not prevent it (an external bundle + * can still call {@link AuditEvent#of(String, String, java.util.Map)} + * directly with this domain + a type from this class). Listeners that + * need to distinguish Oak-attested events from fire-and-forget emissions + * MUST check the reserved {@code commit.*} keys in the payload — a reliable + * signal for events delivered through Oak dispatch; see the trust contract + * on {@link AuditEvent#getPayload()}. + */ +public final class UserAuditTypes { + + // ── Type strings ────────────────────────────────────────────────── + + /** + * Recorded when one or more authorizables are added as members of a + * group. A single {@code Group.addMember} and a bulk + * {@code Group.addMembers} share this type; discriminate by the size of + * {@link #PAYLOAD_MEMBER_IDS}. + *

+ * Payload keys: {@link #PAYLOAD_GROUP_PATH}, {@link #PAYLOAD_MEMBER_IDS}, + * {@link #PAYLOAD_MEMBERSHIP_SOURCE}, {@link #PAYLOAD_IS_CONTENT_ID}, and — + * for single-member changes — {@link #PAYLOAD_MEMBER_PATHS}; bulk changes + * additionally carry {@link #PAYLOAD_FAILED_IDS}. + */ + public static final String MEMBER_ADDED = "membership.added"; + + /** + * Recorded when one or more authorizables are removed from a group. + * Single and bulk removes share this type; discriminate by the size of + * {@link #PAYLOAD_MEMBER_IDS}. Payload keys: same as {@link #MEMBER_ADDED}. + */ + public static final String MEMBER_REMOVED = "membership.removed"; + + // ── Payload keys ────────────────────────────────────────────────── + + /** Group path. Value type: {@code String}. */ + public static final String PAYLOAD_GROUP_PATH = "groupPath"; + + /** + * Member identifiers added or removed. Value type: {@code List}; + * always present with at least one entry. Entries are content ids (UUIDs + * from {@code rep:members}) when {@link #PAYLOAD_IS_CONTENT_ID} is + * {@code true}, otherwise authorizable ids. + */ + public static final String PAYLOAD_MEMBER_IDS = "memberIds"; + + /** + * JCR paths of the members, when the producer resolved them. Value type: + * {@code List}. Present on single-member changes; the bulk path + * carries ids only and omits this key. + */ + public static final String PAYLOAD_MEMBER_PATHS = "memberPaths"; + + /** + * Oak membership storage model the change applied to. Value type: + * {@code String}. User-management API capture always writes the group's + * {@code rep:members}, so this key is {@link #MEMBERSHIP_SOURCE_STATIC}. + */ + public static final String PAYLOAD_MEMBERSHIP_SOURCE = "membershipSource"; + + /** + * {@code true} when {@link #PAYLOAD_MEMBER_IDS} carries content ids + * (UUIDs from {@code rep:members}); {@code false} when they are + * authorizable ids. Value type: {@code Boolean}. + */ + public static final String PAYLOAD_IS_CONTENT_ID = "isContentId"; + + /** + * Ids that failed to stage (already-member, not-found, etc.). Value type: + * {@code List}; may be empty but never null. Carried on the bulk + * path only. + */ + public static final String PAYLOAD_FAILED_IDS = "failedIds"; + + // ── Membership-source values ────────────────────────────────────── + + /** + * {@link #PAYLOAD_MEMBERSHIP_SOURCE} value for changes written to a + * group's {@code rep:members} — the model produced by the user-management + * API. Other storage models ({@code static-sharded}, {@code dynamic}, + * {@code dynamic-external}) are not produced by these capture sites. + */ + public static final String MEMBERSHIP_SOURCE_STATIC = "static"; + + private UserAuditTypes() { + // constants + } +} diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java index d92a3fd47ec..d423ef14a1f 100644 --- a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@Version("2.9.0") +@Version("2.10.0") package org.apache.jackrabbit.oak.spi.security.user; import org.osgi.annotation.versioning.Version; diff --git a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java new file mode 100644 index 00000000000..8d2b0881b9d --- /dev/null +++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.security.audit; + +import java.lang.reflect.Constructor; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class SecurityAuditDomainTest { + + @Test + public void nameConstantIsStable() { + assertEquals("oak.security", SecurityAuditDomain.NAME); + } + + @Test + public void privateConstructorIsReachableForCoverage() throws Exception { + // The class is a constants holder with a private no-arg constructor + // that guards against accidental instantiation. Reflection invokes it + // to keep coverage at 100% — there is no production call site. + Constructor ctor = SecurityAuditDomain.class.getDeclaredConstructor(); + ctor.setAccessible(true); + assertNotNull(ctor.newInstance()); + } +} diff --git a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java new file mode 100644 index 00000000000..da00f4b6b17 --- /dev/null +++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.security.user; + +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Set; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +public class UserAuditTypesTest { + + @Test + public void allTypeStringsAreNonBlank() { + for (String value : typeStrings()) { + assertFalse("type string must not be blank: " + value, value.isBlank()); + } + } + + @Test + public void allPayloadKeysAreNonBlank() { + for (String value : payloadKeys()) { + assertFalse("payload key must not be blank: " + value, value.isBlank()); + } + } + + @Test + public void typeStringsAreUnique() { + List values = typeStrings(); + assertEquals("type strings must be unique", + values.size(), Set.copyOf(values).size()); + } + + @Test + public void payloadKeysAreUnique() { + List values = payloadKeys(); + assertEquals("payload keys must be unique", + values.size(), Set.copyOf(values).size()); + } + + @Test + public void privateConstructorIsReachableForCoverage() throws Exception { + // Constants-only class: private constructor guards against + // accidental instantiation; reflection-invoked for line coverage. + Constructor ctor = UserAuditTypes.class.getDeclaredConstructor(); + ctor.setAccessible(true); + assertNotNull(ctor.newInstance()); + } + + private static List typeStrings() { + return List.of( + UserAuditTypes.MEMBER_ADDED, + UserAuditTypes.MEMBER_REMOVED); + } + + private static List payloadKeys() { + return List.of( + UserAuditTypes.PAYLOAD_GROUP_PATH, + UserAuditTypes.PAYLOAD_MEMBER_IDS, + UserAuditTypes.PAYLOAD_MEMBER_PATHS, + UserAuditTypes.PAYLOAD_MEMBERSHIP_SOURCE, + UserAuditTypes.PAYLOAD_IS_CONTENT_ID, + UserAuditTypes.PAYLOAD_FAILED_IDS); + } +} From 27af06ccab8c66d8ac26c44424e74bd070a323d1 Mon Sep 17 00:00:00 2001 From: Andrei Dulvac Date: Tue, 4 Aug 2026 13:56:36 +0200 Subject: [PATCH 2/4] OAK-12331: use value types for audit domain and event type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from #3059. Replaces the raw String domain and type on the audit SPI with AuditDomain and AuditType, so the values can be constrained where they are built rather than trusted everywhere they are read. Both are validated in their static factory: non-blank, no colon, no whitespace, and nothing JcrNameParser rejects as a node name. A listener that persists events into the repository can therefore build a path from a domain without escaping it, which is what prompted the change. The colon is excluded separately because JcrNameParser accepts it as a namespace prefix, and a prefix means nothing for a flat identifier. Embedded whitespace is excluded for the same reason. They are separate types rather than one, so passing a type where a domain belongs no longer compiles. Neither is an enum: consumer bundles define their own domains, so the set is open. The exported constants change type with them, SecurityAuditDomain.NAME becoming SecurityAuditDomain.DOMAIN and the two UserAuditTypes membership constants becoming AuditType. Payload keys stay String, since they are map keys and not identifiers. Also from the same review: - AuditEvent.isCommitAttested(event) replaces the advice that listeners check for the three reserved payload keys themselves. The key names are an implementation detail and a hand-rolled check breaks silently if they move. - Those keys are now oak.commit.* rather than commit.*, matching the oak. prefix the domain constants already use. The decorator reads them from the SPI so the strip path and the attestation check cannot drift apart. - AuditConfigurationImpl is now AuditPipeline. It implements a one-method interface, but its job is owning the toggle, buffer, registry and observer registration. - Dropped the empty @ObjectClassDefinition. It configured nothing, and adding one later is additive. - The activation log line said "activated" next to a bare toggle boolean, which read as a contradiction. It now says whether events will be captured, and the class javadoc separates wired from toggled-on from isActive(). - Product-specific sample domains removed from javadoc, the TOCTOU comment in initialize() cut to the reason for the ordering, and a note added that the toggle starts disabled because Feature backs it with a fresh AtomicBoolean. Co-authored-by: Jörg Hoh --- .../jackrabbit/oak/spi/audit/AuditDomain.java | 99 ++++++++++ .../jackrabbit/oak/spi/audit/AuditEvent.java | 111 +++++++---- .../oak/spi/audit/AuditEventEmitter.java | 27 +-- .../oak/spi/audit/AuditEventImpl.java | 15 +- .../oak/spi/audit/AuditEventListener.java | 28 +-- .../jackrabbit/oak/spi/audit/AuditEvents.java | 6 +- .../jackrabbit/oak/spi/audit/AuditNames.java | 69 +++++++ .../jackrabbit/oak/spi/audit/AuditType.java | 90 +++++++++ .../oak/spi/audit/AuditDomainTest.java | 115 +++++++++++ .../oak/spi/audit/AuditEventImplTest.java | 10 +- .../oak/spi/audit/AuditEventListenerTest.java | 12 +- .../oak/spi/audit/AuditEventTest.java | 93 ++++----- .../oak/spi/audit/AuditEventsTest.java | 22 +-- .../oak/spi/audit/AuditTypeTest.java | 96 +++++++++ .../oak/security/audit/AuditBuffer.java | 4 +- .../security/audit/AuditDrainObserver.java | 9 +- .../security/audit/AuditEventEmitterImpl.java | 3 +- ...figurationImpl.java => AuditPipeline.java} | 95 +++++---- .../audit/CommitMetadataDecorator.java | 20 +- .../WhiteboardAuditEventListenerRegistry.java | 3 +- .../oak/security/user/UserAuditEvents.java | 9 +- .../oak/security/user/UserManagerImpl.java | 8 +- .../oak/security/audit/AuditBufferTest.java | 50 ++--- .../audit/AuditDrainObserverTest.java | 70 +++---- .../audit/AuditEventEmitterImplTest.java | 16 +- .../oak/security/audit/AuditFixtureTest.java | 28 +-- ...t.java => AuditPipelineLifecycleTest.java} | 57 +++--- .../oak/security/audit/AuditPipelineTest.java | 182 +++++++++--------- .../oak/security/audit/AuditWiringTest.java | 24 +-- .../audit/CommitMetadataDecoratorTest.java | 74 +++---- ...teboardAuditEventListenerRegistryTest.java | 75 ++++---- .../SecurityProviderRegistrationTest.java | 2 +- .../security/user/UserAuditEventsTest.java | 10 +- .../user/UserManagerImplAuditTest.java | 16 +- .../jackrabbit/oak/fixture/OakFixture.java | 21 +- .../fixture/MemoryNSWithAuditFixtureTest.java | 6 +- .../oak/fixture/OakFixturePropertyTest.java | 6 +- .../security/audit/SecurityAuditDomain.java | 15 +- .../oak/spi/security/user/UserAuditTypes.java | 15 +- .../audit/SecurityAuditDomainTest.java | 5 +- .../spi/security/user/UserAuditTypesTest.java | 17 +- 41 files changed, 1091 insertions(+), 542 deletions(-) create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDomain.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditNames.java create mode 100644 oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditType.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDomainTest.java create mode 100644 oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditTypeTest.java rename oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/{AuditConfigurationImpl.java => AuditPipeline.java} (86%) rename oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/{AuditConfigurationImplTest.java => AuditPipelineLifecycleTest.java} (90%) diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDomain.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDomain.java new file mode 100644 index 00000000000..2309a6ee033 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDomain.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Identifies the area of Oak (or of a consumer bundle) that produced an + * audit event. Listeners subscribe per domain, and the pipeline routes + * events by comparing domains, so this is the primary dispatch key. + *

+ * The value is constrained at construction rather than at use: a domain is + * usable as a JCR node name, so a listener that persists events into the + * repository can build a path from it without escaping. See + * {@link #of(String)} for the exact rules. + *

+ * The set of domains is open. Oak's own areas declare constants in their + * respective SPI modules (for example + * {@code org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain}), + * and consumer bundles are free to define their own. The convention is a + * dotted identifier prefixed with the owning layer, which is why this is + * not an enum. + * + * @see AuditType + * @see AuditEvent#getDomain() + */ +public final class AuditDomain { + + private final String name; + + private AuditDomain(@NotNull String name) { + this.name = name; + } + + /** + * Returns a domain for the given name. + *

+ * The name must be non-blank and usable as a JCR node name: it is + * rejected if {@link org.apache.jackrabbit.oak.namepath.JcrNameParser} + * would not accept it (which rules out {@code /}, {@code [}, {@code ]}, + * {@code |} and {@code *}), and additionally if it contains a colon. + * A colon denotes a namespace prefix in JCR and carries no meaning for + * a flat audit identifier, so it is rejected rather than silently + * reinterpreted. + * + * @param name the domain name, non-null and non-blank. + * @return a domain wrapping {@code name}. + * @throws IllegalArgumentException if {@code name} is blank or is not + * usable as a JCR node name. + */ + @NotNull + public static AuditDomain of(@NotNull String name) { + return new AuditDomain(AuditNames.validate(name, "domain")); + } + + /** + * @return the domain name, never blank. + */ + @NotNull + public String name() { + return name; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + return (o instanceof AuditDomain) && name.equals(((AuditDomain) o).name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + /** + * @return the domain name; equivalent to {@link #name()}. + */ + @Override + public String toString() { + return name; + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java index 3c1195f34ec..9664dc164a3 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java @@ -30,44 +30,66 @@ *

    *
  • Oak-internal capture sites tied to a successful * {@code Root.commit()}. The commit-attached drain step decorates - * payload with {@code commit.sessionId}, {@code commit.userId}, - * and {@code commit.timestamp} entries before dispatch.
  • + * payload with the three reserved attestation entries before + * dispatch; use {@link #isCommitAttested(AuditEvent)} to test for + * them rather than checking the keys by hand. *
  • Any bundle calling {@link AuditEventEmitter#emit(AuditEvent)}. * Such events carry the payload provided by the caller, except that * Oak strips caller-supplied values for the three reserved - * {@code commit.*} attestation keys before dispatch — see the trust - * contract on {@link #getPayload()}.
  • + * attestation keys before dispatch — see the trust contract on + * {@link #getPayload()}. *
* The {@link #getDomain()} value selects the listeners that receive this * event. *

* Most callers do not implement this interface directly: use the static - * factory {@link #of(String, String, Map)} (or the no-payload overload - * {@link #of(String, String)}) to construct an immutable event with the - * current wall-clock timestamp. The package-private {@code AuditEventImpl} - * backs these factories. + * factory {@link #of(AuditDomain, AuditType, Map)} (or the no-payload + * overload {@link #of(AuditDomain, AuditType)}) to construct an immutable + * event with the current wall-clock timestamp. The package-private + * {@code AuditEventImpl} backs these factories. */ @ProviderType public interface AuditEvent { + /** + * Payload key carrying the id of the session whose commit produced this + * event. One of the three reserved attestation keys; see the trust + * contract on {@link #getPayload()} and {@link #isCommitAttested}. + */ + String COMMIT_SESSION_ID = "oak.commit.sessionId"; + + /** + * Payload key carrying the user id of the commit that produced this + * event. {@code "oak:unknown"} for system commits, which listeners + * MUST NOT try to resolve to a real identity. + */ + String COMMIT_USER_ID = "oak.commit.userId"; + + /** + * Payload key carrying the commit timestamp (millis since epoch), i.e. + * when the change became durable — as opposed to + * {@link #getTimestamp()}, which is when the event was captured. + */ + String COMMIT_TIMESTAMP = "oak.commit.timestamp"; + /** * Returns the domain that owns this event. Listeners are domain-scoped: * an {@link AuditEventListener} only receives events whose * {@code getDomain()} matches its own {@link AuditEventListener#getDomain()}. * - * @return non-null domain name (e.g. {@code "oak.security"}, {@code "aem.content"}). + * @return non-null domain (e.g. {@code "oak.security"}). */ @NotNull - String getDomain(); + AuditDomain getDomain(); /** - * Returns the event type identifier within the domain. Type strings are - * stable across releases for any given domain. + * Returns the event type within the domain. Types are stable across + * releases for any given domain. * - * @return non-null type identifier. + * @return non-null type. */ @NotNull - String getType(); + AuditType getType(); /** * Returns the wall-clock timestamp (millis since epoch) captured at @@ -94,15 +116,16 @@ public interface AuditEvent { * this to expose typed accessors and include their fields here. *

* For commit-attached events, Oak's drain path adds entries with the - * keys {@code commit.sessionId}, {@code commit.userId}, and - * {@code commit.timestamp} when the buffer is drained on commit + * keys {@code oak.commit.sessionId}, {@code oak.commit.userId}, and + * {@code oak.commit.timestamp} when the buffer is drained on commit * success. Oak does not add these entries on the fire-and-forget * path (see the trust contract below). *

* Trust contract (normative — other audit SPI and * implementation docs defer to this paragraph). For events delivered - * through Oak dispatch, the three reserved keys {@code commit.sessionId}, - * {@code commit.userId} and {@code commit.timestamp} are Oak-attested: + * through Oak dispatch, the three reserved keys + * {@code oak.commit.sessionId}, {@code oak.commit.userId} and + * {@code oak.commit.timestamp} are Oak-attested: *

    *
  • On the commit-attached path Oak unconditionally * overwrites the three keys with the values from @@ -113,12 +136,13 @@ public interface AuditEvent { * {@code AuditEvents.dispatch}) Oak strips caller-supplied * values for the same three keys before delivery.
  • *
- * A listener may therefore treat the presence of any of the three keys - * in a dispatched payload as "commit-attached event, values supplied by - * Oak". Every other entry — including other {@code commit.*}-prefixed - * keys — is forwarded verbatim from the caller-supplied payload on both - * paths and is untrusted: anchor trust on the three reserved keys, never - * on the {@code commit.} prefix in general. + * A listener may therefore treat the presence of the three keys in a + * dispatched payload as "commit-attached event, values supplied by + * Oak". Use {@link #isCommitAttested(AuditEvent)} for that test rather + * than reading the keys directly. Every other entry — including other + * {@code oak.commit.*}-prefixed keys — is forwarded verbatim from the + * caller-supplied payload on both paths and is untrusted: anchor trust + * on the three reserved keys, never on the prefix in general. *

* Boundaries of the attestation: *

    @@ -183,28 +207,43 @@ default Map getPayload() { * for the producer-side responsibility under the open trust model. */ @NotNull - static AuditEvent of(@NotNull String domain, - @NotNull String type, + static AuditEvent of(@NotNull AuditDomain domain, + @NotNull AuditType type, @NotNull Map payload) { - if (domain.isBlank()) { - throw new IllegalArgumentException("domain must not be blank"); - } - if (type.isBlank()) { - throw new IllegalArgumentException("type must not be blank"); - } return new AuditEventImpl(domain, type, System.currentTimeMillis(), Map.copyOf(payload)); } /** * Convenience overload for events with no payload. * - * @param domain non-blank domain identifier. - * @param type non-blank event type identifier within {@code domain}. + * @param domain the event domain. + * @param type the event type within {@code domain}. * @return non-null event instance with an empty payload. - * @throws IllegalArgumentException if {@code domain} or {@code type} is blank. */ @NotNull - static AuditEvent of(@NotNull String domain, @NotNull String type) { + static AuditEvent of(@NotNull AuditDomain domain, @NotNull AuditType type) { return of(domain, type, Map.of()); } + + /** + * Returns {@code true} when the event carries Oak's commit attestation, + * i.e. when it reached the listener through the commit-attached path + * after a successful {@code Root.commit()}. + *

    + * This is the supported way to make that distinction. The attestation + * is carried by three reserved payload keys, but their names are an + * implementation detail: read them directly and the check silently + * breaks if they are ever renamed. The semantics — what the attestation + * does and does not guarantee — are documented on {@link #getPayload()}. + * + * @param event the event to test, non-null. + * @return {@code true} when all three reserved attestation entries are + * present. + */ + static boolean isCommitAttested(@NotNull AuditEvent event) { + Map payload = event.getPayload(); + return payload.containsKey(COMMIT_SESSION_ID) + && payload.containsKey(COMMIT_USER_ID) + && payload.containsKey(COMMIT_TIMESTAMP); + } } diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java index 4751e90dfb4..410e618c828 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java @@ -24,9 +24,11 @@ *

    {@code
      * @Reference private AuditEventEmitter audit;
      *
    + * private static final AuditDomain MY_DOMAIN = AuditDomain.of("example.content");
    + *
      * void onSomething() {
    - *     if (audit.isEnabledFor("aem.content")) {
    - *         audit.emit(new MyEvent(...));
    + *     if (audit.isEnabledFor(MY_DOMAIN)) {
    + *         audit.emit(AuditEvent.of(MY_DOMAIN, MY_TYPE, payload));
      *     }
      * }
      * }
    @@ -35,22 +37,25 @@ * listeners registered for the event's domain. Not tied to any commit; * not buffered; not rolled back on failure. *

    - * Listeners are invoked under per-listener try/catch isolation — covering - * the {@link AuditEventListener#getDomain()} routing lookup as well as - * {@code onEvents()}: one listener throwing does not prevent others from - * running. Any {@link Throwable} (including {@link RuntimeException} and - * {@link Error} subclasses such as {@link LinkageError}) is logged at + * Oak guarantees per-listener isolation on this path, so + * callers can rely on it: Oak wraps each listener invocation — covering the + * {@link AuditEventListener#getDomain()} routing lookup as well as + * {@code onEvents()} — so one listener throwing does not prevent others + * from running. Any {@link Throwable} (including {@link RuntimeException} + * and {@link Error} subclasses such as {@link LinkageError}) is logged at * {@code WARN} and never propagates back to the caller. The barrier catches * {@code Throwable} rather than {@code RuntimeException} so JVM-level * failures from a misconfigured consumer bundle (missing transitive * dependency, {@link OutOfMemoryError}, etc.) cannot prevent other - * listeners from receiving the event. + * listeners from receiving the event. Implementations of this interface + * are supplied by Oak ({@code @ProviderType}); the isolation is not + * something a consumer needs to provide or can opt out of. *

    * Trust model: any bundle that resolves this service can * emit any event for any domain. The event payload reflects the emitting * bundle's claim; Oak does not verify it — except that caller-supplied - * values for the three reserved {@code commit.*} attestation keys are - * stripped before delivery. The normative statement is the trust contract + * values for the three reserved attestation keys are stripped before + * delivery. The normative statement is the trust contract * on {@link AuditEvent#getPayload()}; see {@link AuditEventListener} for * the listener-side view. */ @@ -72,5 +77,5 @@ public interface AuditEventEmitter { * * @param domain the domain to check, non-null. */ - boolean isEnabledFor(@NotNull String domain); + boolean isEnabledFor(@NotNull AuditDomain domain); } diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java index f1832e7ab23..d6dd4f180ab 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java @@ -22,7 +22,8 @@ /** * Package-private immutable {@link AuditEvent} backing the - * {@link AuditEvent#of(String, String, Map) AuditEvent.of} static factories. + * {@link AuditEvent#of(AuditDomain, AuditType, Map) AuditEvent.of} static + * factories. *

    * Not part of the SPI surface — consumers always see the bare * {@code AuditEvent} interface. Keeping the impl package-private is the @@ -36,13 +37,13 @@ */ final class AuditEventImpl implements AuditEvent { - private final String domain; - private final String type; + private final AuditDomain domain; + private final AuditType type; private final long timestamp; private final Map payload; - AuditEventImpl(@NotNull String domain, - @NotNull String type, + AuditEventImpl(@NotNull AuditDomain domain, + @NotNull AuditType type, long timestamp, @NotNull Map payload) { this.domain = domain; @@ -53,13 +54,13 @@ final class AuditEventImpl implements AuditEvent { @NotNull @Override - public String getDomain() { + public AuditDomain getDomain() { return domain; } @NotNull @Override - public String getType() { + public AuditType getType() { return type; } diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java index 0d04ae5c0c0..049ba1c62ee 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java @@ -50,21 +50,23 @@ * Events delivered through this method may originate from either: *

      *
    • Oak-internal capture sites tied to a successful - * {@code Root.commit()}. Such events carry {@code commit.sessionId}, - * {@code commit.userId}, and {@code commit.timestamp} entries in - * their payload. {@code commit.userId} is {@code "oak:unknown"} for - * system commits and listeners MUST NOT attempt to - * resolve it to a real user identity.
    • + * {@code Root.commit()}. Such events carry Oak's commit attestation: + * {@link AuditEvent#COMMIT_SESSION_ID}, + * {@link AuditEvent#COMMIT_USER_ID} and + * {@link AuditEvent#COMMIT_TIMESTAMP}. The user id is + * {@code "oak:unknown"} for system commits and listeners + * MUST NOT attempt to resolve it to a real user + * identity. *
    • Any bundle calling {@link AuditEventEmitter#emit(AuditEvent)}. * The accuracy of such events is the emitting bundle's responsibility; * Oak does not verify them. They cannot carry the three reserved - * {@code commit.*} keys — Oak strips caller-supplied values for them - * before delivery. Other {@code commit.*}-prefixed keys are forwarded - * verbatim and are untrusted.
    • + * attestation keys — Oak strips caller-supplied values for them + * before delivery. Other {@code oak.commit.*}-prefixed keys are + * forwarded verbatim and are untrusted. *
    - * Consumers that need to distinguish between the two sources can rely on - * the presence of the {@code commit.sessionId} key — the normative - * statement and the boundaries of this attestation are documented on + * Consumers that need to distinguish between the two sources should call + * {@link AuditEvent#isCommitAttested(AuditEvent)} — the normative statement + * and the boundaries of this attestation are documented on * {@link AuditEvent#getPayload()}. */ @ConsumerType @@ -79,10 +81,10 @@ public interface AuditEventListener { * listener is skipped for that dispatch — see the Throwable-isolation * note in the class Javadoc. * - * @return non-null domain name. + * @return non-null domain. */ @NotNull - String getDomain(); + AuditDomain getDomain(); /** * Returns the dispatch rank for this listener — higher value is diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java index 90d312f0a33..325f08bda4a 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java @@ -47,7 +47,7 @@ public interface Sink { * least one listener is registered for the given domain. Used to * avoid event allocation when no consumer cares about the domain. */ - boolean isEnabledFor(@NotNull String domain); + boolean isEnabledFor(@NotNull AuditDomain domain); /** * Commit-attached path. Buffers the event against the session @@ -69,7 +69,7 @@ public interface Sink { private static final Sink NOOP = new Sink() { @Override public boolean isEnabled() { return false; } - @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return false; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { } @Override public void dispatch(@NotNull AuditEvent event) { } }; @@ -101,7 +101,7 @@ public static boolean isEnabled() { return sink.isEnabled(); } - public static boolean isEnabledFor(@NotNull String domain) { + public static boolean isEnabledFor(@NotNull AuditDomain domain) { return sink.isEnabledFor(domain); } diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditNames.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditNames.java new file mode 100644 index 00000000000..69969ddde73 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditNames.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.apache.jackrabbit.oak.namepath.JcrNameParser; +import org.jetbrains.annotations.NotNull; + +/** + * Shared validation for {@link AuditDomain} and {@link AuditType} names. + * Package-private: the rules are exposed through the two factory methods, + * not as API of their own. + */ +final class AuditNames { + + private AuditNames() { + // utility class + } + + /** + * Validates an audit domain or type name. + *

    + * {@code JcrNameParser} rejects {@code /}, {@code [}, {@code ]}, + * {@code |} and {@code *}, which is most of what we want. It does + * accept a colon (it reads as a namespace prefix) and embedded + * whitespace, and neither belongs in a flat audit identifier, so both + * are rejected here on top of the parser's rules. + * + * @param name the candidate name. + * @param label {@code "domain"} or {@code "type"}, used in the message. + * @return {@code name} unchanged, when valid. + * @throws IllegalArgumentException if {@code name} is blank, contains a + * colon or whitespace, or is not usable as a JCR node name. + */ + @NotNull + static String validate(@NotNull String name, @NotNull String label) { + if (name.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank"); + } + if (name.indexOf(':') >= 0) { + throw new IllegalArgumentException( + label + " must not contain ':' (reserved as a JCR namespace prefix): '" + name + "'"); + } + for (int i = 0; i < name.length(); i++) { + if (Character.isWhitespace(name.charAt(i))) { + throw new IllegalArgumentException( + label + " must not contain whitespace: '" + name + "'"); + } + } + if (!JcrNameParser.validate(name)) { + throw new IllegalArgumentException( + label + " must be usable as a JCR node name: '" + name + "'"); + } + return name; + } +} diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditType.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditType.java new file mode 100644 index 00000000000..b1216dd72b5 --- /dev/null +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditType.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Identifies what happened, within the scope of an {@link AuditDomain}. + * Types are only meaningful relative to their domain: two domains may use + * the same type string for unrelated things, so listeners discriminate on + * the pair. + *

    + * Constrained the same way as {@link AuditDomain} — see {@link #of(String)} + * — so an event's domain and type together can form a repository path + * without escaping. + *

    + * A distinct type from {@link AuditDomain} on purpose: the two are not + * interchangeable, and keeping them separate lets the compiler catch a + * domain passed where a type belongs. + * + * @see AuditEvent#getType() + */ +public final class AuditType { + + private final String name; + + private AuditType(@NotNull String name) { + this.name = name; + } + + /** + * Returns a type for the given name. + *

    + * Same rules as {@link AuditDomain#of(String)}: non-blank, usable as a + * JCR node name, and no colon. + * + * @param name the type name, non-null and non-blank. + * @return a type wrapping {@code name}. + * @throws IllegalArgumentException if {@code name} is blank or is not + * usable as a JCR node name. + */ + @NotNull + public static AuditType of(@NotNull String name) { + return new AuditType(AuditNames.validate(name, "type")); + } + + /** + * @return the type name, never blank. + */ + @NotNull + public String name() { + return name; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + return (o instanceof AuditType) && name.equals(((AuditType) o).name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + /** + * @return the type name; equivalent to {@link #name()}. + */ + @Override + public String toString() { + return name; + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDomainTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDomainTest.java new file mode 100644 index 00000000000..7c75ca6f537 --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDomainTest.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class AuditDomainTest { + + @Test + public void acceptsDottedIdentifier() { + assertEquals("oak.security", AuditDomain.of("oak.security").name()); + } + + @Test + public void acceptsHyphenAndUnderscore() { + assertEquals("oak-security", AuditDomain.of("oak-security").name()); + assertEquals("oak_security", AuditDomain.of("oak_security").name()); + } + + @Test + public void rejectsEmpty() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditDomain.of("")); + assertTrue(ex.getMessage().contains("domain")); + assertTrue(ex.getMessage().contains("blank")); + } + + @Test + public void rejectsWhitespaceOnly() { + // JcrNameParser accepts " ", so the blank check is what rejects it. + assertThrows(IllegalArgumentException.class, () -> AuditDomain.of(" ")); + assertThrows(IllegalArgumentException.class, () -> AuditDomain.of(" \t ")); + } + + @Test + public void rejectsEmbeddedWhitespace() { + // Also accepted by JcrNameParser, hence the explicit check. + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditDomain.of("oak security")); + assertTrue(ex.getMessage().contains("whitespace")); + } + + @Test + public void rejectsColon() { + // A colon is a JCR namespace prefix; meaningless for a flat + // identifier, and JcrNameParser would happily accept it. + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditDomain.of("oak:security")); + assertTrue(ex.getMessage().contains("':'")); + } + + @Test + public void rejectsCharactersIllegalInAJcrName() { + // The reason the type exists: a domain must be usable as a node name. + for (String bad : new String[] {"a/b", "a[b", "a]b", "a|b", "a*b", ".", ".."}) { + assertThrows("must reject " + bad, IllegalArgumentException.class, + () -> AuditDomain.of(bad)); + } + } + + @Test + public void equalsAndHashCodeAreValueBased() { + AuditDomain a = AuditDomain.of("oak.security"); + AuditDomain b = AuditDomain.of("oak.security"); + AuditDomain other = AuditDomain.of("oak.query"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, other); + assertEquals(a, a); + assertNotEquals(a, null); + assertNotEquals("oak.security", a); + } + + @Test + public void usableAsAMapKey() { + // The pipeline routes by domain, so map behaviour is load-bearing. + Map map = new HashMap<>(); + map.put(AuditDomain.of("oak.security"), "v"); + assertEquals("v", map.get(AuditDomain.of("oak.security"))); + } + + @Test + public void notEqualToASameNamedType() { + // Distinct types on purpose: a domain is not a type. + assertNotEquals(AuditDomain.of("x.y"), AuditType.of("x.y")); + } + + @Test + public void toStringReturnsName() { + assertEquals("oak.security", AuditDomain.of("oak.security").toString()); + } +} diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java index c6ca8d3afe6..37e868ec047 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java @@ -36,10 +36,10 @@ public class AuditEventImplTest { @Test public void ctorStoresFields() { Map payload = Map.of("k", "v"); - AuditEventImpl e = new AuditEventImpl("test.domain", "t", 42L, payload); + AuditEventImpl e = new AuditEventImpl(AuditDomain.of("test.domain"), AuditType.of("t"), 42L, payload); - assertEquals("test.domain", e.getDomain()); - assertEquals("t", e.getType()); + assertEquals(AuditDomain.of("test.domain"), e.getDomain()); + assertEquals(AuditType.of("t"), e.getType()); assertEquals(42L, e.getTimestamp()); // Factory invariant: payload is stored by reference (already immutable). assertSame(payload, e.getPayload()); @@ -47,7 +47,7 @@ public void ctorStoresFields() { @Test public void ctorAcceptsEmptyPayload() { - AuditEventImpl e = new AuditEventImpl("test.domain", "t", 0L, Map.of()); + AuditEventImpl e = new AuditEventImpl(AuditDomain.of("test.domain"), AuditType.of("t"), 0L, Map.of()); assertEquals(Map.of(), e.getPayload()); } @@ -56,7 +56,7 @@ public void payloadInstanceIsImmutable() { // AuditEventImpl trusts the caller to pass an immutable Map. // We exercise that the contract holds end-to-end by constructing // with Map.of() (immutable) and verifying mutation throws. - AuditEventImpl e = new AuditEventImpl("test.domain", "t", 0L, Map.of("k", "v")); + AuditEventImpl e = new AuditEventImpl(AuditDomain.of("test.domain"), AuditType.of("t"), 0L, Map.of("k", "v")); assertThrows(UnsupportedOperationException.class, () -> e.getPayload().put("k2", "v2")); } diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java index 1dff2894768..07690f0cb00 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java @@ -30,8 +30,8 @@ public class AuditEventListenerTest { public void defaultRankIsZero() { AuditEventListener listener = new AuditEventListener() { @Override - public @NotNull String getDomain() { - return "test"; + public @NotNull AuditDomain getDomain() { + return AuditDomain.of("test"); } @Override @@ -49,8 +49,8 @@ public void listenerReceivesCallToOnEvents() { // with a non-empty list — passing emptyList would contradict the // documented contract. AuditEvent event = new AuditEvent() { - @Override public @NotNull String getDomain() { return "test"; } - @Override public @NotNull String getType() { return "t"; } + @Override public @NotNull AuditDomain getDomain() { return AuditDomain.of("test"); } + @Override public @NotNull AuditType getType() { return AuditType.of("t"); } @Override public long getTimestamp() { return 0L; } }; List input = Collections.singletonList(event); @@ -58,8 +58,8 @@ public void listenerReceivesCallToOnEvents() { final List[] received = new List[]{null}; AuditEventListener listener = new AuditEventListener() { @Override - public @NotNull String getDomain() { - return "test"; + public @NotNull AuditDomain getDomain() { + return AuditDomain.of("test"); } @Override diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java index f0ad0387486..1b2b4f54450 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -33,8 +34,8 @@ public class AuditEventTest { public void defaultGetPayloadReturnsEmptyMap() { // Impl that does NOT override getPayload() — exercises the default method body. AuditEvent e = new AuditEvent() { - @Override public @NotNull String getDomain() { return "test"; } - @Override public @NotNull String getType() { return "t"; } + @Override public @NotNull AuditDomain getDomain() { return AuditDomain.of("test"); } + @Override public @NotNull AuditType getType() { return AuditType.of("t"); } @Override public long getTimestamp() { return 0L; } }; assertEquals(Collections.emptyMap(), e.getPayload()); @@ -42,16 +43,19 @@ public void defaultGetPayloadReturnsEmptyMap() { //---------------------------< static factory of(domain, type, payload) >--- + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); + private static final AuditType TYPE = AuditType.of("t"); + @Test public void factoryWithPayloadReturnsEventWithSuppliedFields() { long before = System.currentTimeMillis(); - AuditEvent e = AuditEvent.of("test.domain", "membership.added", + AuditEvent e = AuditEvent.of(DOMAIN, AuditType.of("membership.added"), Map.of("groupPath", "/g", "memberPath", "/u")); long after = System.currentTimeMillis(); assertNotNull(e); - assertEquals("test.domain", e.getDomain()); - assertEquals("membership.added", e.getType()); + assertEquals(DOMAIN, e.getDomain()); + assertEquals(AuditType.of("membership.added"), e.getType()); assertEquals(Map.of("groupPath", "/g", "memberPath", "/u"), e.getPayload()); // Capture timestamp is taken inside of(...) — must fall within the // call window observed by the test thread. @@ -61,64 +65,65 @@ public void factoryWithPayloadReturnsEventWithSuppliedFields() { @Test public void factoryWithoutPayloadReturnsEventWithEmptyPayload() { - AuditEvent e = AuditEvent.of("test.domain", "membership.removed"); - assertEquals("test.domain", e.getDomain()); - assertEquals("membership.removed", e.getType()); + AuditEvent e = AuditEvent.of(DOMAIN, AuditType.of("membership.removed")); + assertEquals(DOMAIN, e.getDomain()); + assertEquals(AuditType.of("membership.removed"), e.getType()); assertEquals(Collections.emptyMap(), e.getPayload()); } @Test - public void factoryRejectsBlankDomain() { - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> AuditEvent.of("", "type", Map.of())); - assertTrue(ex.getMessage().contains("domain")); + public void factoryPayloadIsImmutable() { + AuditEvent e = AuditEvent.of(DOMAIN, TYPE, Map.of("k", "v")); + Map p = e.getPayload(); + assertThrows(UnsupportedOperationException.class, () -> p.put("k2", "v2")); } @Test - public void factoryRejectsWhitespaceDomain() { - // String.isBlank() — exercise the non-empty-but-blank path. - assertThrows(IllegalArgumentException.class, - () -> AuditEvent.of(" ", "type", Map.of())); - } + public void factoryDecouplesCallerMap() { + // Map.copyOf returns the same instance for an already-immutable Map.of result, + // so we use HashMap to verify the defensive-copy semantics. + java.util.Map mutable = new java.util.HashMap<>(); + mutable.put("k", "v"); + AuditEvent e = AuditEvent.of(DOMAIN, TYPE, mutable); - @Test - public void factoryRejectsBlankType() { - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> AuditEvent.of("test.domain", "", Map.of())); - assertTrue(ex.getMessage().contains("type")); + mutable.put("k2", "v2"); // mutate the source AFTER construction + assertEquals("event payload must not reflect post-construction source mutation", + Map.of("k", "v"), e.getPayload()); } + //------------------------------------------< isCommitAttested(event) >--- + @Test - public void factoryRejectsWhitespaceType() { - assertThrows(IllegalArgumentException.class, - () -> AuditEvent.of("test.domain", " \t ", Map.of())); + public void isCommitAttestedTrueWhenAllThreeKeysPresent() { + AuditEvent e = AuditEvent.of(DOMAIN, TYPE, Map.of( + AuditEvent.COMMIT_SESSION_ID, "s", + AuditEvent.COMMIT_USER_ID, "u", + AuditEvent.COMMIT_TIMESTAMP, 1L)); + assertTrue(AuditEvent.isCommitAttested(e)); } @Test - public void noPayloadOverloadAlsoValidatesDomain() { - // The no-payload overload delegates to the 3-arg form; verify both - // validation branches still fire via this route. - assertThrows(IllegalArgumentException.class, () -> AuditEvent.of("", "type")); - assertThrows(IllegalArgumentException.class, () -> AuditEvent.of("test.domain", "")); + public void isCommitAttestedFalseForPlainEvent() { + assertFalse(AuditEvent.isCommitAttested(AuditEvent.of(DOMAIN, TYPE))); } @Test - public void factoryPayloadIsImmutable() { - AuditEvent e = AuditEvent.of("test.domain", "t", Map.of("k", "v")); - Map p = e.getPayload(); - assertThrows(UnsupportedOperationException.class, () -> p.put("k2", "v2")); + public void isCommitAttestedRequiresEveryKey() { + // A partial set must not pass: an emitter that supplies only some of + // the keys must never read as Oak-attested. + assertFalse(AuditEvent.isCommitAttested(AuditEvent.of(DOMAIN, TYPE, + Map.of(AuditEvent.COMMIT_SESSION_ID, "s")))); + assertFalse(AuditEvent.isCommitAttested(AuditEvent.of(DOMAIN, TYPE, + Map.of(AuditEvent.COMMIT_SESSION_ID, "s", AuditEvent.COMMIT_USER_ID, "u")))); + assertFalse(AuditEvent.isCommitAttested(AuditEvent.of(DOMAIN, TYPE, + Map.of(AuditEvent.COMMIT_USER_ID, "u", AuditEvent.COMMIT_TIMESTAMP, 1L)))); } @Test - public void factoryDecouplesCallerMap() { - // Map.copyOf returns the same instance for an already-immutable Map.of result, - // so we use HashMap to verify the defensive-copy semantics. - java.util.Map mutable = new java.util.HashMap<>(); - mutable.put("k", "v"); - AuditEvent e = AuditEvent.of("test.domain", "t", mutable); - - mutable.put("k2", "v2"); // mutate the source AFTER construction - assertEquals("event payload must not reflect post-construction source mutation", - Map.of("k", "v"), e.getPayload()); + public void reservedKeysAreOakPrefixed() { + // Pins the wire names: renaming these is a consumer-visible change. + assertEquals("oak.commit.sessionId", AuditEvent.COMMIT_SESSION_ID); + assertEquals("oak.commit.userId", AuditEvent.COMMIT_USER_ID); + assertEquals("oak.commit.timestamp", AuditEvent.COMMIT_TIMESTAMP); } } diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java index dc7c6619535..260b57a51e7 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java @@ -37,10 +37,10 @@ public void tearDown() { AuditEvents.install(null); } - private static AuditEvent fixedEvent(@NotNull String domain) { + private static AuditEvent fixedEvent(@NotNull AuditDomain domain) { return new AuditEvent() { - @Override public @NotNull String getDomain() { return domain; } - @Override public @NotNull String getType() { return "t"; } + @Override public @NotNull AuditDomain getDomain() { return domain; } + @Override public @NotNull AuditType getType() { return AuditType.of("t"); } @Override public long getTimestamp() { return 0L; } @Override public @NotNull Map getPayload() { return Collections.emptyMap(); } }; @@ -49,9 +49,9 @@ private static AuditEvent fixedEvent(@NotNull String domain) { @Test public void facadeNoOpWhenNoSinkInstalled() { assertFalse(AuditEvents.isEnabled()); - assertFalse(AuditEvents.isEnabledFor("test.domain")); - AuditEvents.record(mock(Root.class), fixedEvent("test.domain")); - AuditEvents.dispatch(fixedEvent("test.domain")); + assertFalse(AuditEvents.isEnabledFor(AuditDomain.of("test.domain"))); + AuditEvents.record(mock(Root.class), fixedEvent(AuditDomain.of("test.domain"))); + AuditEvents.dispatch(fixedEvent(AuditDomain.of("test.domain"))); // no exception, no observable effect — verified by no sink installed } @@ -60,11 +60,11 @@ public void recordRoutesThroughInstalledSink() { AtomicReference received = new AtomicReference<>(); AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return true; } - @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { received.set(event); } @Override public void dispatch(@NotNull AuditEvent event) { /* not used */ } }); - AuditEvent e = fixedEvent("test.domain"); + AuditEvent e = fixedEvent(AuditDomain.of("test.domain")); AuditEvents.record(mock(Root.class), e); assertSame(e, received.get()); } @@ -74,11 +74,11 @@ public void dispatchRoutesThroughInstalledSink() { AtomicReference received = new AtomicReference<>(); AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return true; } - @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* not used */ } @Override public void dispatch(@NotNull AuditEvent event) { received.set(event); } }); - AuditEvent e = fixedEvent("aem.content"); + AuditEvent e = fixedEvent(AuditDomain.of("example.content")); AuditEvents.dispatch(e); assertSame(e, received.get()); } @@ -87,7 +87,7 @@ public void dispatchRoutesThroughInstalledSink() { public void installNullResetsToNoOp() { AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return true; } - @Override public boolean isEnabledFor(@NotNull String domain) { return true; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { } @Override public void dispatch(@NotNull AuditEvent event) { } }); diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditTypeTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditTypeTest.java new file mode 100644 index 00000000000..e061440681c --- /dev/null +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditTypeTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.spi.audit; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class AuditTypeTest { + + @Test + public void acceptsDottedIdentifier() { + assertEquals("membership.added", AuditType.of("membership.added").name()); + } + + @Test + public void rejectsEmpty() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditType.of("")); + assertTrue(ex.getMessage().contains("type")); + assertTrue(ex.getMessage().contains("blank")); + } + + @Test + public void rejectsWhitespaceOnly() { + assertThrows(IllegalArgumentException.class, () -> AuditType.of(" ")); + } + + @Test + public void rejectsEmbeddedWhitespace() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditType.of("membership added")); + assertTrue(ex.getMessage().contains("whitespace")); + } + + @Test + public void rejectsColon() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> AuditType.of("rep:added")); + assertTrue(ex.getMessage().contains("':'")); + } + + @Test + public void rejectsCharactersIllegalInAJcrName() { + for (String bad : new String[] {"a/b", "a[b", "a]b", "a|b", "a*b", ".", ".."}) { + assertThrows("must reject " + bad, IllegalArgumentException.class, + () -> AuditType.of(bad)); + } + } + + @Test + public void equalsAndHashCodeAreValueBased() { + AuditType a = AuditType.of("membership.added"); + AuditType b = AuditType.of("membership.added"); + AuditType other = AuditType.of("membership.removed"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, other); + assertEquals(a, a); + assertNotEquals(a, null); + assertNotEquals("membership.added", a); + } + + @Test + public void usableAsAMapKey() { + Map map = new HashMap<>(); + map.put(AuditType.of("membership.added"), "v"); + assertEquals("v", map.get(AuditType.of("membership.added"))); + } + + @Test + public void toStringReturnsName() { + assertEquals("membership.added", AuditType.of("membership.added").toString()); + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java index 049e67c0feb..67711d92334 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java @@ -38,7 +38,7 @@ *

    * The buffer also implements {@link AuditBufferLifecycle.Listener}; it is * installed via {@code AuditBufferLifecycle.install(this)} by - * {@link AuditConfigurationImpl} on activation. + * {@link AuditPipeline} on activation. *

    * Soft per-session cap. A single session must not be able * to accumulate an unbounded number of staged events (e.g. a very large @@ -163,7 +163,7 @@ List drain(@NotNull String sessionId) { /** * Drops all staged events for the current thread. - * Called by {@link AuditConfigurationImpl#deactivate} so the + * Called by {@link AuditPipeline#deactivate} so the * deactivator thread leaves no residue. *

    * Note: this cannot reach across thread boundaries. ThreadLocal diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java index 4d129fe685e..cda4261674c 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; @@ -164,14 +165,14 @@ private void doContentChanged(@NotNull CommitInfo info) { return; } List decorated = CommitMetadataDecorator.decorate(events, info); - Map> byDomain = groupByDomain(decorated); + Map> byDomain = groupByDomain(decorated); for (AuditEventListener listener : listeners) { dispatchOne(listener, byDomain); } } - private static @NotNull Map> groupByDomain(@NotNull List events) { - Map> byDomain = new HashMap<>(4); + private static @NotNull Map> groupByDomain(@NotNull List events) { + Map> byDomain = new HashMap<>(4); for (AuditEvent event : events) { byDomain.computeIfAbsent(event.getDomain(), k -> new ArrayList<>(events.size())).add(event); } @@ -179,7 +180,7 @@ private void doContentChanged(@NotNull CommitInfo info) { } private static void dispatchOne(@NotNull AuditEventListener listener, - @NotNull Map> byDomain) { + @NotNull Map> byDomain) { // The getDomain() routing lookup sits INSIDE the barrier — it is // listener code just like onEvents(), and a throw escaping to the // outer barrier would starve every remaining listener. diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java index b7066c99278..5c11c7ee595 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java @@ -16,6 +16,7 @@ */ package org.apache.jackrabbit.oak.security.audit; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; @@ -40,7 +41,7 @@ public void emit(@NotNull AuditEvent event) { } @Override - public boolean isEnabledFor(@NotNull String domain) { + public boolean isEnabledFor(@NotNull AuditDomain domain) { return AuditEvents.isEnabledFor(domain); } } diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java similarity index 86% rename from oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java rename to oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java index 6d780e45754..51240e164eb 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditConfigurationImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java @@ -24,6 +24,7 @@ import org.apache.jackrabbit.oak.osgi.OsgiWhiteboard; import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; import org.apache.jackrabbit.oak.spi.audit.AuditConfiguration; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; @@ -36,14 +37,13 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; -import org.osgi.service.metatype.annotations.Designate; -import org.osgi.service.metatype.annotations.ObjectClassDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Default {@link AuditConfiguration} implementation. Contributes the - * audit pipeline to Oak: + * Owns the audit pipeline and its lifecycle. Implements + * {@link AuditConfiguration} to expose pipeline state, but its actual job is + * assembling and tearing down the parts: *

      *
    • Registers a {@link Feature} toggle gating capture and dispatch.
    • *
    • Installs a per-session buffer ({@link AuditBuffer}) into @@ -63,13 +63,18 @@ * via {@code SecurityProvider.getConfiguration(AuditConfiguration.class)}. * Embedded callers obtain the drain observer via {@link #getDrainObserver()}. *

      - * When the feature toggle is disabled, capture is a no-op and the observer - * short-circuits — see {@link AuditEvents#isEnabled()} and - * {@link AuditDrainObserver#contentChanged}. + * Three separate things, easily confused. The pipeline is wired once + * {@link #initialize(Whiteboard)} returns, and stays wired until + * {@link #dispose()}. The feature toggle decides whether capture and dispatch + * actually do anything: with it disabled, capture is a no-op and the observer + * short-circuits (see {@link AuditEvents#isEnabled()} and + * {@link AuditDrainObserver#contentChanged}). {@link #isActive()} is narrower + * still — it reports {@code true} only when the toggle is enabled + * and at least one listener is registered, so a wired + * pipeline with the toggle on but no consumers reports {@code false}. */ @Component(service = AuditConfiguration.class) -@Designate(ocd = AuditConfigurationImpl.Configuration.class) -public class AuditConfigurationImpl implements AuditConfiguration { +public class AuditPipeline implements AuditConfiguration { /** * Feature toggle name, following the {@code FT_OAK-} convention @@ -84,20 +89,10 @@ public class AuditConfigurationImpl implements AuditConfiguration { */ public static final String FEATURE_TOGGLE_NAME = "FT_OAK-12331"; - @ObjectClassDefinition(name = "Apache Jackrabbit Oak AuditConfiguration", - description = "Audit event pipeline. Capture and dispatch are " + - "gated by the '" + FEATURE_TOGGLE_NAME + "' feature toggle " + - "(disabled by default).") - @interface Configuration { - // Configuration is currently empty by design: capture/dispatch behavior - // is controlled exclusively by the feature toggle. Listeners are - // contributed via OSGi services / the Whiteboard. - } - - private static final Logger log = LoggerFactory.getLogger(AuditConfigurationImpl.class); + private static final Logger log = LoggerFactory.getLogger(AuditPipeline.class); // Package-private (not private) on the internal-state fields so - // AuditConfigurationImplTest can mock-replace them to verify the + // AuditPipelineLifecycleTest can mock-replace them to verify the // dispose-order invariant via Mockito InOrder. Production callers MUST // NOT touch these fields directly — go through initialize() / dispose(). // @@ -128,7 +123,7 @@ public class AuditConfigurationImpl implements AuditConfiguration { * {@link #initialize(Whiteboard)} and zeroed by {@link #dispose()}. * Exposed via {@link #getDrainObserver()} as an {@link Observer}. *

      - * Singleton-not-factory by design: each {@code AuditConfigurationImpl} + * Singleton-not-factory by design: each {@code AuditPipeline} * owns at most one Observer because (a) the {@link AuditBuffer} * {@code ThreadLocal} is buffer-instance-scoped, so multiple Observer * instances would compete for the same drain, and (b) the destructive @@ -147,29 +142,24 @@ public class AuditConfigurationImpl implements AuditConfiguration { */ ServiceRegistration observerRegistration; - public AuditConfigurationImpl() { + public AuditPipeline() { super(); } @SuppressWarnings("UnusedDeclaration") @Activate - private void activate(@NotNull Configuration configuration, - @NotNull BundleContext bundleContext, + private void activate(@NotNull BundleContext bundleContext, @NotNull Map properties) { - // Step 1-4: install sinks/registry/buffer/toggle. Capture-site - // record(...) calls reach the buffer as soon as initialize returns. - // The singleton AuditDrainObserver is also constructed inside - // initialize() (step 5 below) so the impl is fully wired before - // we publish anything externally. + // Wire everything first: capture sites reach the buffer as soon as + // initialize() returns, and it also constructs the drain observer. initialize(new OsgiWhiteboard(bundleContext)); - // Step 6 LAST: publish the Observer service. ObserverTracker + // Publish the Observer service last. ObserverTracker // (oak-store-spi/.../spi/commit/ObserverTracker.java, instantiated // per-NodeStoreService in DocumentNodeStoreService, SegmentNodeStoreRegistrar, // CompositeNodeStoreService) subscribes it to the root NodeStore. - // Any commit thread racing with activation that reaches step 6 before - // ObserverTracker has noticed the service will simply miss the drain - // on this one commit — events stay in the per-thread buffer until the - // next commit on the same session. No correctness risk. + // A commit thread racing activation just misses the drain for that one + // commit — events stay buffered for the next commit on the same + // session. No correctness risk. observerRegistration = bundleContext.registerService( Observer.class.getName(), getDrainObserver(), null); } @@ -206,6 +196,8 @@ private void activate(@NotNull Configuration configuration, * non-null. */ public void initialize(@NotNull Whiteboard whiteboard) { + // Starts disabled: Feature.newFeature backs the toggle with a fresh + // AtomicBoolean, so nothing needs to set it false explicitly. featureToggle = Feature.newFeature(FEATURE_TOGGLE_NAME, whiteboard); registry = new WhiteboardAuditEventListenerRegistry(); @@ -216,26 +208,27 @@ public void initialize(@NotNull Whiteboard whiteboard) { AuditEvents.install(new BufferSink(featureToggle, registry, buffer)); - // Construct the singleton AuditDrainObserver LAST in initialize(). - // This closes a potential TOCTOU window in the OSGi @Activate flow: - // ObserverTracker (which subscribes to Observer services on a - // background thread) might fire the bootstrap CommitInfo.EMPTY_EXTERNAL - // invocation before @Activate returns. With the singleton constructed - // here (inside initialize, which @Activate calls FIRST), getDrainObserver() - // is safe to call from anywhere in @Activate after this point. + // Constructed last so the observer exists before activate() publishes + // it as a service: ObserverTracker subscribes on a background thread + // and can fire before activate() returns. drainObserver = new AuditDrainObserver(featureToggle, buffer, registry); - log.info("Audit pipeline activated. Toggle '{}' = {}.", - FEATURE_TOGGLE_NAME, featureToggle.isEnabled()); + // The pipeline is wired either way; the toggle decides whether events + // are captured and dispatched. Say which, rather than printing a bare + // boolean — "activated" and "capturing" are not the same thing. + log.info("Audit pipeline wired. Toggle '{}' is {}; events will {}be captured and dispatched.", + FEATURE_TOGGLE_NAME, + featureToggle.isEnabled() ? "enabled" : "disabled", + featureToggle.isEnabled() ? "" : "not "); } /** * Returns the singleton {@link Observer} bound to this pipeline's * buffer, registry, and feature toggle. Constructed once by * {@link #initialize(Whiteboard)} and cached for the lifetime of this - * {@code AuditConfigurationImpl} instance; zeroed by {@link #dispose()}. + * {@code AuditPipeline} instance; zeroed by {@link #dispose()}. *

      - * The singleton shape is deliberate. Each {@code AuditConfigurationImpl} + * The singleton shape is deliberate. Each {@code AuditPipeline} * owns at most one Observer because (a) the {@link AuditBuffer} * {@code ThreadLocal} is buffer-instance-scoped, so multiple Observer * instances would compete for the same drain on every commit thread, @@ -263,13 +256,13 @@ public void initialize(@NotNull Whiteboard whiteboard) { public @NotNull Observer getDrainObserver() { if (drainObserver == null) { throw new IllegalStateException( - "AuditConfigurationImpl.initialize(...) must be called first" + + "AuditPipeline.initialize(...) must be called first" + " (or dispose() has already run)"); } return drainObserver; } - // Package-private (was private) so the test suite can invoke the + // Package-private so the test suite can invoke the // OSGi-shaped tear-down flow directly to verify the // "unregister before dispose internals" ordering invariant via // Mockito InOrder. OSGi DS resolves @Deactivate via reflection; @@ -323,7 +316,7 @@ public void dispose() { // embedded caller is separately responsible for closing the // Closeable returned by ((Observable) store).addObserver(...) BEFORE // calling dispose() — that Closeable is owned by the caller, not by - // AuditConfigurationImpl, because tests/fixtures need explicit + // AuditPipeline, because tests/fixtures need explicit // lifecycle control over their per-store subscriptions. // - Misuse case (e.g. test calls dispose() directly after @Activate // without invoking @Deactivate): caught here, loud failure with @@ -437,7 +430,7 @@ public boolean isEnabled() { } @Override - public boolean isEnabledFor(@NotNull String domain) { + public boolean isEnabledFor(@NotNull AuditDomain domain) { return toggle.isEnabled() && registry.hasListenerFor(domain); } @@ -463,7 +456,7 @@ public void dispatch(@NotNull AuditEvent event) { // any dispatched payload is a reliable "Oak-attested" signal — // see the AuditEvent.getPayload() trust contract. AuditEvent toDispatch = CommitMetadataDecorator.stripReservedCommitKeys(event); - String domain = toDispatch.getDomain(); + AuditDomain domain = toDispatch.getDomain(); List single = Collections.singletonList(toDispatch); for (AuditEventListener listener : listeners) { // The listener's getDomain() filter sits INSIDE the barrier — diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java index 1b074027b99..9d74f64e78a 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java @@ -24,7 +24,9 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -66,9 +68,13 @@ final class CommitMetadataDecorator { private static final Logger log = LoggerFactory.getLogger(CommitMetadataDecorator.class); - static final String KEY_SESSION_ID = "commit.sessionId"; - static final String KEY_USER_ID = "commit.userId"; - static final String KEY_TIMESTAMP = "commit.timestamp"; + // Aliases for the reserved keys declared on the SPI. Single source of + // truth: the strip path here and AuditEvent.isCommitAttested must agree + // on the names, or a listener's attestation check silently diverges from + // what Oak actually stamps. + static final String KEY_SESSION_ID = AuditEvent.COMMIT_SESSION_ID; + static final String KEY_USER_ID = AuditEvent.COMMIT_USER_ID; + static final String KEY_TIMESTAMP = AuditEvent.COMMIT_TIMESTAMP; /** * One-shot latch for the strip WARN (package-private so tests can @@ -182,8 +188,8 @@ private static final class DecoratedAuditEvent implements AuditEvent { this.payload = Collections.unmodifiableMap(merged); } - @Override public @NotNull String getDomain() { return delegate.getDomain(); } - @Override public @NotNull String getType() { return delegate.getType(); } + @Override public @NotNull AuditDomain getDomain() { return delegate.getDomain(); } + @Override public @NotNull AuditType getType() { return delegate.getType(); } @Override public long getTimestamp() { return delegate.getTimestamp(); } @Override public @NotNull Map getPayload() { return payload; } } @@ -209,8 +215,8 @@ private static final class StrippedAuditEvent implements AuditEvent { this.payload = Collections.unmodifiableMap(filtered); } - @Override public @NotNull String getDomain() { return delegate.getDomain(); } - @Override public @NotNull String getType() { return delegate.getType(); } + @Override public @NotNull AuditDomain getDomain() { return delegate.getDomain(); } + @Override public @NotNull AuditType getType() { return delegate.getType(); } @Override public long getTimestamp() { return delegate.getTimestamp(); } @Override public @NotNull Map getPayload() { return payload; } } diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java index 7f33bd220dc..e813c156a08 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.whiteboard.AbstractServiceTracker; import org.jetbrains.annotations.NotNull; @@ -146,7 +147,7 @@ boolean hasAnyListener() { * @return {@code true} when at least one listener is registered for * the domain. */ - boolean hasListenerFor(@NotNull String domain) { + boolean hasListenerFor(@NotNull AuditDomain domain) { for (AuditEventListener listener : getServices()) { try { if (domain.equals(listener.getDomain())) { diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java index 71333198468..9f30f3ff8e2 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.java @@ -21,6 +21,7 @@ import java.util.Set; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; import org.jetbrains.annotations.NotNull; @@ -77,7 +78,7 @@ static AuditEvent memberRemoved(@NotNull String groupPath, } @NotNull - private static AuditEvent singleMember(@NotNull String type, + private static AuditEvent singleMember(@NotNull AuditType type, @NotNull String groupPath, @NotNull String memberId, @NotNull String memberPath) { @@ -85,7 +86,7 @@ private static AuditEvent singleMember(@NotNull String type, // so isContentId is false. memberIds is present (schema-required) and // memberPaths carries the resolved node path. return AuditEvent.of( - SecurityAuditDomain.NAME, + SecurityAuditDomain.DOMAIN, type, Map.of( UserAuditTypes.PAYLOAD_GROUP_PATH, groupPath, @@ -154,7 +155,7 @@ static AuditEvent membersRemovedBulk(@NotNull String groupPath, } @NotNull - private static AuditEvent bulkMembers(@NotNull String type, + private static AuditEvent bulkMembers(@NotNull AuditType type, @NotNull String groupPath, @NotNull Set memberIds, boolean isContentId, @@ -163,7 +164,7 @@ private static AuditEvent bulkMembers(@NotNull String type, throw new IllegalArgumentException("memberIds must not be empty"); } return AuditEvent.of( - SecurityAuditDomain.NAME, + SecurityAuditDomain.DOMAIN, type, Map.of( UserAuditTypes.PAYLOAD_GROUP_PATH, groupPath, diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java index 5b71bd4c16c..44f8408bb3e 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java @@ -381,7 +381,7 @@ void onImpersonation(@NotNull User user, @NotNull Principal principal, boolean g * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable member) throws RepositoryException { - if (AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)) { + if (AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)) { recordSingleMembershipAuditEvent(group, isRemove, member); } for (GroupAction action : filterGroupActions()) { @@ -406,7 +406,7 @@ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, boolean isContentId, @NotNull Set memberIds, @NotNull Set failedIds) throws RepositoryException { - if (AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)) { + if (AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)) { recordBulkMembershipAuditEvent(group, isRemove, isContentId, memberIds, failedIds); } for (GroupAction action : filterGroupActions()) { @@ -439,7 +439,7 @@ private void recordSingleMembershipAuditEvent(@NotNull Group group, boolean isRe // Path resolution failed — drop the event rather than fail the // surrounding group update. It is an audit-completeness signal: // a successful membership change produced no audit event. - warnAuditPathResolutionFailed("failed to resolve path for group membership update", e); + warnAuditPathResolutionFailed("failed to resolve path for group membership update, not recording the audit event", e); } } @@ -461,7 +461,7 @@ private void recordBulkMembershipAuditEvent(@NotNull Group group, boolean isRemo ? UserAuditEvents.membersRemovedBulk(groupPath, memberIds, isContentId, failedIds) : UserAuditEvents.membersAddedBulk(groupPath, memberIds, isContentId, failedIds)); } catch (RepositoryException e) { - warnAuditPathResolutionFailed("failed to resolve group path for bulk membership update", e); + warnAuditPathResolutionFailed("failed to resolve group path for bulk membership update, not recording audit event", e); } } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java index 840353c1c2d..d876a8f5dfb 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java @@ -20,7 +20,9 @@ import java.util.concurrent.atomic.AtomicReference; import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -45,7 +47,7 @@ public class AuditBufferTest { private static final String SESSION = "session-1"; private static final String OTHER_SESSION = "session-2"; - private static final String DOMAIN = "test.domain"; + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); private AuditBuffer buffer; @@ -60,7 +62,7 @@ public void tearDown() { buffer.clearAll(); } - private static AuditEvent event(String type) { + private static AuditEvent event(AuditType type) { return AuditEvent.of(DOMAIN, type); } @@ -68,13 +70,13 @@ private static AuditEvent event(String type) { @Test public void recordThenPeekReturnsStagedEventsInOrder() { - buffer.record(SESSION, event("a")); - buffer.record(SESSION, event("b")); + buffer.record(SESSION, event(AuditType.of("a"))); + buffer.record(SESSION, event(AuditType.of("b"))); List staged = buffer.peek(SESSION); assertEquals(2, staged.size()); - assertEquals("a", staged.get(0).getType()); - assertEquals("b", staged.get(1).getType()); + assertEquals("a", staged.get(0).getType().name()); + assertEquals("b", staged.get(1).getType().name()); } @Test @@ -84,9 +86,9 @@ public void peekReturnsNullWhenNothingStaged() { @Test public void recordIsPerSession() { - buffer.record(SESSION, event("a")); - buffer.record(OTHER_SESSION, event("x")); - buffer.record(OTHER_SESSION, event("y")); + buffer.record(SESSION, event(AuditType.of("a"))); + buffer.record(OTHER_SESSION, event(AuditType.of("x"))); + buffer.record(OTHER_SESSION, event(AuditType.of("y"))); assertEquals(1, buffer.peek(SESSION).size()); assertEquals(2, buffer.peek(OTHER_SESSION).size()); @@ -99,23 +101,23 @@ public void recordIsPerSession() { */ @Test public void peekReturnsDefensiveCopy() { - buffer.record(SESSION, event("a")); + buffer.record(SESSION, event(AuditType.of("a"))); List first = buffer.peek(SESSION); // The returned list is immutable (List.copyOf) — structural mutation throws. - assertThrows(UnsupportedOperationException.class, () -> first.add(event("injected"))); + assertThrows(UnsupportedOperationException.class, () -> first.add(event(AuditType.of("injected")))); // And it is decoupled from the backing list: recording more does not // grow the previously-returned snapshot. - buffer.record(SESSION, event("b")); + buffer.record(SESSION, event(AuditType.of("b"))); assertEquals("earlier peek snapshot must be decoupled", 1, first.size()); assertEquals("buffer itself reflects the new event", 2, buffer.peek(SESSION).size()); } @Test public void drainReturnsStagedEventsAndEmptiesSession() { - AuditEvent a = event("a"); - AuditEvent b = event("b"); + AuditEvent a = event(AuditType.of("a")); + AuditEvent b = event(AuditType.of("b")); buffer.record(SESSION, a); buffer.record(SESSION, b); @@ -133,8 +135,8 @@ public void drainReturnsNullWhenNothingStaged() { @Test public void drainIsScopedToTheRequestedSession() { - buffer.record(SESSION, event("a")); - buffer.record(OTHER_SESSION, event("x")); + buffer.record(SESSION, event(AuditType.of("a"))); + buffer.record(OTHER_SESSION, event(AuditType.of("x"))); buffer.drain(SESSION); assertNull(buffer.peek(SESSION)); @@ -145,22 +147,22 @@ public void drainIsScopedToTheRequestedSession() { @Test public void onRefreshDrainsSession() { - buffer.record(SESSION, event("a")); + buffer.record(SESSION, event(AuditType.of("a"))); buffer.onRefresh(SESSION); assertNull(buffer.peek(SESSION)); } @Test public void onCommitFailedDrainsSession() { - buffer.record(SESSION, event("a")); + buffer.record(SESSION, event(AuditType.of("a"))); buffer.onCommitFailed(SESSION); assertNull(buffer.peek(SESSION)); } @Test public void clearAllRemovesCurrentThreadEvents() { - buffer.record(SESSION, event("a")); - buffer.record(OTHER_SESSION, event("x")); + buffer.record(SESSION, event(AuditType.of("a"))); + buffer.record(OTHER_SESSION, event(AuditType.of("x"))); buffer.clearAll(); assertNull(buffer.peek(SESSION)); assertNull(buffer.peek(OTHER_SESSION)); @@ -180,7 +182,7 @@ public void overflowDropsBeyondCapAndWarnsOncePerSession() { log.starting(); try { for (int i = 0; i < AuditBuffer.MAX_EVENTS_PER_SESSION + 5; i++) { - buffer.record(SESSION, event("e" + i)); + buffer.record(SESSION, event(AuditType.of("e" + i))); } assertEquals("buffer must be capped at the maximum", AuditBuffer.MAX_EVENTS_PER_SESSION, buffer.peek(SESSION).size()); @@ -206,11 +208,11 @@ public void overflowWarningReArmsAfterDrain() { log.starting(); try { for (int i = 0; i <= AuditBuffer.MAX_EVENTS_PER_SESSION; i++) { - buffer.record(SESSION, event("first" + i)); + buffer.record(SESSION, event(AuditType.of("first" + i))); } buffer.drain(SESSION); for (int i = 0; i <= AuditBuffer.MAX_EVENTS_PER_SESSION; i++) { - buffer.record(SESSION, event("second" + i)); + buffer.record(SESSION, event(AuditType.of("second" + i))); } assertEquals("a fresh overflow episode after drain must WARN again", 2, log.getLogs().size()); @@ -229,7 +231,7 @@ public void overflowWarningReArmsAfterDrain() { */ @Test public void stagedEventsAreThreadConfined() throws InterruptedException { - buffer.record(SESSION, event("a")); + buffer.record(SESSION, event(AuditType.of("a"))); AtomicReference> otherPeek = new AtomicReference<>(); AtomicReference> otherDrain = new AtomicReference<>(); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java index 00ace1102bd..2425222cd9b 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java @@ -25,8 +25,10 @@ import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState; import org.mockito.Mockito; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; import org.apache.jackrabbit.oak.spi.state.NodeState; import org.apache.jackrabbit.oak.spi.toggle.Feature; @@ -77,8 +79,8 @@ public class AuditDrainObserverTest { private static final String SESSION_ID = "test-session-1"; private static final String USER_ID = "alice"; private static final NodeState ROOT = EmptyNodeState.EMPTY_NODE; - private static final String DOMAIN_A = "test.domain.a"; - private static final String DOMAIN_B = "test.domain.b"; + private static final AuditDomain DOMAIN_A = AuditDomain.of("test.domain.a"); + private static final AuditDomain DOMAIN_B = AuditDomain.of("test.domain.b"); private DefaultWhiteboard whiteboard; private Feature featureToggle; @@ -89,7 +91,7 @@ public class AuditDrainObserverTest { @Before public void setUp() { whiteboard = new DefaultWhiteboard(); - featureToggle = Feature.newFeature(AuditConfigurationImpl.FEATURE_TOGGLE_NAME, whiteboard); + featureToggle = Feature.newFeature(AuditPipeline.FEATURE_TOGGLE_NAME, whiteboard); buffer = new AuditBuffer(); registry = new WhiteboardAuditEventListenerRegistry(); registry.start(whiteboard); @@ -122,7 +124,7 @@ public void tearDown() { public void externalCommitShortCircuitsBeforeDrain() { setToggle(true); CapturingListener listener = registerCapturingListener(DOMAIN_A); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, externalCommit()); @@ -149,7 +151,7 @@ public void toggleDisabledDrainsButDoesNotDispatch() { // Toggle defaults to disabled — explicit setToggle(false) for clarity. setToggle(false); CapturingListener listener = registerCapturingListener(DOMAIN_A); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); @@ -186,7 +188,7 @@ public void emptyBufferIsNoOp() { public void noListenersShortCircuitsAfterDrain() { setToggle(true); // No listener registered. - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); @@ -212,20 +214,20 @@ public void groupByDomainSendsEachListenerOnlyItsDomain() { CapturingListener listenerB = registerCapturingListener(DOMAIN_B); // Interleave A and B events to verify capture-order preservation. - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "a-1")); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_B, "b-1")); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "a-2")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("a-1"))); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_B, AuditType.of("b-1"))); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("a-2"))); observer.contentChanged(ROOT, localCommit()); assertEquals("listener-A must receive 2 events in capture order", 2, listenerA.received.size()); - assertEquals("a-1", listenerA.received.get(0).getType()); - assertEquals("a-2", listenerA.received.get(1).getType()); + assertEquals("a-1", listenerA.received.get(0).getType().name()); + assertEquals("a-2", listenerA.received.get(1).getType().name()); assertEquals("listener-B must receive 1 event", 1, listenerB.received.size()); - assertEquals("b-1", listenerB.received.get(0).getType()); + assertEquals("b-1", listenerB.received.get(0).getType().name()); } /** @@ -240,14 +242,14 @@ public void multipleListenersPerDomainDispatchedInRankOrder() { setToggle(true); List timeline = new ArrayList<>(); AuditEventListener high = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN_A; } @Override public int getRank() { return 100; } @Override public void onEvents(@NotNull List events) { timeline.add("high"); } }; AuditEventListener low = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN_A; } @Override public int getRank() { return 1; } @Override public void onEvents(@NotNull List events) { timeline.add("low"); @@ -257,7 +259,7 @@ public void multipleListenersPerDomainDispatchedInRankOrder() { whiteboard.register(AuditEventListener.class, low, Map.of()); whiteboard.register(AuditEventListener.class, high, Map.of()); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); assertEquals("both listeners must receive", List.of("high", "low"), timeline); @@ -303,7 +305,7 @@ public void listenerNoClassDefFoundErrorDoesNotPreventOtherListeners() { private void verifyListenerErrorIsolated(@NotNull Throwable thrown) { setToggle(true); AuditEventListener throwingFirst = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN_A; } @Override public int getRank() { return 100; } // dispatched first @Override public void onEvents(@NotNull List events) { rethrow(thrown); @@ -312,7 +314,7 @@ private void verifyListenerErrorIsolated(@NotNull Throwable thrown) { whiteboard.register(AuditEventListener.class, throwingFirst, Map.of()); CapturingListener okSecond = registerCapturingListener(DOMAIN_A); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); assertEquals("second listener must receive despite first listener throwing " @@ -333,7 +335,7 @@ public void listenerGetDomainThrowableDoesNotStarvePeerListeners() { setToggle(true); AtomicBoolean brokenInvoked = new AtomicBoolean(); AuditEventListener brokenDomain = new AuditEventListener() { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { throw new LinkageError("synthetic-getDomain"); } @Override public int getRank() { return 100; } // routed first @@ -344,7 +346,7 @@ public void listenerGetDomainThrowableDoesNotStarvePeerListeners() { whiteboard.register(AuditEventListener.class, brokenDomain, Map.of()); CapturingListener okSecond = registerCapturingListener(DOMAIN_A); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); assertEquals("healthy listener must receive despite peer's broken getDomain()", @@ -379,10 +381,10 @@ public void poisonedEventGetDomainThrowsCaughtByOuterBarrier() { // The exception propagates out of doContentChanged into the outer // catch in contentChanged. AuditEvent poison = new AuditEvent() { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { throw new RuntimeException("synthetic-poisoned-event"); } - @Override public @NotNull String getType() { return "type-poison"; } + @Override public @NotNull AuditType getType() { return AuditType.of("type-poison"); } @Override public long getTimestamp() { return 0L; } }; buffer.record(SESSION_ID, poison); @@ -483,12 +485,12 @@ public void successfulCommitDrainsAndDecoratesAndDispatches() { setToggle(true); CapturingListener listener = registerCapturingListener(DOMAIN_A); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1", Map.of("k", "v"))); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"), Map.of("k", "v"))); observer.contentChanged(ROOT, localCommit()); assertEquals("listener must receive exactly one event", 1, listener.received.size()); AuditEvent received = listener.received.get(0); - assertEquals("event type must round-trip", "type-1", received.getType()); + assertEquals("event type must round-trip", "type-1", received.getType().name()); Map payload = received.getPayload(); // Original payload is preserved. @@ -519,10 +521,10 @@ public void listenerReceivesImmutableEventList() { setToggle(true); AtomicReference caught = new AtomicReference<>(); AuditEventListener mutating = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN_A; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN_A; } @Override public void onEvents(@NotNull List events) { try { - events.add(AuditEvent.of(DOMAIN_A, "injected")); + events.add(AuditEvent.of(DOMAIN_A, AuditType.of("injected"))); } catch (Throwable t) { caught.set(t); } @@ -530,7 +532,7 @@ public void listenerReceivesImmutableEventList() { }; whiteboard.register(AuditEventListener.class, mutating, Map.of()); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "type-1")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("type-1"))); observer.contentChanged(ROOT, localCommit()); assertNotNull("listener's mutation attempt must have been rejected", caught.get()); @@ -560,7 +562,7 @@ public void toggleFlipMidFlightDoesNotLeakStaleEvent() { // Commit #1: capture E1 with toggle ON, observer fires with toggle OFF. setToggle(true); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "E1", Map.of("trace.id", "E1"))); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("E1"), Map.of("trace.id", "E1"))); setToggle(false); observer.contentChanged(ROOT, localCommit()); @@ -570,13 +572,13 @@ public void toggleFlipMidFlightDoesNotLeakStaleEvent() { // Commit #2: toggle back ON, capture E2, observer fires. Only E2. setToggle(true); - buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, "E2", Map.of("trace.id", "E2"))); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN_A, AuditType.of("E2"), Map.of("trace.id", "E2"))); observer.contentChanged(ROOT, localCommit()); assertEquals("exactly one event must be delivered on commit #2", 1, listener.received.size()); assertEquals("delivered event must be E2, not the stale E1", - "E2", listener.received.get(0).getType()); + "E2", listener.received.get(0).getType().name()); assertEquals("E2", listener.received.get(0).getPayload().get("trace.id")); } @@ -611,7 +613,7 @@ private void setToggle(boolean enabled) { Tracker tracker = whiteboard.track(FeatureToggle.class); try { for (FeatureToggle ft : tracker.getServices()) { - if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + if (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) { ft.setEnabled(enabled); } } @@ -620,7 +622,7 @@ private void setToggle(boolean enabled) { } } - private CapturingListener registerCapturingListener(@NotNull String domain) { + private CapturingListener registerCapturingListener(@NotNull AuditDomain domain) { CapturingListener l = new CapturingListener(domain); whiteboard.register(AuditEventListener.class, l, Map.of()); return l; @@ -639,15 +641,15 @@ private static void rethrow(@NotNull Throwable t) throws T private static final class CapturingListener implements AuditEventListener { - private final String domain; + private final AuditDomain domain; final List received = new ArrayList<>(); - CapturingListener(@NotNull String domain) { + CapturingListener(@NotNull AuditDomain domain) { this.domain = domain; } @Override - public @NotNull String getDomain() { + public @NotNull AuditDomain getDomain() { return domain; } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java index 5d1421ea4e1..c4c792d2d1e 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java @@ -21,8 +21,10 @@ import java.util.concurrent.atomic.AtomicReference; import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.jetbrains.annotations.NotNull; import org.junit.After; import org.junit.Before; @@ -41,7 +43,7 @@ public void installSink() { dispatched = new AtomicReference<>(); AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return true; } - @Override public boolean isEnabledFor(@NotNull String domain) { return "yes".equals(domain); } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return "yes".equals(domain.name()); } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* unused */ } @Override public void dispatch(@NotNull AuditEvent event) { dispatched.set(event); } }); @@ -52,10 +54,10 @@ public void tearDown() { AuditEvents.install(null); } - private static AuditEvent fixedEvent(@NotNull String domain) { + private static AuditEvent fixedEvent(@NotNull AuditDomain domain) { return new AuditEvent() { - @Override public @NotNull String getDomain() { return domain; } - @Override public @NotNull String getType() { return "t"; } + @Override public @NotNull AuditDomain getDomain() { return domain; } + @Override public @NotNull AuditType getType() { return AuditType.of("t"); } @Override public long getTimestamp() { return 0L; } @Override public @NotNull Map getPayload() { return Collections.emptyMap(); } }; @@ -64,7 +66,7 @@ private static AuditEvent fixedEvent(@NotNull String domain) { @Test public void emitRoutesToFacadeDispatch() { AuditEventEmitterImpl impl = new AuditEventEmitterImpl(); - AuditEvent e = fixedEvent("yes"); + AuditEvent e = fixedEvent(AuditDomain.of("yes")); impl.emit(e); assertSame(e, dispatched.get()); } @@ -72,7 +74,7 @@ public void emitRoutesToFacadeDispatch() { @Test public void isEnabledForRoutesToFacade() { AuditEventEmitterImpl impl = new AuditEventEmitterImpl(); - assertTrue(impl.isEnabledFor("yes")); - assertFalse(impl.isEnabledFor("no")); + assertTrue(impl.isEnabledFor(AuditDomain.of("yes"))); + assertFalse(impl.isEnabledFor(AuditDomain.of("no"))); } } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java index 3f394c55649..184bdab6107 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java @@ -34,9 +34,11 @@ import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.Observable; import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; import org.apache.jackrabbit.oak.spi.security.SecurityProvider; @@ -74,8 +76,8 @@ * (asynchronous observation, MongoDB-backed, must be guarded behind a * Mongo-available check) variants belong in a module that already depends on * those stores — {@code oak-jcr} or {@code oak-it}. The audit wiring used - * here is fully public ({@link AuditConfigurationImpl#initialize}, - * {@link AuditConfigurationImpl#getDrainObserver}, {@link AuditEvents}, + * here is fully public ({@link AuditPipeline#initialize}, + * {@link AuditPipeline#getDrainObserver}, {@link AuditEvents}, * {@link AuditEventListener}), so this class can be lifted there as-is and * the extra fixtures added to {@link #fixtures()}. The DOCUMENT_NS row will * additionally need to await asynchronous dispatch (the drain observer is @@ -85,8 +87,8 @@ @RunWith(Parameterized.class) public class AuditFixtureTest { - private static final String DOMAIN = "test.domain"; - private static final String FEATURE_TOGGLE_NAME = AuditConfigurationImpl.FEATURE_TOGGLE_NAME; + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); + private static final String FEATURE_TOGGLE_NAME = AuditPipeline.FEATURE_TOGGLE_NAME; /** * Supplies a fresh {@link NodeStore} for each test run of a fixture. @@ -109,7 +111,7 @@ public static Collection fixtures() { private final NodeStoreFactory storeFactory; private Whiteboard whiteboard; - private AuditConfigurationImpl auditConfig; + private AuditPipeline auditConfig; private Closeable drainObserverSubscription; private List received; private ContentRepository repository; @@ -125,7 +127,7 @@ public void setUp() { whiteboard = new DefaultWhiteboard(); received = new CopyOnWriteArrayList<>(); - auditConfig = new AuditConfigurationImpl(); + auditConfig = new AuditPipeline(); auditConfig.initialize(whiteboard); securityProvider = SecurityProviderBuilder.newBuilder() .withWhiteboard(whiteboard) @@ -137,7 +139,7 @@ public void setUp() { setToggle(true); AuditEventListener listener = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -190,10 +192,10 @@ private static Credentials adminCredentials() { return new SimpleCredentials("admin", "admin".toCharArray()); } - private static AuditEvent eventFor(@NotNull String type, @NotNull Map payload) { + private static AuditEvent eventFor(@NotNull AuditType type, @NotNull Map payload) { return new AuditEvent() { - @Override public @NotNull String getDomain() { return DOMAIN; } - @Override public @NotNull String getType() { return type; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } + @Override public @NotNull AuditType getType() { return type; } @Override public long getTimestamp() { return System.currentTimeMillis(); } @Override public @NotNull Map getPayload() { return payload; } }; @@ -203,16 +205,16 @@ private static AuditEvent eventFor(@NotNull String type, @NotNull Map * Two test layers: @@ -101,18 +102,18 @@ * InOrder test injects spies via the package-private fields exposed for * test access (production callers MUST NOT touch those fields). */ -public class AuditConfigurationImplTest { +public class AuditPipelineLifecycleTest { @Rule public final OsgiContext osgiContext = new OsgiContext(); private Whiteboard whiteboard; - private AuditConfigurationImpl config; + private AuditPipeline config; @Before public void setUp() { whiteboard = new DefaultWhiteboard(); - config = new AuditConfigurationImpl(); + config = new AuditPipeline(); } @After @@ -124,7 +125,7 @@ public void tearDown() { // // Special-case the (g) misuse test: dispose() throws if // observerRegistration is still non-null. The test's local - // AuditConfigurationImpl instance is a different object — the + // AuditPipeline instance is a different object — the // @Before-created config is untouched and disposes cleanly. config.dispose(); } @@ -187,8 +188,8 @@ public void isActiveReturnsFalseAfterDispose() { */ @Test public void activateRegistersObserverService() { - AuditConfigurationImpl audit = osgiContext.registerInjectActivateService( - new AuditConfigurationImpl()); + AuditPipeline audit = osgiContext.registerInjectActivateService( + new AuditPipeline()); try { Observer registered = osgiContext.getService(Observer.class); assertNotNull("@Activate must register an Observer service", registered); @@ -207,8 +208,8 @@ public void activateRegistersObserverService() { */ @Test public void deactivateUnregistersObserverService() { - AuditConfigurationImpl audit = osgiContext.registerInjectActivateService( - new AuditConfigurationImpl()); + AuditPipeline audit = osgiContext.registerInjectActivateService( + new AuditPipeline()); assertNotNull("precondition: Observer service must be registered", osgiContext.getService(Observer.class)); @@ -219,12 +220,12 @@ public void deactivateUnregistersObserverService() { } /** - * Case (c) — {@link AuditConfigurationImpl#getDrainObserver()} returns + * Case (c) — {@link AuditPipeline#getDrainObserver()} returns * a non-null {@link Observer} after {@code initialize(...)} ran. */ @Test public void getDrainObserverReturnsObserverAfterInitialize() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); try { audit.initialize(whiteboard); Observer observer = audit.getDrainObserver(); @@ -236,7 +237,7 @@ public void getDrainObserverReturnsObserverAfterInitialize() { } /** - * Case (d) — {@link AuditConfigurationImpl#getDrainObserver()} returns + * Case (d) — {@link AuditPipeline#getDrainObserver()} returns * the SAME instance on repeat calls (singleton invariant). Regression * guard against accidental factory revert: the * {@link AuditBuffer#drain(String)} contract is destructive, so two @@ -246,7 +247,7 @@ public void getDrainObserverReturnsObserverAfterInitialize() { */ @Test public void getDrainObserverReturnsSameInstanceOnRepeatCalls() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); try { audit.initialize(whiteboard); Observer first = audit.getDrainObserver(); @@ -262,13 +263,13 @@ public void getDrainObserverReturnsSameInstanceOnRepeatCalls() { } /** - * Case (e) — {@link AuditConfigurationImpl#getDrainObserver()} throws + * Case (e) — {@link AuditPipeline#getDrainObserver()} throws * {@link IllegalStateException} when called before - * {@link AuditConfigurationImpl#initialize(Whiteboard)}. + * {@link AuditPipeline#initialize(Whiteboard)}. */ @Test public void getDrainObserverThrowsBeforeInitialize() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); try { audit.getDrainObserver(); fail("getDrainObserver() must throw IllegalStateException pre-initialize"); @@ -281,15 +282,15 @@ public void getDrainObserverThrowsBeforeInitialize() { } /** - * Case (f) — {@link AuditConfigurationImpl#getDrainObserver()} throws + * Case (f) — {@link AuditPipeline#getDrainObserver()} throws * {@link IllegalStateException} when called after - * {@link AuditConfigurationImpl#dispose()}. The singleton field is + * {@link AuditPipeline#dispose()}. The singleton field is * zeroed by {@code dispose()}'s step 5, so the same null-check that * pins case (e) covers this state too. */ @Test public void getDrainObserverThrowsAfterDispose() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); audit.initialize(whiteboard); // Confirm precondition — pre-dispose call must succeed. assertNotNull(audit.getDrainObserver()); @@ -307,7 +308,7 @@ public void getDrainObserverThrowsAfterDispose() { } /** - * Case (g) — {@link AuditConfigurationImpl#dispose()} throws + * Case (g) — {@link AuditPipeline#dispose()} throws * {@link IllegalStateException} when called with * {@code observerRegistration} still non-null. Defense-in-depth * precondition guard: @@ -324,7 +325,7 @@ public void getDrainObserverThrowsAfterDispose() { */ @Test public void disposeThrowsIfObserverRegistrationStillSet() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); audit.initialize(whiteboard); // Simulate the OSGi misuse path: observerRegistration was set by // @Activate but @Deactivate's unregister step never ran. The field @@ -343,7 +344,7 @@ public void disposeThrowsIfObserverRegistrationStillSet() { // any subsequent state) passes the precondition. Don't dispose // here; the misuse-case path already left fields half-initialized // and another dispose call would compound the test's leak. Letting - // the AuditConfigurationImpl instance go out of scope is enough — + // the AuditPipeline instance go out of scope is enough — // the static AuditEvents/AuditBufferLifecycle sinks need explicit // cleanup though. audit.observerRegistration = null; @@ -365,7 +366,7 @@ public void disposeThrowsIfObserverRegistrationStillSet() { */ @Test public void deactivateRunsTearDownStepsInOrder() { - AuditConfigurationImpl audit = new AuditConfigurationImpl(); + AuditPipeline audit = new AuditPipeline(); audit.initialize(whiteboard); // Replace internal collaborators with spies/mocks so InOrder can @@ -401,14 +402,14 @@ public void deactivateRunsTearDownStepsInOrder() { /** * Flips the FT_OAK-12331 feature toggle by locating the {@link FeatureToggle} - * service that {@link AuditConfigurationImpl#initialize(Whiteboard) + * service that {@link AuditPipeline#initialize(Whiteboard) * initialize} registered on the whiteboard. */ private void setToggle(boolean enabled) { Tracker tracker = whiteboard.track(FeatureToggle.class); try { for (FeatureToggle ft : tracker.getServices()) { - if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + if (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) { ft.setEnabled(enabled); } } @@ -421,8 +422,8 @@ private void registerTestListener() { AuditEventListener listener = new AuditEventListener() { @NotNull @Override - public String getDomain() { - return "test.isActive.coverage"; + public AuditDomain getDomain() { + return AuditDomain.of("test.isActive.coverage"); } @Override diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java index e1ad24e9e39..df8922db528 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java @@ -37,10 +37,12 @@ import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; import org.apache.jackrabbit.oak.spi.commit.DefaultValidator; import org.apache.jackrabbit.oak.spi.commit.EmptyHook; @@ -71,12 +73,12 @@ /** * End-to-end integration test exercising both audit pipelines through a - * direct {@link AuditConfigurationImpl} install. Uses {@link MemoryNodeStore} + * direct {@link AuditPipeline} install. Uses {@link MemoryNodeStore} * for a real but in-process Oak instance. *

      * Wiring path: *

        - *
      1. {@link AuditConfigurationImpl#initialize(Whiteboard)} installs the + *
      2. {@link AuditPipeline#initialize(Whiteboard)} installs the * audit feature toggle, listener registry, buffer and capture-time * sink onto the whiteboard.
      3. *
      4. {@code store.addObserver(audit.getDrainObserver())} attaches the @@ -86,7 +88,7 @@ * default anonymous-override whiteboard and bypasses the auto-attach * at {@code Oak.java:300-302}.
      5. *
      6. Teardown closes the {@code Observable.addObserver} {@code Closeable} - * and calls {@link AuditConfigurationImpl#dispose()} — the same code + * and calls {@link AuditPipeline#dispose()} — the same code * path that OSGi {@code @Deactivate} uses.
      7. *
      * No test mirror of {@code BufferSink} or observer wiring exists in this @@ -94,12 +96,12 @@ */ public class AuditPipelineTest { - private static final String DOMAIN = "test.domain"; - private static final String OTHER_DOMAIN = "other.domain"; - private static final String FEATURE_TOGGLE_NAME = AuditConfigurationImpl.FEATURE_TOGGLE_NAME; + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); + private static final AuditDomain OTHER_DOMAIN = AuditDomain.of("other.domain"); + private static final String FEATURE_TOGGLE_NAME = AuditPipeline.FEATURE_TOGGLE_NAME; private Whiteboard whiteboard; - private AuditConfigurationImpl auditConfig; + private AuditPipeline auditConfig; private Closeable drainObserverSubscription; private Registration listenerRegistration; private List received; @@ -112,7 +114,7 @@ public void setUp() { whiteboard = new DefaultWhiteboard(); received = new CopyOnWriteArrayList<>(); - auditConfig = new AuditConfigurationImpl(); + auditConfig = new AuditPipeline(); // initialize() installs sinks/registry/buffer/toggle; the drain Observer // is attached per-store below via Observable.addObserver(...). auditConfig.initialize(whiteboard); @@ -126,7 +128,7 @@ public void setUp() { ConfigurationUtil.getDefaultConfiguration(ConfigurationParameters.EMPTY)); // Flip the feature toggle ON via the FeatureToggle service the - // AuditConfigurationImpl.initialize() call registered on the + // AuditPipeline.initialize() call registered on the // whiteboard. setToggle(true); @@ -134,7 +136,7 @@ public void setUp() { // verification. Single listener tests use DOMAIN; multi-listener // tests register additional listeners inline. AuditEventListener listener = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -193,12 +195,12 @@ private void setToggle(boolean enabled) { } } - private static AuditEvent eventFor(@NotNull String domain, - @NotNull String type, + private static AuditEvent eventFor(@NotNull AuditDomain domain, + @NotNull AuditType type, @NotNull Map payload) { return new AuditEvent() { - @Override public @NotNull String getDomain() { return domain; } - @Override public @NotNull String getType() { return type; } + @Override public @NotNull AuditDomain getDomain() { return domain; } + @Override public @NotNull AuditType getType() { return type; } @Override public long getTimestamp() { return System.currentTimeMillis(); } @Override public @NotNull Map getPayload() { return payload; } }; @@ -212,19 +214,19 @@ private ContentSession login() throws Exception { @Test public void fireAndForgetEventCarriesNoCommitMetadata() { - emitter.emit(eventFor(DOMAIN, "forget", Map.of("key", "v"))); + emitter.emit(eventFor(DOMAIN, AuditType.of("forget"), Map.of("key", "v"))); assertEquals(1, received.size()); AuditEvent e = received.get(0); - assertEquals("forget", e.getType()); + assertEquals("forget", e.getType().name()); assertFalse("fire-and-forget event must not carry commit.sessionId", - e.getPayload().containsKey("commit.sessionId")); - assertFalse(e.getPayload().containsKey("commit.userId")); + e.getPayload().containsKey("oak.commit.sessionId")); + assertFalse(e.getPayload().containsKey("oak.commit.userId")); assertEquals("v", e.getPayload().get("key")); } @Test public void emitNoListenerForDomainIsNoOp() { - emitter.emit(eventFor(OTHER_DOMAIN, "x", Map.of())); + emitter.emit(eventFor(OTHER_DOMAIN, AuditType.of("x"), Map.of())); assertTrue(received.isEmpty()); } @@ -233,18 +235,18 @@ public void commitAttachedEventCarriesCommitMetadata() throws Exception { try (ContentSession session = login()) { Root root = session.getLatestRoot(); AuditEvents.record( - root, eventFor(DOMAIN, "commit.type", Map.of("note", "v"))); + root, eventFor(DOMAIN, AuditType.of("commit.type"), Map.of("note", "v"))); root.getTree("/").setProperty("scratch", "value"); root.commit(); assertEquals(1, received.size()); AuditEvent e = received.get(0); - assertEquals("commit.type", e.getType()); + assertEquals("commit.type", e.getType().name()); Map p = e.getPayload(); assertTrue("commit-attached event must carry commit.sessionId", - p.containsKey("commit.sessionId")); - assertTrue(p.containsKey("commit.userId")); - assertTrue(p.containsKey("commit.timestamp")); + p.containsKey("oak.commit.sessionId")); + assertTrue(p.containsKey("oak.commit.userId")); + assertTrue(p.containsKey("oak.commit.timestamp")); assertEquals("v", p.get("note")); } } @@ -269,26 +271,26 @@ public void commitAttachedEventCarriesCommitMetadata() throws Exception { */ @Test public void fireAndForgetStripsForgedCommitAttestationKeys() { - emitter.emit(eventFor(DOMAIN, "forged", Map.of( - "commit.sessionId", "forged-session", - "commit.userId", "forged-admin", - "commit.timestamp", 99999999L, + emitter.emit(eventFor(DOMAIN, AuditType.of("forged"), Map.of( + "oak.commit.sessionId", "forged-session", + "oak.commit.userId", "forged-admin", + "oak.commit.timestamp", 99999999L, "commit.custom", "passenger", "key", "v"))); assertEquals(1, received.size()); Map p = received.get(0).getPayload(); assertFalse("forged commit.sessionId must be stripped on fire-and-forget dispatch", - p.containsKey("commit.sessionId")); + p.containsKey("oak.commit.sessionId")); assertFalse("forged commit.userId must be stripped on fire-and-forget dispatch", - p.containsKey("commit.userId")); + p.containsKey("oak.commit.userId")); assertFalse("forged commit.timestamp must be stripped on fire-and-forget dispatch", - p.containsKey("commit.timestamp")); + p.containsKey("oak.commit.timestamp")); assertEquals("non-reserved commit.* keys are forwarded verbatim (untrusted)", "passenger", p.get("commit.custom")); assertEquals("ordinary payload entries are forwarded verbatim", "v", p.get("key")); assertEquals("domain must survive the strip", DOMAIN, received.get(0).getDomain()); - assertEquals("type must survive the strip", "forged", received.get(0).getType()); + assertEquals("type must survive the strip", "forged", received.get(0).getType().name()); } /** @@ -301,7 +303,7 @@ public void fireAndForgetStripsForgedCommitAttestationKeys() { */ @Test public void fireAndForgetCleanPayloadDispatchesSameEventInstance() { - AuditEvent clean = eventFor(DOMAIN, "clean", Map.of("key", "v")); + AuditEvent clean = eventFor(DOMAIN, AuditType.of("clean"), Map.of("key", "v")); emitter.emit(clean); assertEquals(1, received.size()); @@ -335,7 +337,7 @@ public void commitFailureDiscardsStagedEvents() throws Exception { // Stage E1, then force commit failure via the trigger property. Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "discarded", + r1, eventFor(DOMAIN, AuditType.of("discarded"), Map.of("trace.id", "E1-from-failed-commit"))); r1.getTree("/").setProperty("trigger-failure", "boom"); try { @@ -348,18 +350,18 @@ r1, eventFor(DOMAIN, "discarded", // Subsequent successful commit on the SAME session. Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered", + r2, eventFor(DOMAIN, AuditType.of("delivered"), Map.of("trace.id", "E2-from-successful-commit"))); r2.getTree("/").setProperty("scratch", "value"); r2.commit(); assertEquals("only E2 must be delivered", 1, received.size()); AuditEvent d = received.get(0); - assertEquals("event type is E2's", "delivered", d.getType()); + assertEquals("event type is E2's", "delivered", d.getType().name()); assertEquals("payload is E2's, not E1's or merged", "E2-from-successful-commit", d.getPayload().get("trace.id")); assertEquals("commit.sessionId decorates with current session", - session.toString(), d.getPayload().get("commit.sessionId")); + session.toString(), d.getPayload().get("oak.commit.sessionId")); } finally { observer2.close(); if (repo2 instanceof Closeable) { @@ -377,21 +379,21 @@ public void refreshDiscardsStagedEvents() throws Exception { try (ContentSession session = login()) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "discarded", + r1, eventFor(DOMAIN, AuditType.of("discarded"), Map.of("trace.id", "E1-discarded-by-refresh"))); r1.refresh(); // After refresh, dispatch a fresh event and commit. Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered", + r2, eventFor(DOMAIN, AuditType.of("delivered"), Map.of("trace.id", "E2-after-refresh"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); assertEquals("only E2 must be delivered", 1, received.size()); AuditEvent d = received.get(0); - assertEquals("delivered", d.getType()); + assertEquals("delivered", d.getType().name()); assertEquals("E2-after-refresh", d.getPayload().get("trace.id")); } } @@ -414,13 +416,13 @@ public void rebasePreservesStagedEvents() throws Exception { try (ContentSession session = login()) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "preserved", + r1, eventFor(DOMAIN, AuditType.of("preserved"), Map.of("trace.id", "E1-survives-rebase"))); r1.rebase(); Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered", + r2, eventFor(DOMAIN, AuditType.of("delivered"), Map.of("trace.id", "E2-after-rebase"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -440,7 +442,7 @@ r2, eventFor(DOMAIN, "delivered", // across a gate transition: the audit gate flips OFF between capture and // the lifecycle event, then ON again before the next commit. The gate // factors as {@code featureToggle.isEnabled() && registry.hasAnyListener()} - // (see {@code AuditConfigurationImpl.BufferSink.isEnabled}) so it can flip + // (see {@code AuditPipeline.BufferSink.isEnabled}) so it can flip // OFF via two functionally identical sources: // 1. Toggle flicker — {@code FT_OAK-12331} flipped off at runtime. // 2. Listener churn — the only registered listener deregisters. @@ -470,7 +472,7 @@ public void refreshDiscardsStagedEventsAcrossToggleFlicker() throws Exception { // Capture E1 with gate=ON. Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-r1"), Map.of("trace.id", "E1-must-not-leak-via-toggle"))); // Flip gate OFF via toggle. @@ -486,7 +488,7 @@ r1, eventFor(DOMAIN, "staged-by-r1", // Capture E2 and commit on the SAME session. Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -498,7 +500,7 @@ r2, eventFor(DOMAIN, "delivered-by-r2", assertEquals("only E2 must be delivered; E1 must NOT survive the toggle-flicker", 1, received.size()); AuditEvent d = received.get(0); - assertEquals("delivered-by-r2", d.getType()); + assertEquals("delivered-by-r2", d.getType().name()); assertEquals("E2-current", d.getPayload().get("trace.id")); } } @@ -515,7 +517,7 @@ public void rebasePreservesStagedEventsAcrossToggleFlicker() throws Exception { try (ContentSession session = login()) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-r1"), Map.of("trace.id", "E1-survives-rebase-toggle"))); setToggle(false); @@ -524,7 +526,7 @@ r1, eventFor(DOMAIN, "staged-by-r1", Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -559,7 +561,7 @@ public void commitFailureDiscardsStagedEventsAcrossToggleFlicker() throws Except // Capture E1 with gate=ON. Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-failed-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-failed-r1"), Map.of("trace.id", "E1-must-not-leak-via-toggle-failure"))); // Flip gate OFF, then trigger a deterministic commit failure. @@ -579,7 +581,7 @@ r1, eventFor(DOMAIN, "staged-by-failed-r1", Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -610,7 +612,7 @@ public void refreshDiscardsStagedEventsAcrossListenerChurn() throws Exception { try (ContentSession session = login()) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-r1"), Map.of("trace.id", "E1-must-not-leak-via-listener-churn"))); // Flip gate OFF via listener deregistration. @@ -623,7 +625,7 @@ r1, eventFor(DOMAIN, "staged-by-r1", // is observable downstream, not which listener instance sees it. listenerRegistration = whiteboard.register(AuditEventListener.class, new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -631,7 +633,7 @@ r1, eventFor(DOMAIN, "staged-by-r1", Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -654,14 +656,14 @@ public void rebasePreservesStagedEventsAcrossListenerChurn() throws Exception { try (ContentSession session = login()) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-r1"), Map.of("trace.id", "E1-survives-rebase-churn"))); listenerRegistration.unregister(); r1.rebase(); listenerRegistration = whiteboard.register(AuditEventListener.class, new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -669,7 +671,7 @@ r1, eventFor(DOMAIN, "staged-by-r1", Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -699,7 +701,7 @@ public void commitFailureDiscardsStagedEventsAcrossListenerChurn() throws Except try (ContentSession session = repo2.login(adminCredentials(), null)) { Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "staged-by-failed-r1", + r1, eventFor(DOMAIN, AuditType.of("staged-by-failed-r1"), Map.of("trace.id", "E1-must-not-leak-via-listener-churn-failure"))); listenerRegistration.unregister(); @@ -712,7 +714,7 @@ r1, eventFor(DOMAIN, "staged-by-failed-r1", } listenerRegistration = whiteboard.register(AuditEventListener.class, new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -720,7 +722,7 @@ r1, eventFor(DOMAIN, "staged-by-failed-r1", Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "delivered-by-r2", + r2, eventFor(DOMAIN, AuditType.of("delivered-by-r2"), Map.of("trace.id", "E2-current"))); r2.getTree("/").setProperty("scratch", "v"); r2.commit(); @@ -750,7 +752,7 @@ public void toggleDisabledShortCircuitsEntirePipeline() throws Exception { setToggle(false); // Fire-and-forget path: - emitter.emit(eventFor(DOMAIN, "forget", Map.of())); + emitter.emit(eventFor(DOMAIN, AuditType.of("forget"), Map.of())); assertTrue("fire-and-forget must short-circuit with toggle disabled", received.isEmpty()); @@ -758,7 +760,7 @@ public void toggleDisabledShortCircuitsEntirePipeline() throws Exception { try (ContentSession session = login()) { Root root = session.getLatestRoot(); AuditEvents.record( - root, eventFor(DOMAIN, "commit.type", Map.of())); + root, eventFor(DOMAIN, AuditType.of("commit.type"), Map.of())); root.getTree("/").setProperty("scratch", "v"); root.commit(); assertTrue("commit-attached must short-circuit with toggle disabled", @@ -775,7 +777,7 @@ public void toggleDisabledShortCircuitsEntirePipeline() throws Exception { public void multipleEventsAcrossDomainsGroupedCorrectly() throws Exception { List otherReceived = new CopyOnWriteArrayList<>(); AuditEventListener otherListener = new AuditEventListener() { - @Override public @NotNull String getDomain() { return OTHER_DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return OTHER_DOMAIN; } @Override public void onEvents(@NotNull List events) { otherReceived.addAll(events); } @@ -785,22 +787,22 @@ public void multipleEventsAcrossDomainsGroupedCorrectly() throws Exception { try (ContentSession session = login()) { Root root = session.getLatestRoot(); AuditEvents.record( - root, eventFor(DOMAIN, "a-1", Map.of())); + root, eventFor(DOMAIN, AuditType.of("a-1"), Map.of())); AuditEvents.record( - root, eventFor(OTHER_DOMAIN, "b-1", Map.of())); + root, eventFor(OTHER_DOMAIN, AuditType.of("b-1"), Map.of())); AuditEvents.record( - root, eventFor(DOMAIN, "a-2", Map.of())); + root, eventFor(DOMAIN, AuditType.of("a-2"), Map.of())); root.getTree("/").setProperty("scratch", "v"); root.commit(); assertEquals("DOMAIN listener receives 2 events in capture order", 2, received.size()); - assertEquals("a-1", received.get(0).getType()); - assertEquals("a-2", received.get(1).getType()); + assertEquals("a-1", received.get(0).getType().name()); + assertEquals("a-2", received.get(1).getType().name()); assertEquals("OTHER_DOMAIN listener receives 1 event", 1, otherReceived.size()); - assertEquals("b-1", otherReceived.get(0).getType()); + assertEquals("b-1", otherReceived.get(0).getType().name()); } finally { otherReg.unregister(); } @@ -820,22 +822,22 @@ public void bufferDrainedAfterSuccessfulCommit() throws Exception { // Commit #1: record E1, commit. Root r1 = session.getLatestRoot(); AuditEvents.record( - r1, eventFor(DOMAIN, "e1", Map.of("trace.id", "E1"))); + r1, eventFor(DOMAIN, AuditType.of("e1"), Map.of("trace.id", "E1"))); r1.getTree("/").setProperty("scratch1", "v"); r1.commit(); // Commit #2 on the same session: record E2, commit. Root r2 = session.getLatestRoot(); AuditEvents.record( - r2, eventFor(DOMAIN, "e2", Map.of("trace.id", "E2"))); + r2, eventFor(DOMAIN, AuditType.of("e2"), Map.of("trace.id", "E2"))); r2.getTree("/").setProperty("scratch2", "v"); r2.commit(); // Exactly two deliveries — E1 first, then E2. If drain were // broken after commit#1, we'd see [E1, E1, E2] = 3 events. assertEquals("buffer must be drained between commits", 2, received.size()); - assertEquals("first received is E1", "e1", received.get(0).getType()); - assertEquals("second received is E2", "e2", received.get(1).getType()); + assertEquals("first received is E1", "e1", received.get(0).getType().name()); + assertEquals("second received is E2", "e2", received.get(1).getType().name()); // The marker is the regression-guard: a re-dispatch would // duplicate "E1" at position 1, not produce a fresh "E2". assertNotEquals("position 1 must not be a stale E1", @@ -853,14 +855,14 @@ public void bufferDrainedAfterSuccessfulCommit() throws Exception { public void listenerRuntimeExceptionDoesNotPreventOtherListeners() throws Exception { List bReceived = new CopyOnWriteArrayList<>(); AuditEventListener throwingA = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 10; } // dispatched first @Override public void onEvents(@NotNull List events) { throw new RuntimeException("synthetic-A"); } }; AuditEventListener okB = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 5; } @Override public void onEvents(@NotNull List events) { bReceived.addAll(events); @@ -871,7 +873,7 @@ public void listenerRuntimeExceptionDoesNotPreventOtherListeners() throws Except try (ContentSession session = login()) { Root root = session.getLatestRoot(); AuditEvents.record( - root, eventFor(DOMAIN, "x", Map.of())); + root, eventFor(DOMAIN, AuditType.of("x"), Map.of())); root.getTree("/").setProperty("scratch", "v"); root.commit(); @@ -896,14 +898,14 @@ public void listenerRuntimeExceptionDoesNotPreventOtherListeners() throws Except public void listenerNoClassDefFoundErrorIsIsolated() throws Exception { List bReceived = new CopyOnWriteArrayList<>(); AuditEventListener throwingA = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 10; } @Override public void onEvents(@NotNull List events) { throw new NoClassDefFoundError("synthetic-A"); } }; AuditEventListener okB = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 5; } @Override public void onEvents(@NotNull List events) { bReceived.addAll(events); @@ -914,7 +916,7 @@ public void listenerNoClassDefFoundErrorIsIsolated() throws Exception { try (ContentSession session = login()) { Root root = session.getLatestRoot(); AuditEvents.record( - root, eventFor(DOMAIN, "x", Map.of())); + root, eventFor(DOMAIN, AuditType.of("x"), Map.of())); root.getTree("/").setProperty("scratch", "v"); root.commit(); @@ -934,14 +936,14 @@ public void listenerNoClassDefFoundErrorIsIsolated() throws Exception { public void fireAndForgetListenerRuntimeExceptionDoesNotPreventOthers() { List bReceived = new CopyOnWriteArrayList<>(); AuditEventListener throwingA = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 10; } @Override public void onEvents(@NotNull List events) { throw new RuntimeException("synthetic-A"); } }; AuditEventListener okB = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 5; } @Override public void onEvents(@NotNull List events) { bReceived.addAll(events); @@ -950,7 +952,7 @@ public void fireAndForgetListenerRuntimeExceptionDoesNotPreventOthers() { Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); try { - emitter.emit(eventFor(DOMAIN, "x", Map.of())); + emitter.emit(eventFor(DOMAIN, AuditType.of("x"), Map.of())); assertEquals("fire-and-forget: listener-B must receive despite A's RuntimeException", 1, bReceived.size()); } finally { @@ -966,14 +968,14 @@ public void fireAndForgetListenerRuntimeExceptionDoesNotPreventOthers() { public void fireAndForgetListenerNoClassDefFoundErrorIsIsolated() { List bReceived = new CopyOnWriteArrayList<>(); AuditEventListener throwingA = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 10; } @Override public void onEvents(@NotNull List events) { throw new NoClassDefFoundError("synthetic-A"); } }; AuditEventListener okB = new AuditEventListener() { - @Override public @NotNull String getDomain() { return DOMAIN; } + @Override public @NotNull AuditDomain getDomain() { return DOMAIN; } @Override public int getRank() { return 5; } @Override public void onEvents(@NotNull List events) { bReceived.addAll(events); @@ -982,7 +984,7 @@ public void fireAndForgetListenerNoClassDefFoundErrorIsIsolated() { Registration regA = whiteboard.register(AuditEventListener.class, throwingA, Map.of()); Registration regB = whiteboard.register(AuditEventListener.class, okB, Map.of()); try { - emitter.emit(eventFor(DOMAIN, "x", Map.of())); + emitter.emit(eventFor(DOMAIN, AuditType.of("x"), Map.of())); assertEquals("fire-and-forget: listener-B must receive despite A's NoClassDefFoundError", 1, bReceived.size()); } finally { @@ -1003,7 +1005,7 @@ public void fireAndForgetListenerNoClassDefFoundErrorIsIsolated() { @Test public void fireAndForgetBrokenGetDomainListenerDoesNotThrowToEmitter() { AuditEventListener brokenDomain = new AuditEventListener() { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { throw new LinkageError("synthetic-getDomain"); } @Override public int getRank() { return 10; } // consulted first @@ -1013,7 +1015,7 @@ public void fireAndForgetBrokenGetDomainListenerDoesNotThrowToEmitter() { }; Registration reg = whiteboard.register(AuditEventListener.class, brokenDomain, Map.of()); try { - emitter.emit(eventFor(DOMAIN, "x", Map.of("key", "v"))); + emitter.emit(eventFor(DOMAIN, AuditType.of("x"), Map.of("key", "v"))); assertEquals("healthy listener must receive despite peer's broken getDomain()", 1, received.size()); } finally { @@ -1031,7 +1033,7 @@ public void fireAndForgetBrokenGetDomainListenerDoesNotThrowToEmitter() { @Test public void captureGateIsEnabledForToleratesBrokenListener() { AuditEventListener brokenDomain = new AuditEventListener() { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { throw new LinkageError("synthetic-getDomain"); } @Override public void onEvents(@NotNull List events) { @@ -1043,7 +1045,7 @@ public void captureGateIsEnabledForToleratesBrokenListener() { // Unserved domain first — the full registry scan must consult // (and skip) the broken listener, never propagate its throw. assertFalse("gate must return false (not throw) for an unserved domain", - emitter.isEnabledFor("no.such.domain")); + emitter.isEnabledFor(AuditDomain.of("no.such.domain"))); assertTrue("gate must find the healthy fixture listener despite the broken peer", emitter.isEnabledFor(DOMAIN)); } finally { @@ -1087,13 +1089,13 @@ public void poisonedEventDoesNotMaskCommitAsFailure() throws Exception { // surfaces only at dispatch. AtomicInteger calls = new AtomicInteger(); AuditEvent counterPoison = new AuditEvent() { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { if (calls.incrementAndGet() <= 1) { return DOMAIN; } throw new RuntimeException("synthetic-drain-time-poison"); } - @Override public @NotNull String getType() { return "poison.type"; } + @Override public @NotNull AuditType getType() { return AuditType.of("poison.type"); } @Override public long getTimestamp() { return 0L; } @Override public @NotNull Map getPayload() { return Map.of(); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java index 3cb92155328..6f950d95154 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java @@ -29,15 +29,17 @@ import org.apache.jackrabbit.api.security.user.UserManager; import org.apache.jackrabbit.oak.InitialContentHelper; import org.apache.jackrabbit.oak.Oak; -import org.apache.jackrabbit.oak.security.audit.AuditConfigurationImpl; +import org.apache.jackrabbit.oak.security.audit.AuditPipeline; import org.apache.jackrabbit.oak.api.ContentRepository; import org.apache.jackrabbit.oak.api.ContentSession; import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.namepath.NamePathMapper; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; import org.apache.jackrabbit.oak.spi.security.SecurityProvider; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; @@ -76,7 +78,7 @@ public class AuditWiringTest { private Whiteboard whiteboard; - private AuditConfigurationImpl auditConfig; + private AuditPipeline auditConfig; private Closeable drainObserverSubscription; private List received; private ContentRepository repository; @@ -87,7 +89,7 @@ public void setUp() { whiteboard = new DefaultWhiteboard(); received = new CopyOnWriteArrayList<>(); - auditConfig = new AuditConfigurationImpl(); + auditConfig = new AuditPipeline(); // initialize() installs sinks/registry/buffer/toggle. The drain Observer // is attached to the MemoryNodeStore directly below; we can't rely on // Oak.with(Observer)'s auto-attach because .with(whiteboard) replaces @@ -105,7 +107,7 @@ public void setUp() { // Listener for the security domain — that's where the member-added event lands. AuditEventListener securityListener = new AuditEventListener() { - @Override public @NotNull String getDomain() { return SecurityAuditDomain.NAME; } + @Override public @NotNull AuditDomain getDomain() { return SecurityAuditDomain.DOMAIN; } @Override public void onEvents(@NotNull List events) { received.addAll(events); } @@ -142,7 +144,7 @@ private void setToggle(boolean enabled) { Tracker toggleTracker = whiteboard.track(FeatureToggle.class); try { for (FeatureToggle ft : toggleTracker.getServices()) { - if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + if (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) { ft.setEnabled(enabled); } } @@ -199,17 +201,17 @@ public void groupAddMemberFiresUserMemberAddedEndToEnd() throws Exception { assertEquals("exactly one member-added audit event must arrive", 1, received.size()); AuditEvent event = received.get(0); - assertEquals(SecurityAuditDomain.NAME, event.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, event.getDomain()); assertEquals(UserAuditTypes.MEMBER_ADDED, event.getType()); Map payload = event.getPayload(); // Commit metadata decorated by AuditDrainObserver (via CommitMetadataDecorator). assertTrue("commit.sessionId must be decorated", - payload.containsKey("commit.sessionId")); + payload.containsKey("oak.commit.sessionId")); assertTrue("commit.userId must be decorated", - payload.containsKey("commit.userId")); + payload.containsKey("oak.commit.userId")); assertTrue("commit.timestamp must be decorated", - payload.containsKey("commit.timestamp")); + payload.containsKey("oak.commit.timestamp")); // Event-specific payload — values, not just key presence, // so a future refactor that left the keys but lost the values // (e.g. wrong getPath() variable in the capture site) is caught. @@ -267,12 +269,12 @@ public void groupRemoveMemberFiresUserMemberRemovedEndToEnd() throws Exception { assertEquals("exactly one member-removed audit event must arrive", 1, received.size()); AuditEvent event = received.get(0); - assertEquals(SecurityAuditDomain.NAME, event.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, event.getDomain()); assertEquals(UserAuditTypes.MEMBER_REMOVED, event.getType()); Map payload = event.getPayload(); assertTrue("commit.sessionId must be decorated", - payload.containsKey("commit.sessionId")); + payload.containsKey("oak.commit.sessionId")); assertEquals(groupPath, payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); assertEquals(List.of(memberId), payload.get(UserAuditTypes.PAYLOAD_MEMBER_IDS)); assertEquals(List.of(memberPath), payload.get(UserAuditTypes.PAYLOAD_MEMBER_PATHS)); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java index 79b64a7c6ec..274853902b5 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java @@ -23,7 +23,9 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; import org.jetbrains.annotations.NotNull; import org.junit.Before; @@ -48,10 +50,10 @@ public void resetStripWarnLatch() { CommitMetadataDecorator.STRIP_WARNED.set(false); } - private static AuditEvent original(@NotNull String domain, @NotNull String type, @NotNull Map payload) { + private static AuditEvent original(@NotNull AuditDomain domain, @NotNull AuditType type, @NotNull Map payload) { return new AuditEvent() { - @Override public @NotNull String getDomain() { return domain; } - @Override public @NotNull String getType() { return type; } + @Override public @NotNull AuditDomain getDomain() { return domain; } + @Override public @NotNull AuditType getType() { return type; } @Override public long getTimestamp() { return 12345L; } @Override public @NotNull Map getPayload() { return payload; } }; @@ -59,15 +61,15 @@ private static AuditEvent original(@NotNull String domain, @NotNull String type, @Test public void decoratesPayloadWithCommitMetadata() { - AuditEvent in = original("oak.security", "member.added", Map.of("group", "/g", "member", "/m")); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("member.added"), Map.of("group", "/g", "member", "/m")); CommitInfo info = new CommitInfo("session-1", "alice", Map.of(), false); List out = CommitMetadataDecorator.decorate(List.of(in), info); assertEquals(1, out.size()); AuditEvent decorated = out.get(0); Map p = decorated.getPayload(); - assertEquals("session-1", p.get("commit.sessionId")); - assertEquals("alice", p.get("commit.userId")); - assertTrue(p.containsKey("commit.timestamp")); + assertEquals("session-1", p.get("oak.commit.sessionId")); + assertEquals("alice", p.get("oak.commit.userId")); + assertTrue(p.containsKey("oak.commit.timestamp")); // original payload entries preserved assertEquals("/g", p.get("group")); assertEquals("/m", p.get("member")); @@ -85,34 +87,34 @@ public void emptyEventsReturnsEmpty() { @Test public void preservesDomainTypeTimestamp() { - AuditEvent in = original("aem.content", "fragment.published", Map.of("path", "/p")); + AuditEvent in = original(AuditDomain.of("example.content"), AuditType.of("fragment.published"), Map.of("path", "/p")); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); - assertEquals("aem.content", decorated.getDomain()); - assertEquals("fragment.published", decorated.getType()); + assertEquals("example.content", decorated.getDomain().name()); + assertEquals("fragment.published", decorated.getType().name()); assertEquals(12345L, decorated.getTimestamp()); } @Test public void systemCommitUserIdIsOakUnknown() { - AuditEvent in = original("oak.security", "system.event", Map.of()); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("system.event"), Map.of()); // CommitInfo with null userId resolves to OAK_UNKNOWN CommitInfo info = CommitInfo.EMPTY; AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); // CommitInfo.OAK_UNKNOWN exposed via getUserId() for empty/system commits - assertEquals(CommitInfo.OAK_UNKNOWN, decorated.getPayload().get("commit.userId")); + assertEquals(CommitInfo.OAK_UNKNOWN, decorated.getPayload().get("oak.commit.userId")); } @Test public void preservesOrderOfEvents() { - AuditEvent a = original("oak.security", "a", Map.of()); - AuditEvent b = original("oak.security", "b", Map.of()); - AuditEvent c = original("oak.security", "c", Map.of()); + AuditEvent a = original(AuditDomain.of("oak.security"), AuditType.of("a"), Map.of()); + AuditEvent b = original(AuditDomain.of("oak.security"), AuditType.of("b"), Map.of()); + AuditEvent c = original(AuditDomain.of("oak.security"), AuditType.of("c"), Map.of()); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); List out = CommitMetadataDecorator.decorate(asList(a, b, c), info); - assertEquals("a", out.get(0).getType()); - assertEquals("b", out.get(1).getType()); - assertEquals("c", out.get(2).getType()); + assertEquals("a", out.get(0).getType().name()); + assertEquals("b", out.get(1).getType().name()); + assertEquals("c", out.get(2).getType().name()); } //--------------------------------------------< overwrite invariant tests >--- @@ -130,7 +132,7 @@ public void preservesOrderOfEvents() { @Test public void decoratorOverwritesCallerProvidedSessionId() { - AuditEvent in = original("oak.security", "x", + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "spoofed-session")); CommitInfo info = new CommitInfo("real-session", "alice", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); @@ -141,7 +143,7 @@ public void decoratorOverwritesCallerProvidedSessionId() { @Test public void decoratorOverwritesCallerProvidedUserId() { - AuditEvent in = original("oak.security", "x", + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of(CommitMetadataDecorator.KEY_USER_ID, "admin")); CommitInfo info = new CommitInfo("s", "alice", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); @@ -152,7 +154,7 @@ public void decoratorOverwritesCallerProvidedUserId() { @Test public void decoratorOverwritesCallerProvidedTimestamp() { - AuditEvent in = original("oak.security", "x", + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of(CommitMetadataDecorator.KEY_TIMESTAMP, 99999999L)); // CommitInfo's date is set internally to System.currentTimeMillis() // at construction; we read the actual value via getDate() to @@ -172,7 +174,7 @@ public void decoratorOverwritesCallerProvidedTimestamp() { */ @Test public void decoratorOverwritesAcrossValueTypes() { - AuditEvent in = original("oak.security", "x", + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of(CommitMetadataDecorator.KEY_TIMESTAMP, "definitely-a-string-not-a-long")); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); @@ -197,7 +199,7 @@ public void decoratorOverwritesAcrossValueTypes() { */ @Test public void decoratorOverwritesAllThreeOakAttestedKeysSimultaneously() { - AuditEvent in = original("oak.security", "x", Map.of( + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of( CommitMetadataDecorator.KEY_SESSION_ID, "spoofed-session", CommitMetadataDecorator.KEY_USER_ID, "spoofed-user", CommitMetadataDecorator.KEY_TIMESTAMP, 1L)); @@ -222,7 +224,7 @@ public void decoratorOverwritesAllThreeOakAttestedKeysSimultaneously() { */ @Test public void decoratorDoesNotProtectOtherCommitPrefixedKeys() { - AuditEvent in = original("oak.security", "x", Map.of("commit.custom", "caller-supplied")); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of("commit.custom", "caller-supplied")); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); Map p = CommitMetadataDecorator.decorate(List.of(in), info).get(0).getPayload(); // The three Oak-attested keys are added/overwritten... @@ -242,7 +244,7 @@ public void decoratorDoesNotProtectOtherCommitPrefixedKeys() { */ @Test public void decoratorAddsCommitKeysWhenAbsent() { - AuditEvent in = original("oak.security", "x", Map.of()); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of()); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); assertTrue(decorated.getPayload().containsKey(CommitMetadataDecorator.KEY_SESSION_ID)); @@ -254,7 +256,7 @@ public void decoratorAddsCommitKeysWhenAbsent() { @Test public void decoratedPayloadIsUnmodifiable() { - AuditEvent in = original("oak.security", "x", Map.of("k", "v")); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of("k", "v")); CommitInfo info = new CommitInfo("s", "u", Map.of(), false); AuditEvent decorated = CommitMetadataDecorator.decorate(List.of(in), info).get(0); try { @@ -274,7 +276,7 @@ public void decoratedPayloadIsUnmodifiable() { @Test public void stripRemovesAllThreeReservedKeysAndKeepsTheRest() { - AuditEvent in = original("oak.security", "x", Map.of( + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of( CommitMetadataDecorator.KEY_SESSION_ID, "forged-session", CommitMetadataDecorator.KEY_USER_ID, "forged-user", CommitMetadataDecorator.KEY_TIMESTAMP, 1L, @@ -293,8 +295,8 @@ public void stripRemovesAllThreeReservedKeysAndKeepsTheRest() { // Domain/type/capture-timestamp survive: the wrapper delegates, it // does NOT rebuild via AuditEvent.of() (which would reset the // timestamp to wall-clock now). - assertEquals("oak.security", stripped.getDomain()); - assertEquals("x", stripped.getType()); + assertEquals("oak.security", stripped.getDomain().name()); + assertEquals("x", stripped.getType().name()); assertEquals(12345L, stripped.getTimestamp()); // Input event untouched. assertEquals("forged-session", in.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID)); @@ -302,7 +304,7 @@ public void stripRemovesAllThreeReservedKeysAndKeepsTheRest() { @Test public void stripWithSingleReservedKeyStripsIt() { - AuditEvent in = original("oak.security", "x", Map.of( + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of( CommitMetadataDecorator.KEY_USER_ID, "forged-user", "k", "v")); Map p = CommitMetadataDecorator.stripReservedCommitKeys(in).getPayload(); @@ -313,7 +315,7 @@ public void stripWithSingleReservedKeyStripsIt() { @Test public void stripReturnsSameInstanceWhenNoReservedKeyPresent() { - AuditEvent in = original("oak.security", "x", Map.of("commit.custom", "passenger", "k", "v")); + AuditEvent in = original(AuditDomain.of("oak.security"), AuditType.of("x"), Map.of("commit.custom", "passenger", "k", "v")); assertSame("clean payloads must not be wrapped — concrete event type preserved", in, CommitMetadataDecorator.stripReservedCommitKeys(in)); } @@ -331,9 +333,9 @@ public void stripLogsWarnOnceWithKeyNamesButNeverValues() { .enable(Level.WARN).create(); log.starting(); try { - CommitMetadataDecorator.stripReservedCommitKeys(original("oak.security", "strip.warn.type", + CommitMetadataDecorator.stripReservedCommitKeys(original(AuditDomain.of("oak.security"), AuditType.of("strip.warn.type"), Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "forged-session-value", "k", "v"))); - CommitMetadataDecorator.stripReservedCommitKeys(original("oak.security", "strip.other.type", + CommitMetadataDecorator.stripReservedCommitKeys(original(AuditDomain.of("oak.security"), AuditType.of("strip.other.type"), Map.of(CommitMetadataDecorator.KEY_USER_ID, "forged-user-value"))); List logs = log.getLogs(); @@ -360,7 +362,7 @@ public void strippedPayloadIsUnmodifiable() { mutable.put(CommitMetadataDecorator.KEY_SESSION_ID, "forged"); mutable.put("k", "v"); AuditEvent stripped = CommitMetadataDecorator.stripReservedCommitKeys( - original("oak.security", "x", mutable)); + original(AuditDomain.of("oak.security"), AuditType.of("x"), mutable)); try { stripped.getPayload().put("newkey", "newvalue"); fail("stripped payload must be unmodifiable — caller mutation must throw"); @@ -382,8 +384,8 @@ public void strippedPayloadIsUnmodifiable() { public void stripSnapshotsPayloadEagerlyFromSingleConsult() { AtomicInteger consults = new AtomicInteger(); AuditEvent hostile = new AuditEvent() { - @Override public @NotNull String getDomain() { return "oak.security"; } - @Override public @NotNull String getType() { return "x"; } + @Override public @NotNull AuditDomain getDomain() { return AuditDomain.of("oak.security"); } + @Override public @NotNull AuditType getType() { return AuditType.of("x"); } @Override public long getTimestamp() { return 12345L; } @Override public @NotNull Map getPayload() { if (consults.incrementAndGet() == 1) { diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java index 3e217f1c350..1937aa65111 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java @@ -21,6 +21,7 @@ import java.util.Set; import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; @@ -39,13 +40,13 @@ public class WhiteboardAuditEventListenerRegistryTest { private static final class StubListener implements AuditEventListener { - private final String domain; + private final AuditDomain domain; private final int rank; - StubListener(String domain, int rank) { + StubListener(AuditDomain domain, int rank) { this.domain = domain; this.rank = rank; } - @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull AuditDomain getDomain() { return domain; } @Override public int getRank() { return rank; } @Override public void onEvents(@NotNull List events) { /* not exercised here */ } } @@ -58,7 +59,7 @@ public void emptyByDefault() { try { assertEquals(0, reg.getListeners().size()); assertFalse(reg.hasAnyListener()); - assertFalse(reg.hasListenerFor("oak.security")); + assertFalse(reg.hasListenerFor(AuditDomain.of("oak.security"))); } finally { reg.stop(); } @@ -70,10 +71,10 @@ public void registeredListenerIsListed() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("oak.security"), 0), Map.of()); assertEquals(1, reg.getListeners().size()); assertTrue(reg.hasAnyListener()); - assertTrue(reg.hasListenerFor("oak.security")); + assertTrue(reg.hasListenerFor(AuditDomain.of("oak.security"))); } finally { reg.stop(); } @@ -85,9 +86,9 @@ public void listenersSortedByRankDescending() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); - wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); - wb.register(AuditEventListener.class, new StubListener("d", 5), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 1), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 5), Map.of()); List sorted = reg.getListeners(); assertEquals(3, sorted.size()); assertEquals(10, sorted.get(0).getRank()); @@ -111,12 +112,12 @@ public void hasListenerForReflectsLiveRegistrations() { reg.start(wb); try { // First call when no listener for "oak.security" exists. - assertFalse(reg.hasListenerFor("oak.security")); + assertFalse(reg.hasListenerFor(AuditDomain.of("oak.security"))); // Register and re-check — must observe the new registration. - wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); - assertTrue(reg.hasListenerFor("oak.security")); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("oak.security"), 0), Map.of()); + assertTrue(reg.hasListenerFor(AuditDomain.of("oak.security"))); // Different domain must still return false. - assertFalse(reg.hasListenerFor("aem.content")); + assertFalse(reg.hasListenerFor(AuditDomain.of("example.content"))); } finally { reg.stop(); } @@ -145,11 +146,11 @@ public void stableOrderAmongEqualRanks() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - StubListener high = new StubListener("d", 10); - StubListener midA = new StubListener("d", 5); - StubListener midB = new StubListener("d", 5); - StubListener midC = new StubListener("d", 5); - StubListener low = new StubListener("d", 1); + StubListener high = new StubListener(AuditDomain.of("d"), 10); + StubListener midA = new StubListener(AuditDomain.of("d"), 5); + StubListener midB = new StubListener(AuditDomain.of("d"), 5); + StubListener midC = new StubListener(AuditDomain.of("d"), 5); + StubListener low = new StubListener(AuditDomain.of("d"), 1); wb.register(AuditEventListener.class, high, Map.of()); wb.register(AuditEventListener.class, midA, Map.of()); wb.register(AuditEventListener.class, midB, Map.of()); @@ -188,7 +189,7 @@ public void stableOrderAmongEqualRanks() { * missing transitive dependency produces at first call). */ private static final class ThrowingDomainListener implements AuditEventListener { - @Override public @NotNull String getDomain() { + @Override public @NotNull AuditDomain getDomain() { throw new LinkageError("synthetic-getDomain"); } @Override public int getRank() { return 100; } @@ -203,11 +204,11 @@ private static final class ThrowingDomainListener implements AuditEventListener * independently of the domain guard. */ private static final class ThrowingRankListener implements AuditEventListener { - private final String domain; - ThrowingRankListener(String domain) { + private final AuditDomain domain; + ThrowingRankListener(AuditDomain domain) { this.domain = domain; } - @Override public @NotNull String getDomain() { return domain; } + @Override public @NotNull AuditDomain getDomain() { return domain; } @Override public int getRank() { throw new RuntimeException("synthetic-getRank"); } @@ -231,7 +232,7 @@ public void hasListenerForToleratesThrowingGetDomain() { try { wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); assertFalse("broken listener must be skipped, not propagated", - reg.hasListenerFor("oak.security")); + reg.hasListenerFor(AuditDomain.of("oak.security"))); } finally { reg.stop(); } @@ -248,9 +249,9 @@ public void hasListenerForFindsHealthyListenerDespiteBrokenPeer() { reg.start(wb); try { wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); - wb.register(AuditEventListener.class, new StubListener("oak.security", 0), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("oak.security"), 0), Map.of()); assertTrue("healthy listener must be found despite broken peer", - reg.hasListenerFor("oak.security")); + reg.hasListenerFor(AuditDomain.of("oak.security"))); } finally { reg.stop(); } @@ -268,9 +269,9 @@ public void getListenersSkipsListenerWhoseGetRankThrows() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - wb.register(AuditEventListener.class, new ThrowingRankListener("d"), Map.of()); - wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); - wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); + wb.register(AuditEventListener.class, new ThrowingRankListener(AuditDomain.of("d")), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 1), Map.of()); List out = reg.getListeners(); assertEquals("broken-rank listener must be skipped", 2, out.size()); assertEquals(10, out.get(0).getRank()); @@ -291,7 +292,7 @@ public void getListenersSkipsThrowingGetRankEvenWhenAlone() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - wb.register(AuditEventListener.class, new ThrowingRankListener("d"), Map.of()); + wb.register(AuditEventListener.class, new ThrowingRankListener(AuditDomain.of("d")), Map.of()); assertTrue("a lone broken listener must be skipped, not returned", reg.getListeners().isEmpty()); } finally { @@ -316,8 +317,8 @@ public void brokenListenerIsLoggedAtWarnOncePerListener() { logs.starting(); try { wb.register(AuditEventListener.class, new ThrowingDomainListener(), Map.of()); - reg.hasListenerFor("oak.security"); - reg.hasListenerFor("oak.security"); + reg.hasListenerFor(AuditDomain.of("oak.security")); + reg.hasListenerFor(AuditDomain.of("oak.security")); reg.getListeners(); assertEquals("exactly one WARN per broken listener identity", 1, logs.getLogs().size()); @@ -337,11 +338,11 @@ public void getListenersReturnsImmutableList() { WhiteboardAuditEventListenerRegistry reg = new WhiteboardAuditEventListenerRegistry(); reg.start(wb); try { - wb.register(AuditEventListener.class, new StubListener("d", 10), Map.of()); - wb.register(AuditEventListener.class, new StubListener("d", 1), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 10), Map.of()); + wb.register(AuditEventListener.class, new StubListener(AuditDomain.of("d"), 1), Map.of()); List out = reg.getListeners(); try { - out.add(new StubListener("d", 0)); + out.add(new StubListener(AuditDomain.of("d"), 0)); fail("getListeners() must return an immutable list"); } catch (UnsupportedOperationException expected) { // contract honored @@ -363,14 +364,14 @@ public void unregisterRemovesListener() { reg.start(wb); try { Registration r = wb.register(AuditEventListener.class, - new StubListener("oak.security", 0), Map.of()); + new StubListener(AuditDomain.of("oak.security"), 0), Map.of()); assertEquals(1, reg.getListeners().size()); - assertTrue(reg.hasListenerFor("oak.security")); + assertTrue(reg.hasListenerFor(AuditDomain.of("oak.security"))); r.unregister(); assertEquals(0, reg.getListeners().size()); - assertFalse(reg.hasListenerFor("oak.security")); + assertFalse(reg.hasListenerFor(AuditDomain.of("oak.security"))); assertFalse(reg.hasAnyListener()); } finally { reg.stop(); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java index dd516f05aa7..8533f278172 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/internal/SecurityProviderRegistrationTest.java @@ -212,7 +212,7 @@ public void testActivateWithoutPreconditions() { assertNotNull(service); // 6 SecurityConfigurations: authentication, authorization, user, // privilege, principal, token. Audit is not a SecurityConfiguration — - // it's owned by AuditConfigurationImpl and registered separately as + // it's owned by AuditPipeline and registered separately as // an Observer service. assertEquals(6, IterableUtils.size(IterableUtils.filter(service.getConfigurations(), x -> x != null))); } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java index cc5b6e49398..4f3c607755d 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java @@ -22,7 +22,9 @@ import java.util.Map; import java.util.Set; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; import org.junit.Test; @@ -44,7 +46,7 @@ public void memberAddedReturnsExpectedDomainTypeAndPayload() { AuditEvent e = UserAuditEvents.memberAdded(GROUP, MEMBER_ID, MEMBER); assertNotNull(e); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); assertEquals( Map.of( @@ -60,7 +62,7 @@ public void memberAddedReturnsExpectedDomainTypeAndPayload() { public void memberRemovedReturnsExpectedDomainTypeAndPayload() { AuditEvent e = UserAuditEvents.memberRemoved(GROUP, MEMBER_ID, MEMBER); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); assertEquals( Map.of( @@ -80,7 +82,7 @@ public void membersAddedBulkCarriesMemberIdsAndFailedIds() { Set failed = Set.of("bad-id"); AuditEvent e = UserAuditEvents.membersAddedBulk(GROUP, members, false, failed); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); assertEquals(GROUP, e.getPayload().get(UserAuditTypes.PAYLOAD_GROUP_PATH)); assertEquals(UserAuditTypes.MEMBERSHIP_SOURCE_STATIC, @@ -137,7 +139,7 @@ public void membersRemovedBulkCarriesMemberIdsAndFailedIds() { Set failed = Set.of(); AuditEvent e = UserAuditEvents.membersRemovedBulk(GROUP, members, true, failed); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); assertEquals(GROUP, e.getPayload().get(UserAuditTypes.PAYLOAD_GROUP_PATH)); assertEquals(Boolean.TRUE, e.getPayload().get(UserAuditTypes.PAYLOAD_IS_CONTENT_ID)); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java index 3974a5ed955..d4fb46e11c2 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java @@ -30,8 +30,10 @@ import org.apache.jackrabbit.oak.AbstractSecurityTest; import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; import org.jetbrains.annotations.NotNull; @@ -81,7 +83,7 @@ public boolean isEnabled() { } @Override - public boolean isEnabledFor(@NotNull String domain) { + public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @@ -111,7 +113,7 @@ public void singleMemberAddRecordsEvent() throws Exception { userMgr.onGroupUpdate(group, false, user); assertEquals(1, recordedEvents.size()); AuditEvent e = recordedEvents.get(0); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); Map payload = e.getPayload(); assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); @@ -132,7 +134,7 @@ public void singleMemberRemoveRecordsEvent() throws Exception { userMgr.onGroupUpdate(group, true, user); assertEquals(1, recordedEvents.size()); AuditEvent e = recordedEvents.get(0); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); Map payload = e.getPayload(); assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); @@ -154,7 +156,7 @@ public void bulkMemberAddRecordsEvent() throws Exception { Collections.emptySet()); assertEquals(1, recordedEvents.size()); AuditEvent e = recordedEvents.get(0); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_ADDED, e.getType()); Map payload = e.getPayload(); assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); @@ -179,7 +181,7 @@ public void bulkMemberRemoveRecordsEvent() throws Exception { Collections.emptySet()); assertEquals(1, recordedEvents.size()); AuditEvent e = recordedEvents.get(0); - assertEquals(SecurityAuditDomain.NAME, e.getDomain()); + assertEquals(SecurityAuditDomain.DOMAIN, e.getDomain()); assertEquals(UserAuditTypes.MEMBER_REMOVED, e.getType()); Map payload = e.getPayload(); assertEquals(group.getPath(), payload.get(UserAuditTypes.PAYLOAD_GROUP_PATH)); @@ -197,7 +199,7 @@ public void auditDisabledShortCircuitsCapture() throws Exception { // Sink reports disabled — capture sites must short-circuit before record(). AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return false; } - @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return false; } @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { recordedEvents.add(event); } @@ -228,7 +230,7 @@ public void domainPreciseGateSkipsCaptureWhenNoSecurityListener() throws Excepti // this test fails on such a regression. AuditEvents.install(new AuditEvents.Sink() { @Override public boolean isEnabled() { return true; } - @Override public boolean isEnabledFor(@NotNull String domain) { return false; } + @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return false; } @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { recordedEvents.add(event); } diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java index a3791bf4e51..de237536efc 100644 --- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java +++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java @@ -49,9 +49,10 @@ import org.apache.jackrabbit.oak.plugins.document.rdb.RDBOptions; import org.apache.jackrabbit.oak.plugins.document.util.MongoConnection; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; -import org.apache.jackrabbit.oak.security.audit.AuditConfigurationImpl; +import org.apache.jackrabbit.oak.security.audit.AuditPipeline; import org.apache.jackrabbit.oak.security.internal.SecurityProviderBuilder; import org.apache.jackrabbit.oak.segment.Segment; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; import org.apache.jackrabbit.oak.spi.blob.BlobStore; @@ -107,7 +108,7 @@ public abstract class OakFixture { * every consumer that doesn't explicitly opt in. *

      * When the property is set, the fixture wires an - * {@link AuditConfigurationImpl} on a fresh {@link DefaultWhiteboard}, + * {@link AuditPipeline} on a fresh {@link DefaultWhiteboard}, * flips the {@code FT_OAK-12331} feature toggle ON, registers a no-op * {@link AuditEventListener} for the {@code oak.security} domain so * Oak's security capture sites actually allocate / buffer / dispatch, @@ -181,7 +182,7 @@ private static OakFixture getMemory(String name, final long cacheSize, final boo private Whiteboard whiteboard; private SecurityProvider securityProvider; - private AuditConfigurationImpl auditConfig; + private AuditPipeline auditConfig; private final List drainObserverSubscriptions = new ArrayList<>(); private synchronized void initAuditPipelineIfNeeded() { @@ -189,7 +190,7 @@ private synchronized void initAuditPipelineIfNeeded() { return; } whiteboard = new DefaultWhiteboard(); - auditConfig = new AuditConfigurationImpl(); + auditConfig = new AuditPipeline(); auditConfig.initialize(whiteboard); // Drain observer is attached per-store below via // store.addObserver(...). Oak.with(Observer) auto-attaches only @@ -202,7 +203,7 @@ private synchronized void initAuditPipelineIfNeeded() { Tracker tracker = whiteboard.track(FeatureToggle.class); try { for (FeatureToggle ft : tracker.getServices()) { - if (AuditConfigurationImpl.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + if (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) { ft.setEnabled(true); } } @@ -211,7 +212,7 @@ private synchronized void initAuditPipelineIfNeeded() { } whiteboard.register(AuditEventListener.class, - new BenchmarkNoopListener(SecurityAuditDomain.NAME), + new BenchmarkNoopListener(SecurityAuditDomain.DOMAIN), Map.of()); } @@ -702,19 +703,19 @@ static Oak newOak(NodeStore nodeStore) { * flips {@code AuditEvents.isEnabledFor(domain)} to {@code true} and * causes capture sites to allocate and buffer events — see the * {@code BufferSink.isEnabledFor} short-circuit in - * {@code AuditConfigurationImpl}. + * {@code AuditPipeline}. */ private static final class BenchmarkNoopListener implements AuditEventListener { - private final String domain; + private final AuditDomain domain; - BenchmarkNoopListener(@NotNull String domain) { + BenchmarkNoopListener(@NotNull AuditDomain domain) { this.domain = domain; } @NotNull @Override - public String getDomain() { + public AuditDomain getDomain() { return domain; } diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java index 2c0381c89d7..062876b987b 100644 --- a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java @@ -59,7 +59,7 @@ public void getOakWiresAuditPipelineOn() throws Exception { AuditEvents.isEnabled()); assertTrue("Capture sites in UserManagerImpl gate on isEnabledFor('security'); " + "must return true so audit-ON capture exercise the buffer path", - AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test @@ -71,7 +71,7 @@ public void setUpClusterAlsoWiresAuditPipeline() throws Exception { cluster.length == 2); assertTrue(AuditEvents.isEnabled()); - assertTrue(AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + assertTrue(AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test @@ -86,7 +86,7 @@ public void tearDownClusterDisposesAuditPipeline() throws Exception { assertFalse("After tearDownCluster, AuditEvents must route to NOOP", AuditEvents.isEnabled()); assertFalse("Domain-scoped probe must also revert to NOOP", - AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java index 77c209e8345..d02266da9eb 100644 --- a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java @@ -41,7 +41,7 @@ * mutate JVM-static state, so the tests defensively reset both in * {@link #before()} / {@link #after()} on top of the per-test * {@code fixture.tearDownCluster()} call (which calls - * {@code AuditConfigurationImpl.dispose()} — the production path that + * {@code AuditPipeline.dispose()} — the production path that * NOOPs both façades). That keeps the OFF assertions honest even if a * prior test in the same JVM leaked state. */ @@ -97,7 +97,7 @@ public void propertyAbsentLeavesGetMemoryNSAuditOff() throws Exception { assertFalse("default getMemoryNS(0) without the property must be audit-OFF", AuditEvents.isEnabled()); assertFalse("audit must remain OFF for every domain probe", - AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); } /** @@ -121,7 +121,7 @@ public void propertySetTrueEnablesAuditOnGetMemoryNS() throws Exception { AuditEvents.isEnabled()); assertTrue("the security-domain listener must be live so capture sites in " + "UserManagerImpl actually allocate / buffer / dispatch events", - AuditEvents.isEnabledFor(SecurityAuditDomain.NAME)); + AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); } /** diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java index ef6b788e3c9..6734eabe04f 100644 --- a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java @@ -16,6 +16,8 @@ */ package org.apache.jackrabbit.oak.spi.security.audit; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; + /** * Domain constant for events produced by Oak security modules. *

      @@ -29,14 +31,13 @@ public final class SecurityAuditDomain { /** - * Domain name for events produced by Oak security modules - * (user management, ACLs, principal management, tokens, etc.). - * Namespaced with the {@code oak.} prefix so listeners hosted in - * mixed environments (Sling, AEM, third-party bundles) can - * disambiguate Oak's security events from same-named domains - * defined by other layers. + * Domain for events produced by Oak security modules (user management, + * ACLs, principal management, tokens, etc.). Namespaced with the + * {@code oak.} prefix so listeners hosted alongside other layers can + * tell Oak's security events apart from same-named domains defined + * elsewhere. */ - public static final String NAME = "oak.security"; + public static final AuditDomain DOMAIN = AuditDomain.of("oak.security"); private SecurityAuditDomain() { // constants class diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java index d7eba32fe06..5ae14ae8a68 100644 --- a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java +++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java @@ -17,26 +17,27 @@ package org.apache.jackrabbit.oak.spi.security.user; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; /** - * Stable type-string constants and payload keys for user-management audit + * Stable event types and payload keys for user-management audit * events. All events declared here share the - * {@link SecurityAuditDomain#NAME oak.security} domain. + * {@link SecurityAuditDomain#DOMAIN oak.security} domain. *

      * Listener bundles discriminate user-management events by combining - * {@code event.getDomain().equals(SecurityAuditDomain.NAME)} with + * {@code event.getDomain().equals(SecurityAuditDomain.DOMAIN)} with * {@code event.getType().equals(UserAuditTypes.MEMBER_ADDED)} (or another * constant declared here). *

      - * A single membership add or remove and a bulk one share the same type + * A single membership add or remove and a bulk one share the same * string ({@link #MEMBER_ADDED} / {@link #MEMBER_REMOVED}); a bulk change is * simply an event whose {@link #PAYLOAD_MEMBER_IDS} list holds more than one * entry. Consumers that need a bulk/single flag derive it from the list size * rather than from a distinct type. *

      * Future sub-domains under {@code oak.security} (ACL, principal, token) - * declare their own type-string classes alongside their respective + * declare their own event-type classes alongside their respective * configuration packages. *

      * Asymmetric exposure. This class is the read-side @@ -66,14 +67,14 @@ public final class UserAuditTypes { * for single-member changes — {@link #PAYLOAD_MEMBER_PATHS}; bulk changes * additionally carry {@link #PAYLOAD_FAILED_IDS}. */ - public static final String MEMBER_ADDED = "membership.added"; + public static final AuditType MEMBER_ADDED = AuditType.of("membership.added"); /** * Recorded when one or more authorizables are removed from a group. * Single and bulk removes share this type; discriminate by the size of * {@link #PAYLOAD_MEMBER_IDS}. Payload keys: same as {@link #MEMBER_ADDED}. */ - public static final String MEMBER_REMOVED = "membership.removed"; + public static final AuditType MEMBER_REMOVED = AuditType.of("membership.removed"); // ── Payload keys ────────────────────────────────────────────────── diff --git a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java index 8d2b0881b9d..bf7dd6de446 100644 --- a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java +++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java @@ -26,8 +26,9 @@ public class SecurityAuditDomainTest { @Test - public void nameConstantIsStable() { - assertEquals("oak.security", SecurityAuditDomain.NAME); + public void domainConstantIsStable() { + // Pins the wire value: listener bundles match on it. + assertEquals("oak.security", SecurityAuditDomain.DOMAIN.name()); } @Test diff --git a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java index da00f4b6b17..581ad13f6ce 100644 --- a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java +++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Set; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -29,10 +30,10 @@ public class UserAuditTypesTest { @Test - public void allTypeStringsAreNonBlank() { - for (String value : typeStrings()) { - assertFalse("type string must not be blank: " + value, value.isBlank()); - } + public void typeConstantsAreStable() { + // Pins the wire values: listener bundles match on them. + assertEquals("membership.added", UserAuditTypes.MEMBER_ADDED.name()); + assertEquals("membership.removed", UserAuditTypes.MEMBER_REMOVED.name()); } @Test @@ -43,9 +44,9 @@ public void allPayloadKeysAreNonBlank() { } @Test - public void typeStringsAreUnique() { - List values = typeStrings(); - assertEquals("type strings must be unique", + public void typesAreUnique() { + List values = types(); + assertEquals("types must be unique", values.size(), Set.copyOf(values).size()); } @@ -65,7 +66,7 @@ public void privateConstructorIsReachableForCoverage() throws Exception { assertNotNull(ctor.newInstance()); } - private static List typeStrings() { + private static List types() { return List.of( UserAuditTypes.MEMBER_ADDED, UserAuditTypes.MEMBER_REMOVED); From 5afa5f872699b1abf1db63fe97fb0a01f9f26357 Mon Sep 17 00:00:00 2001 From: Andrei Dulvac Date: Tue, 4 Aug 2026 16:43:12 +0200 Subject: [PATCH 3/4] OAK-12331: add audit pipeline metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested in review on PR #3058: there was no way to see the pipeline from outside, and a slow listener runs on the commit thread, so it costs commit latency with nothing to point at. AuditMonitor wraps a StatisticsProvider and records: - security.audit.events;domain= — events dispatched per domain - security.audit.events.dropped;domain= — events discarded at the per-session buffer cap - security.audit.listener.duration;listener= — time in onEvents - security.audit.listener.failures;listener= — listener throws The provider is looked up on the whiteboard rather than injected as a DS reference, so OSGi and embedded callers share the one path through initialize(). Deployments without a provider get AuditMonitor.NOOP. Two counting decisions worth knowing about. An event is counted once per domain, not once per delivery, so N listeners on a domain do not multiply the rate. And it is counted only when a listener actually consumed it: a listener that unregisters between capture and drain leaves a domain in the grouped map that nothing consumed, and counting that would overstate the rate. Both are covered by AuditMonitorWiringTest. Recording sits inside the existing per-listener Throwable barriers, so a metrics failure cannot break a dispatch. A listener that throws is still timed: it burned commit-thread time before it threw. --- .../oak/security/audit/AuditBuffer.java | 16 ++ .../security/audit/AuditDrainObserver.java | 57 +++- .../oak/security/audit/AuditMonitor.java | 156 +++++++++++ .../oak/security/audit/AuditPipeline.java | 44 ++- .../oak/security/audit/AuditMonitorTest.java | 229 ++++++++++++++++ .../audit/AuditMonitorWiringTest.java | 253 ++++++++++++++++++ 6 files changed, 742 insertions(+), 13 deletions(-) create mode 100644 oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditMonitor.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorTest.java create mode 100644 oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorWiringTest.java diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java index 67711d92334..fbdf693a339 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java @@ -69,6 +69,21 @@ final class AuditBuffer implements AuditBufferLifecycle.Listener { */ static final int MAX_EVENTS_PER_SESSION = 10_000; + /** + * Records dropped events. The WARN below fires once per session slot, + * which keeps the log readable but makes the drop rate invisible; the + * meter is what an operator can alert on. + */ + private final AuditMonitor monitor; + + AuditBuffer() { + this(AuditMonitor.NOOP); + } + + AuditBuffer(@NotNull AuditMonitor monitor) { + this.monitor = monitor; + } + /** * Thread-local map keyed by {@code sessionId} * ({@code ContentSession.toString()}). The inner {@link SessionBuffer} @@ -108,6 +123,7 @@ void record(@NotNull String sessionId, @NotNull AuditEvent event) { // push a later (sensitive) event past the cap; bounded and self-healing // (the slot re-arms on the next drain/refresh). if (sb.events.size() >= MAX_EVENTS_PER_SESSION) { + monitor.eventDropped(event.getDomain()); if (!sb.overflowWarned) { sb.overflowWarned = true; log.warn("Audit buffer for session {} reached the cap of {} staged events; " + diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java index cda4261674c..e8d17250fa9 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java @@ -19,8 +19,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; @@ -30,6 +32,7 @@ import org.apache.jackrabbit.oak.spi.state.NodeState; import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -107,13 +110,22 @@ final class AuditDrainObserver implements Observer { private final Feature featureToggle; private final AuditBuffer buffer; private final WhiteboardAuditEventListenerRegistry registry; + private final AuditMonitor monitor; AuditDrainObserver(@NotNull Feature featureToggle, @NotNull AuditBuffer buffer, @NotNull WhiteboardAuditEventListenerRegistry registry) { + this(featureToggle, buffer, registry, AuditMonitor.NOOP); + } + + AuditDrainObserver(@NotNull Feature featureToggle, + @NotNull AuditBuffer buffer, + @NotNull WhiteboardAuditEventListenerRegistry registry, + @NotNull AuditMonitor monitor) { this.featureToggle = featureToggle; this.buffer = buffer; this.registry = registry; + this.monitor = monitor; } @Override @@ -166,8 +178,21 @@ private void doContentChanged(@NotNull CommitInfo info) { } List decorated = CommitMetadataDecorator.decorate(events, info); Map> byDomain = groupByDomain(decorated); + // Domains that reached at least one listener. Collected during the + // loop rather than taken from byDomain.keySet(): a listener can + // unregister between capture and drain, leaving a domain in the map + // that nothing consumed. Counting those would overstate the dispatch + // rate. Counted once per domain, so N listeners on one domain do not + // multiply the count. + Set delivered = new HashSet<>(4); for (AuditEventListener listener : listeners) { - dispatchOne(listener, byDomain); + AuditDomain to = dispatchOne(listener, byDomain); + if (to != null) { + delivered.add(to); + } + } + for (AuditDomain domain : delivered) { + monitor.eventsDispatched(domain, byDomain.get(domain).size()); } } @@ -179,19 +204,33 @@ private void doContentChanged(@NotNull CommitInfo info) { return byDomain; } - private static void dispatchOne(@NotNull AuditEventListener listener, - @NotNull Map> byDomain) { + /** + * @return the domain whose batch was handed to {@code listener}, or + * {@code null} when the listener had nothing to consume or threw. + */ + private @Nullable AuditDomain dispatchOne(@NotNull AuditEventListener listener, + @NotNull Map> byDomain) { // The getDomain() routing lookup sits INSIDE the barrier — it is // listener code just like onEvents(), and a throw escaping to the // outer barrier would starve every remaining listener. try { - List forListener = byDomain.get(listener.getDomain()); + AuditDomain domain = listener.getDomain(); + List forListener = byDomain.get(domain); if (forListener == null || forListener.isEmpty()) { - return; + return null; + } + // Timed inside the barrier so a listener that throws still records + // the time it burned first — a listener failing slowly is the case + // worth seeing, and it costs commit latency either way. + long startNanos = System.nanoTime(); + try { + // Hand each listener an immutable view so one misbehaving listener + // cannot mutate the per-domain list seen by its peers. + listener.onEvents(Collections.unmodifiableList(forListener)); + } finally { + monitor.listenerDuration(listener.getClass(), System.nanoTime() - startNanos); } - // Hand each listener an immutable view so one misbehaving listener - // cannot mutate the per-domain list seen by its peers. - listener.onEvents(Collections.unmodifiableList(forListener)); + return domain; } catch (Throwable t) { // Per-listener isolation: a misconfigured consumer bundle whose listener // throws e.g. LinkageError must not crash the commit-dispatch path for @@ -200,8 +239,10 @@ private static void dispatchOne(@NotNull AuditEventListener listener, // channels. Do not narrow this catch to RuntimeException — listener // Throwables (any kind) must not escape into the dispatch loop. The log // must not re-invoke getDomain(): it may be exactly what threw. + monitor.listenerFailed(listener.getClass()); log.warn("AuditEventListener {} threw {} during commit-attached dispatch; isolating from other listeners.", listener.getClass().getName(), t.getClass().getSimpleName(), t); + return null; } } } diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditMonitor.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditMonitor.java new file mode 100644 index 00000000000..866c56182e5 --- /dev/null +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditMonitor.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.apache.jackrabbit.oak.plugins.metric.util.StatsProviderUtil; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; +import org.apache.jackrabbit.oak.stats.MeterStats; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.apache.jackrabbit.oak.stats.TimerStats; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Metrics for the audit pipeline. Records how many events are dispatched per + * domain, how long each listener spends in + * {@link org.apache.jackrabbit.oak.spi.audit.AuditEventListener#onEvents}, + * how often a listener fails, and how many events are dropped at the + * per-session buffer cap. + *

      + * The listener timer is the operationally interesting one: listeners run + * synchronously on the commit thread, so time spent in {@code onEvents} is + * added to commit latency for the writing session. The dropped-event meter is + * the one that matters for a compliance trail — a non-zero value means a + * persisted write left no audit event behind. + *

      + * Metric names carry {@code ;domain=} / {@code ;listener=} + * label suffixes via {@link StatsProviderUtil}, the convention Prometheus and + * similar systems split back into a name plus labels. Domains and listener + * classes are both unbounded in principle, so the per-label {@code Meter} and + * {@code Timer} handles are cached in a {@link ConcurrentHashMap} rather than + * resolved per event: {@code getMeter(...)} is a lookup on the provider's own + * registry and not free. + *

      + * {@link #NOOP} is used when no {@link StatisticsProvider} is bound, which is + * the common case for embedded callers and tests. Every record method on it is + * an empty call, so callers never null-check. + */ +class AuditMonitor { + + /** + * Monitor that records nothing. Installed when no + * {@link StatisticsProvider} is available. + */ + static final AuditMonitor NOOP = new AuditMonitor(null); + + private static final String EVENTS = "security.audit.events"; + private static final String EVENTS_DROPPED = "security.audit.events.dropped"; + private static final String LISTENER_DURATION = "security.audit.listener.duration"; + private static final String LISTENER_FAILURES = "security.audit.listener.failures"; + + private static final String LABEL_DOMAIN = "domain"; + private static final String LABEL_LISTENER = "listener"; + + /** + * {@code null} for {@link #NOOP}; every record method short-circuits on it. + */ + private final StatsProviderUtil stats; + + private final Map eventMeters = new ConcurrentHashMap<>(); + private final Map droppedMeters = new ConcurrentHashMap<>(); + private final Map listenerTimers = new ConcurrentHashMap<>(); + private final Map listenerFailureMeters = new ConcurrentHashMap<>(); + + /** + * @param statisticsProvider provider to register metrics on, or + * {@code null} to record nothing. + */ + AuditMonitor(@Nullable StatisticsProvider statisticsProvider) { + this.stats = (statisticsProvider == null) + ? null + : new StatsProviderUtil(statisticsProvider); + } + + /** + * Records that {@code count} events were dispatched to at least one + * listener in {@code domain}. Not called for events discarded at the + * toggle or the listener gate: those never reach a consumer, so counting + * them would misreport the dispatch rate. + * + * @param domain the events' domain, non-null. + * @param count number of events dispatched, positive. + */ + void eventsDispatched(@NotNull AuditDomain domain, int count) { + if (stats == null) { + return; + } + eventMeters.computeIfAbsent(domain.name(), + name -> stats.getMeterStats().apply(EVENTS, Map.of(LABEL_DOMAIN, name))) + .mark(count); + } + + /** + * Records that one event was dropped because the capturing session had + * reached the per-session buffer cap. A non-zero rate here is a gap in + * the audit trail, not just a capacity signal. + * + * @param domain the dropped event's domain, non-null. + */ + void eventDropped(@NotNull AuditDomain domain) { + if (stats == null) { + return; + } + droppedMeters.computeIfAbsent(domain.name(), + name -> stats.getMeterStats().apply(EVENTS_DROPPED, Map.of(LABEL_DOMAIN, name))) + .mark(); + } + + /** + * Records how long a listener spent handling a batch. + * + * @param listener the listener class, non-null. + * @param durationNanos elapsed wall-clock time in nanoseconds. + */ + void listenerDuration(@NotNull Class listener, long durationNanos) { + if (stats == null) { + return; + } + listenerTimers.computeIfAbsent(listener.getName(), + name -> stats.getTimerStats().apply(LISTENER_DURATION, Map.of(LABEL_LISTENER, name))) + .update(durationNanos, TimeUnit.NANOSECONDS); + } + + /** + * Records that a listener threw. Counted separately from the duration + * timer so a consistently failing listener is visible even when it fails + * fast. + * + * @param listener the listener class, non-null. + */ + void listenerFailed(@NotNull Class listener) { + if (stats == null) { + return; + } + listenerFailureMeters.computeIfAbsent(listener.getName(), + name -> stats.getMeterStats().apply(LISTENER_FAILURES, Map.of(LABEL_LISTENER, name))) + .mark(); + } +} diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java index 51240e164eb..5203d699d89 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java @@ -31,6 +31,8 @@ import org.apache.jackrabbit.oak.spi.commit.Observer; import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.WhiteboardUtils; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; import org.jetbrains.annotations.NotNull; import org.osgi.framework.BundleContext; import org.osgi.framework.ServiceRegistration; @@ -118,6 +120,13 @@ public class AuditPipeline implements AuditConfiguration { AuditBuffer buffer; WhiteboardAuditEventListenerRegistry registry; + /** + * Metrics sink, shared by the buffer, the drain observer, and the + * fire-and-forget path. {@link AuditMonitor#NOOP} when no + * {@link StatisticsProvider} is on the whiteboard. + */ + AuditMonitor monitor = AuditMonitor.NOOP; + /** * Singleton {@link AuditDrainObserver} instance constructed by * {@link #initialize(Whiteboard)} and zeroed by {@link #dispose()}. @@ -203,15 +212,21 @@ public void initialize(@NotNull Whiteboard whiteboard) { registry = new WhiteboardAuditEventListenerRegistry(); registry.start(whiteboard); - buffer = new AuditBuffer(); + // Resolved off the whiteboard rather than as a DS @Reference so the + // OSGi and embedded paths share one lookup: initialize() is the only + // entry point either uses. Absent provider (the usual case for tests + // and embedded callers) yields the NOOP monitor. + monitor = new AuditMonitor(WhiteboardUtils.getService(whiteboard, StatisticsProvider.class)); + + buffer = new AuditBuffer(monitor); AuditBufferLifecycle.install(buffer); - AuditEvents.install(new BufferSink(featureToggle, registry, buffer)); + AuditEvents.install(new BufferSink(featureToggle, registry, buffer, monitor)); // Constructed last so the observer exists before activate() publishes // it as a service: ObserverTracker subscribes on a background thread // and can fire before activate() returns. - drainObserver = new AuditDrainObserver(featureToggle, buffer, registry); + drainObserver = new AuditDrainObserver(featureToggle, buffer, registry, monitor); // The pipeline is wired either way; the toggle decides whether events // are captured and dispatched. Say which, rather than printing a bare @@ -379,6 +394,10 @@ public void dispose() { // 5. Zero the cached singleton observer. Subsequent getDrainObserver() // calls throw IllegalStateException — same contract as pre-init. drainObserver = null; + // 6. Back to NOOP rather than null: the field is read without a + // null-check by anything still holding a reference to the torn-down + // buffer or sink. + monitor = AuditMonitor.NOOP; log.info("Audit pipeline deactivated."); } @@ -415,13 +434,16 @@ private static final class BufferSink implements AuditEvents.Sink { private final Feature toggle; private final WhiteboardAuditEventListenerRegistry registry; private final AuditBuffer buffer; + private final AuditMonitor monitor; BufferSink(@NotNull Feature toggle, @NotNull WhiteboardAuditEventListenerRegistry registry, - @NotNull AuditBuffer buffer) { + @NotNull AuditBuffer buffer, + @NotNull AuditMonitor monitor) { this.toggle = toggle; this.registry = registry; this.buffer = buffer; + this.monitor = monitor; } @Override @@ -458,6 +480,7 @@ public void dispatch(@NotNull AuditEvent event) { AuditEvent toDispatch = CommitMetadataDecorator.stripReservedCommitKeys(event); AuditDomain domain = toDispatch.getDomain(); List single = Collections.singletonList(toDispatch); + boolean delivered = false; for (AuditEventListener listener : listeners) { // The listener's getDomain() filter sits INSIDE the barrier — // it is listener code just like onEvents(), and a throw here @@ -468,8 +491,15 @@ public void dispatch(@NotNull AuditEvent event) { if (!domain.equals(listener.getDomain())) { continue; } - listener.onEvents(single); + long startNanos = System.nanoTime(); + try { + listener.onEvents(single); + } finally { + monitor.listenerDuration(listener.getClass(), System.nanoTime() - startNanos); + } + delivered = true; } catch (Throwable t) { + monitor.listenerFailed(listener.getClass()); // Per-listener isolation: a misconfigured consumer bundle whose listener // throws e.g. LinkageError must not fail the commit for unrelated work. // JVM-level pathology (OutOfMemoryError) is caught here too but @@ -481,6 +511,10 @@ public void dispatch(@NotNull AuditEvent event) { listener.getClass().getName(), t.getClass().getSimpleName(), domain, t); } } + // One event, counted once, and only when something consumed it. + if (delivered) { + monitor.eventsDispatched(domain, 1); + } } } } diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorTest.java new file mode 100644 index 00000000000..cba0e96fa98 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorTest.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.apache.jackrabbit.oak.commons.concurrent.ExecutorCloser; +import org.apache.jackrabbit.oak.plugins.metric.MetricStatisticsProvider; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import com.codahale.metrics.MetricRegistry; + +import static java.lang.management.ManagementFactory.getPlatformMBeanServer; +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link AuditMonitor}. Backed by a real + * {@link MetricStatisticsProvider} rather than a mock, so the assertions run + * against the metric names and label suffixes an operator actually sees in + * JMX. + */ +public class AuditMonitorTest { + + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); + private static final AuditDomain OTHER_DOMAIN = AuditDomain.of("other.domain"); + + private ScheduledExecutorService executor; + private MetricStatisticsProvider statisticsProvider; + private MetricRegistry registry; + private AuditMonitor monitor; + + @Before + public void setUp() { + executor = newSingleThreadScheduledExecutor(); + statisticsProvider = new MetricStatisticsProvider(getPlatformMBeanServer(), executor); + registry = statisticsProvider.getRegistry(); + monitor = new AuditMonitor(statisticsProvider); + } + + @After + public void tearDown() { + if (statisticsProvider != null) { + statisticsProvider.close(); + } + if (executor != null) { + new ExecutorCloser(executor).close(); + } + } + + //-------------------------------------------------------< event meter >--- + + @Test + public void eventsDispatchedMarksPerDomainMeter() { + monitor.eventsDispatched(DOMAIN, 3); + + assertEquals(3, meterCount("security.audit.events;domain=test.domain")); + } + + @Test + public void eventsDispatchedAccumulatesAcrossCalls() { + monitor.eventsDispatched(DOMAIN, 2); + monitor.eventsDispatched(DOMAIN, 5); + + assertEquals(7, meterCount("security.audit.events;domain=test.domain")); + } + + @Test + public void eventsDispatchedKeepsDomainsSeparate() { + monitor.eventsDispatched(DOMAIN, 1); + monitor.eventsDispatched(OTHER_DOMAIN, 4); + + assertEquals(1, meterCount("security.audit.events;domain=test.domain")); + assertEquals(4, meterCount("security.audit.events;domain=other.domain")); + } + + //-----------------------------------------------------< dropped meter >--- + + @Test + public void eventDroppedMarksPerDomainMeter() { + monitor.eventDropped(DOMAIN); + monitor.eventDropped(DOMAIN); + + assertEquals(2, meterCount("security.audit.events.dropped;domain=test.domain")); + } + + @Test + public void droppedMeterIsSeparateFromDispatchedMeter() { + monitor.eventsDispatched(DOMAIN, 5); + monitor.eventDropped(DOMAIN); + + assertEquals(5, meterCount("security.audit.events;domain=test.domain")); + assertEquals(1, meterCount("security.audit.events.dropped;domain=test.domain")); + } + + //--------------------------------------------------< listener metrics >--- + + @Test + public void listenerDurationRecordsPerListenerTimer() { + monitor.listenerDuration(TestListener.class, TimeUnit.MILLISECONDS.toNanos(7)); + + String name = "security.audit.listener.duration;listener=" + + TestListener.class.getName(); + assertNotNull(registry.getTimers().get(name)); + assertEquals(1, registry.getTimers().get(name).getCount()); + } + + @Test + public void listenerDurationKeepsListenersSeparate() { + monitor.listenerDuration(TestListener.class, 100L); + monitor.listenerDuration(OtherTestListener.class, 200L); + monitor.listenerDuration(OtherTestListener.class, 300L); + + assertEquals(1, timerCount("security.audit.listener.duration;listener=" + + TestListener.class.getName())); + assertEquals(2, timerCount("security.audit.listener.duration;listener=" + + OtherTestListener.class.getName())); + } + + @Test + public void listenerFailedMarksPerListenerMeter() { + monitor.listenerFailed(TestListener.class); + + assertEquals(1, meterCount("security.audit.listener.failures;listener=" + + TestListener.class.getName())); + } + + @Test + public void listenerFailureIsSeparateFromDuration() { + monitor.listenerDuration(TestListener.class, 50L); + monitor.listenerFailed(TestListener.class); + + assertEquals(1, timerCount("security.audit.listener.duration;listener=" + + TestListener.class.getName())); + assertEquals(1, meterCount("security.audit.listener.failures;listener=" + + TestListener.class.getName())); + } + + //----------------------------------------------------------------< NOOP >--- + + @Test + public void noopMonitorRecordsNothing() { + // Every method must be callable and must not touch a provider. + AuditMonitor.NOOP.eventsDispatched(DOMAIN, 5); + AuditMonitor.NOOP.eventDropped(DOMAIN); + AuditMonitor.NOOP.listenerDuration(TestListener.class, 100L); + AuditMonitor.NOOP.listenerFailed(TestListener.class); + + assertNoAuditMetricsRegistered(); + } + + @Test + public void nullProviderBehavesLikeNoop() { + AuditMonitor nullBacked = new AuditMonitor(null); + + nullBacked.eventsDispatched(DOMAIN, 5); + nullBacked.eventDropped(DOMAIN); + nullBacked.listenerDuration(TestListener.class, 100L); + nullBacked.listenerFailed(TestListener.class); + + assertNoAuditMetricsRegistered(); + } + + @Test + public void noopProviderIsAccepted() { + // StatisticsProvider.NOOP hands back NoopStats for every handle; + // the monitor must not treat that differently from a real provider. + AuditMonitor noopBacked = new AuditMonitor(StatisticsProvider.NOOP); + + noopBacked.eventsDispatched(DOMAIN, 5); + noopBacked.listenerFailed(TestListener.class); + + assertNull(registry.getMeters().get("security.audit.events;domain=test.domain")); + } + + //--------------------------------------------------------------< utils >--- + + /** + * The registry is not empty to begin with — {@code MetricStatisticsProvider} + * registers its own baseline metrics — so "recorded nothing" is asserted + * against the audit metric names rather than against registry size. + */ + private void assertNoAuditMetricsRegistered() { + assertTrue("no audit meter should be registered", + registry.getMeters().keySet().stream().noneMatch(n -> n.startsWith("security.audit."))); + assertTrue("no audit timer should be registered", + registry.getTimers().keySet().stream().noneMatch(n -> n.startsWith("security.audit."))); + } + + private long meterCount(String name) { + assertNotNull("no meter registered under " + name, registry.getMeters().get(name)); + return registry.getMeters().get(name).getCount(); + } + + private long timerCount(String name) { + assertNotNull("no timer registered under " + name, registry.getTimers().get(name)); + return registry.getTimers().get(name).getCount(); + } + + private static final class TestListener { + // Name-only stand-in: the monitor labels by Class#getName(). + } + + private static final class OtherTestListener { + // Second label so the per-listener separation is observable. + } +} diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorWiringTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorWiringTest.java new file mode 100644 index 00000000000..959c07b88b6 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditMonitorWiringTest.java @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.security.audit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; +import org.apache.jackrabbit.oak.spi.audit.AuditType; +import org.apache.jackrabbit.oak.spi.commit.CommitInfo; +import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * Verifies the {@link AuditMonitor} is actually invoked from the commit-attached + * dispatch path and from the buffer's overflow branch, rather than merely being + * callable ({@link AuditMonitorTest} covers the recording itself). + *

      + * Uses a counting {@code AuditMonitor} subclass instead of a + * {@code StatisticsProvider}: the assertions are about which pipeline branch + * calls which method, so counting the calls is the direct observation. + */ +public class AuditMonitorWiringTest { + + private static final String SESSION_ID = "wiring-session"; + private static final String USER_ID = "alice"; + private static final NodeState ROOT = EmptyNodeState.EMPTY_NODE; + private static final AuditDomain DOMAIN = AuditDomain.of("test.domain"); + private static final AuditType TYPE = AuditType.of("test.type"); + + private DefaultWhiteboard whiteboard; + private Feature featureToggle; + private CountingMonitor monitor; + private AuditBuffer buffer; + private WhiteboardAuditEventListenerRegistry registry; + private AuditDrainObserver observer; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + featureToggle = Feature.newFeature(AuditPipeline.FEATURE_TOGGLE_NAME, whiteboard); + monitor = new CountingMonitor(); + buffer = new AuditBuffer(monitor); + registry = new WhiteboardAuditEventListenerRegistry(); + registry.start(whiteboard); + observer = new AuditDrainObserver(featureToggle, buffer, registry, monitor); + setToggle(true); + } + + @After + public void tearDown() { + if (featureToggle != null) { + featureToggle.close(); + } + if (registry != null) { + registry.stop(); + } + if (buffer != null) { + buffer.clearAll(); + } + } + + @Test + public void dispatchedEventsAreCounted() { + registerListener(DOMAIN); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals("both events counted once", 2, monitor.dispatched); + assertEquals(1, monitor.durations); + assertEquals(0, monitor.failures); + } + + /** + * Two listeners on one domain must not double the event count: the meter + * answers "how many events flowed through", not "how many deliveries + * happened". + */ + @Test + public void twoListenersOnOneDomainCountEventsOnce() { + registerListener(DOMAIN); + registerListener(DOMAIN); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals("one event, counted once", 1, monitor.dispatched); + assertEquals("but timed per listener", 2, monitor.durations); + } + + /** + * An event whose domain has no listener reached no consumer, so it must + * not appear in the dispatch meter. + */ + @Test + public void eventWithNoListenerIsNotCounted() { + registerListener(AuditDomain.of("other.domain")); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals(0, monitor.dispatched); + assertEquals(0, monitor.durations); + } + + @Test + public void toggleOffDispatchesAndCountsNothing() { + setToggle(false); + registerListener(DOMAIN); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals(0, monitor.dispatched); + assertEquals(0, monitor.durations); + } + + /** + * A throwing listener is counted as a failure, and still contributes a + * duration: it burned commit-thread time before it threw. + */ + @Test + public void throwingListenerIsCountedAsFailure() { + whiteboard.register(AuditEventListener.class, new AuditEventListener() { + @Override + public @NotNull AuditDomain getDomain() { + return DOMAIN; + } + + @Override + public void onEvents(@NotNull List events) { + throw new IllegalStateException("boom"); + } + }, Map.of()); + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + + observer.contentChanged(ROOT, localCommit()); + + assertEquals(1, monitor.failures); + assertEquals("timed even though it threw", 1, monitor.durations); + assertEquals("nothing was successfully consumed", 0, monitor.dispatched); + } + + @Test + public void eventsDroppedAtTheCapAreCounted() { + for (int i = 0; i < AuditBuffer.MAX_EVENTS_PER_SESSION + 3; i++) { + buffer.record(SESSION_ID, AuditEvent.of(DOMAIN, TYPE)); + } + + assertEquals("three events past the cap", 3, monitor.dropped); + } + + //--------------------------------------------------------------< utils >--- + + private static CommitInfo localCommit() { + return new CommitInfo(SESSION_ID, USER_ID); + } + + private void setToggle(boolean enabled) { + Tracker tracker = whiteboard.track(FeatureToggle.class); + try { + for (FeatureToggle ft : tracker.getServices()) { + if (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) { + ft.setEnabled(enabled); + } + } + } finally { + tracker.stop(); + } + } + + private void registerListener(@NotNull AuditDomain domain) { + whiteboard.register(AuditEventListener.class, new AuditEventListener() { + private final List received = new ArrayList<>(); + + @Override + public @NotNull AuditDomain getDomain() { + return domain; + } + + @Override + public void onEvents(@NotNull List events) { + received.addAll(events); + } + }, Map.of()); + } + + /** + * Counts calls per metric. Not a Mockito mock so the assertions read as + * plain numbers, and so the class is safe to call from the commit thread + * without stubbing. + */ + private static final class CountingMonitor extends AuditMonitor { + + int dispatched; + int dropped; + int durations; + int failures; + + CountingMonitor() { + super(null); + } + + @Override + void eventsDispatched(@NotNull AuditDomain domain, int count) { + dispatched += count; + } + + @Override + void eventDropped(@NotNull AuditDomain domain) { + dropped++; + } + + @Override + void listenerDuration(@NotNull Class listener, long durationNanos) { + durations++; + } + + @Override + void listenerFailed(@NotNull Class listener) { + failures++; + } + } +} From f1fccf94cc763e91ed6c4c44fd1864854e33818d Mon Sep 17 00:00:00 2001 From: Andrei Dulvac Date: Thu, 6 Aug 2026 15:20:37 +0200 Subject: [PATCH 4/4] OAK-12331: address the second round of review on the audit SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review on PR #3059: - Move AuditEventImpl into spi.audit.impl, which is not exported. The class was already package-private, so it never reached the API surface, but BND computes the baseline version per package: editing it could have bumped the exported package's version with no API change. It has to be public now, since AuditEvent.of in the parent package constructs it. - Rename AuditEvents to AuditDispatch. Oak's plural-facade convention (PropertyValues, NodeStates) means "factory for the singular type", and this class never builds an AuditEvent — AuditEvent.of does. It installs a sink and routes, so name it for that. - Return an empty list from AuditBuffer.peek and drain instead of null. The one production caller had a redundant null check; the tests now assert emptiness rather than null. - Drop the explicit ArrayList capacity in SessionBuffer and use the default. - Say "unmodifiable shallow copy" on peek rather than "defensive copy". The reviewer read it as a deep copy; List.copyOf is not one, and the shared AuditEvent instances are safe because events are immutable. The AuditDomain / AuditType value types the same review asked for landed earlier in c955987672. --- .../AuditCaptureSiteOverheadTest.java | 2 +- .../AuditEmptyCommitOverheadTest.java | 2 +- .../oak/spi/audit/AuditConfiguration.java | 6 +- .../{AuditEvents.java => AuditDispatch.java} | 4 +- .../jackrabbit/oak/spi/audit/AuditEvent.java | 7 +- .../spi/audit/{ => impl}/AuditEventImpl.java | 31 +++++---- ...EventsTest.java => AuditDispatchTest.java} | 28 ++++---- .../audit/{ => impl}/AuditEventImplTest.java | 14 ++-- .../oak/security/audit/AuditBuffer.java | 43 +++++++------ .../security/audit/AuditDrainObserver.java | 4 +- .../security/audit/AuditEventEmitterImpl.java | 8 +-- .../oak/security/audit/AuditPipeline.java | 34 +++++----- .../oak/security/user/UserManagerImpl.java | 10 +-- .../oak/security/audit/AuditBufferTest.java | 21 +++--- .../audit/AuditDrainObserverTest.java | 17 +++-- .../audit/AuditEventEmitterImplTest.java | 6 +- .../oak/security/audit/AuditFixtureTest.java | 6 +- .../audit/AuditPipelineLifecycleTest.java | 4 +- .../oak/security/audit/AuditPipelineTest.java | 64 +++++++++---------- .../oak/security/audit/AuditWiringTest.java | 6 +- .../user/UserManagerImplAuditTest.java | 12 ++-- .../jackrabbit/oak/fixture/OakFixture.java | 2 +- .../fixture/MemoryNSWithAuditFixtureTest.java | 22 +++---- .../oak/fixture/OakFixturePropertyTest.java | 22 +++---- 24 files changed, 194 insertions(+), 181 deletions(-) rename oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/{AuditEvents.java => AuditDispatch.java} (98%) rename oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/{ => impl}/AuditEventImpl.java (62%) rename oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/{AuditEventsTest.java => AuditDispatchTest.java} (82%) rename oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/{ => impl}/AuditEventImplTest.java (83%) diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java index bba87c9e001..5db2e3a4258 100644 --- a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java +++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditCaptureSiteOverheadTest.java @@ -37,7 +37,7 @@ * by {@code 2 * pairsPerIteration}, is the per-event audit overhead * (allocation + path resolve + buffer record + drain + listener * dispatch). On the audit-OFF side capture sites short-circuit at - * {@code AuditEvents.isEnabled()} returning false (NOOP sink) so the + * {@code AuditDispatch.isEnabled()} returning false (NOOP sink) so the * commit cost is the audit-free baseline. * *

      Tunable via {@code -DpairsPerIteration=N} (default 50). diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java index 85578de8778..ea13b77c240 100644 --- a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java +++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/AuditEmptyCommitOverheadTest.java @@ -25,7 +25,7 @@ * pipeline wired but no audit capture site firing. Each iteration does * {@code commitsPerIteration} small {@code setProperty} + {@code save} * cycles on a fixed leaf node. No {@code UserManager} traffic, so no - * {@code AuditEvents.record} calls happen — the only audit-attributable + * {@code AuditDispatch.record} calls happen — the only audit-attributable * work per commit is: *

        *
      • {@code AuditBufferLifecycle.onRefresh(sessionId)} on each diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java index fad3e74d1a7..20778c72b58 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditConfiguration.java @@ -52,11 +52,11 @@ public interface AuditConfiguration { * {@link AuditEventListener} is registered on the Whiteboard. The * two predicates AND together so a deployed-but-unused pipeline still * reports {@code false}, matching the no-allocation semantics - * documented at {@link AuditEvents#isEnabled()}. + * documented at {@link AuditDispatch#isEnabled()}. * - *

        Equivalent in semantics to {@code AuditEvents.isEnabled()}, but + *

        Equivalent in semantics to {@code AuditDispatch.isEnabled()}, but * reachable via the typed handle. Drift-prevention: both paths read - * through the volatile {@code AuditEvents.sink} (single source of + * through the volatile {@code AuditDispatch.sink} (single source of * truth). Any future divergence MUST be documented explicitly in * both Javadocs. * diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatch.java similarity index 98% rename from oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java rename to oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatch.java index 325f08bda4a..fa8d780db71 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvents.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatch.java @@ -27,7 +27,7 @@ * when no module is deployed (or the feature toggle is off) it short-circuits * with zero allocation. */ -public final class AuditEvents { +public final class AuditDispatch { /** * Sink contract implemented by the audit module. Installed via @@ -76,7 +76,7 @@ public interface Sink { private static volatile Sink sink = NOOP; - private AuditEvents() { + private AuditDispatch() { // utility class } diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java index 9664dc164a3..6c781585899 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java @@ -19,6 +19,7 @@ import java.util.Collections; import java.util.Map; +import org.apache.jackrabbit.oak.spi.audit.impl.AuditEventImpl; import org.jetbrains.annotations.NotNull; import org.osgi.annotation.versioning.ProviderType; @@ -45,8 +46,8 @@ * Most callers do not implement this interface directly: use the static * factory {@link #of(AuditDomain, AuditType, Map)} (or the no-payload * overload {@link #of(AuditDomain, AuditType)}) to construct an immutable - * event with the current wall-clock timestamp. The package-private - * {@code AuditEventImpl} backs these factories. + * event with the current wall-clock timestamp. The unexported + * {@code spi.audit.impl.AuditEventImpl} backs these factories. */ @ProviderType public interface AuditEvent { @@ -133,7 +134,7 @@ public interface AuditEvent { * {@code CommitMetadataDecorator}).

      • *
      • On the fire-and-forget path * ({@link AuditEventEmitter#emit(AuditEvent)} / - * {@code AuditEvents.dispatch}) Oak strips caller-supplied + * {@code AuditDispatch.dispatch}) Oak strips caller-supplied * values for the same three keys before delivery.
      • *
      * A listener may therefore treat the presence of the three keys in a diff --git a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java similarity index 62% rename from oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java rename to oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java index d6dd4f180ab..0b48716666b 100644 --- a/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImpl.java +++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java @@ -14,38 +14,47 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.jackrabbit.oak.spi.audit; +package org.apache.jackrabbit.oak.spi.audit.impl; import java.util.Map; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; +import org.apache.jackrabbit.oak.spi.audit.AuditEvent; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.jetbrains.annotations.NotNull; /** - * Package-private immutable {@link AuditEvent} backing the + * Immutable {@link AuditEvent} backing the * {@link AuditEvent#of(AuditDomain, AuditType, Map) AuditEvent.of} static * factories. *

      - * Not part of the SPI surface — consumers always see the bare - * {@code AuditEvent} interface. Keeping the impl package-private is the - * core of item 3 (drop typed event hierarchy): consumers cannot - * {@code instanceof}-check or downcast; discrimination is via + * Not part of the SPI surface. This package is deliberately left out of the + * bundle's {@code Export-Package}, so the class is unreachable outside + * {@code oak-core-spi} despite being {@code public} — it has to be public + * for {@code AuditEvent.of} in the parent package to construct it. Sitting + * outside the exported package also keeps edits here from moving that + * package's baseline version, which BND computes per package rather than + * per class. + *

      + * Consumers always see the bare {@code AuditEvent} interface: they cannot + * {@code instanceof}-check or downcast, and discriminate via * {@link AuditEvent#getDomain()} + {@link AuditEvent#getType()}. *

      * The {@code payload} Map is expected to already be immutable (the factory * runs {@link Map#copyOf} before constructing the impl); the constructor * stores it by reference. */ -final class AuditEventImpl implements AuditEvent { +public final class AuditEventImpl implements AuditEvent { private final AuditDomain domain; private final AuditType type; private final long timestamp; private final Map payload; - AuditEventImpl(@NotNull AuditDomain domain, - @NotNull AuditType type, - long timestamp, - @NotNull Map payload) { + public AuditEventImpl(@NotNull AuditDomain domain, + @NotNull AuditType type, + long timestamp, + @NotNull Map payload) { this.domain = domain; this.type = type; this.timestamp = timestamp; diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatchTest.java similarity index 82% rename from oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java rename to oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatchTest.java index 260b57a51e7..4407232af0a 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventsTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatchTest.java @@ -30,11 +30,11 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; -public class AuditEventsTest { +public class AuditDispatchTest { @After public void tearDown() { - AuditEvents.install(null); + AuditDispatch.install(null); } private static AuditEvent fixedEvent(@NotNull AuditDomain domain) { @@ -48,51 +48,51 @@ private static AuditEvent fixedEvent(@NotNull AuditDomain domain) { @Test public void facadeNoOpWhenNoSinkInstalled() { - assertFalse(AuditEvents.isEnabled()); - assertFalse(AuditEvents.isEnabledFor(AuditDomain.of("test.domain"))); - AuditEvents.record(mock(Root.class), fixedEvent(AuditDomain.of("test.domain"))); - AuditEvents.dispatch(fixedEvent(AuditDomain.of("test.domain"))); + assertFalse(AuditDispatch.isEnabled()); + assertFalse(AuditDispatch.isEnabledFor(AuditDomain.of("test.domain"))); + AuditDispatch.record(mock(Root.class), fixedEvent(AuditDomain.of("test.domain"))); + AuditDispatch.dispatch(fixedEvent(AuditDomain.of("test.domain"))); // no exception, no observable effect — verified by no sink installed } @Test public void recordRoutesThroughInstalledSink() { AtomicReference received = new AtomicReference<>(); - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { received.set(event); } @Override public void dispatch(@NotNull AuditEvent event) { /* not used */ } }); AuditEvent e = fixedEvent(AuditDomain.of("test.domain")); - AuditEvents.record(mock(Root.class), e); + AuditDispatch.record(mock(Root.class), e); assertSame(e, received.get()); } @Test public void dispatchRoutesThroughInstalledSink() { AtomicReference received = new AtomicReference<>(); - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* not used */ } @Override public void dispatch(@NotNull AuditEvent event) { received.set(event); } }); AuditEvent e = fixedEvent(AuditDomain.of("example.content")); - AuditEvents.dispatch(e); + AuditDispatch.dispatch(e); assertSame(e, received.get()); } @Test public void installNullResetsToNoOp() { - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return true; } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { } @Override public void dispatch(@NotNull AuditEvent event) { } }); - assertTrue(AuditEvents.isEnabled()); - AuditEvents.install(null); - assertFalse(AuditEvents.isEnabled()); + assertTrue(AuditDispatch.isEnabled()); + AuditDispatch.install(null); + assertFalse(AuditDispatch.isEnabled()); } } diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java similarity index 83% rename from oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java rename to oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java index 37e868ec047..174edd43563 100644 --- a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventImplTest.java +++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java @@ -14,10 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.jackrabbit.oak.spi.audit; +package org.apache.jackrabbit.oak.spi.audit.impl; import java.util.Map; +import org.apache.jackrabbit.oak.spi.audit.AuditDomain; +import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -25,11 +27,11 @@ import static org.junit.Assert.assertThrows; /** - * Unit tests for the package-private {@link AuditEventImpl} value holder. - *

      - * Co-located in the same package as the impl so we can construct it - * directly (it is not exposed in the SPI). The factory paths are covered - * by {@link AuditEventTest}; this class focuses on the ctor + getters. + * Unit tests for the {@link AuditEventImpl} value holder, which backs the + * {@code AuditEvent.of} factories without being part of the exported SPI. + * The factory paths are covered by + * {@code org.apache.jackrabbit.oak.spi.audit.AuditEventTest}; this class + * focuses on the ctor + getters. */ public class AuditEventImplTest { diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java index fbdf693a339..6d5a2e20387 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.java @@ -24,13 +24,12 @@ import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Per-thread, per-session staging area for audit events. Events captured - * via {@link org.apache.jackrabbit.oak.spi.audit.AuditEvents#record} + * via {@link org.apache.jackrabbit.oak.spi.audit.AuditDispatch#record} * are appended to a session-scoped buffer held in a {@link ThreadLocal}; * the buffer is allocated lazily on first {@code record} and is removed * when {@link #drain(String)} is called (i.e. on commit snapshot) or when @@ -54,7 +53,7 @@ * the session's caller thread. Cross-thread invocation is not supported * — sessions are not thread-safe in Oak. Because the staging area is a * {@link ThreadLocal}, a drain issued from a thread other than the one - * that captured simply sees an empty buffer (returns {@code null}); it + * that captured simply sees an empty buffer (returns an empty list); it * never observes or removes another thread's events. */ final class AuditBuffer implements AuditBufferLifecycle.Listener { @@ -137,44 +136,48 @@ void record(@NotNull String sessionId, @NotNull AuditEvent event) { } /** - * Test-only inspector. Returns a defensive copy of the - * events staged for {@code sessionId} without removing - * them. Mutating the returned list does not affect the buffer. Production - * drain goes through {@link #drain(String)}. + * Test-only inspector. Returns the events staged for {@code sessionId} + * without removing them. Production drain goes through + * {@link #drain(String)}. + *

      + * The returned list is an unmodifiable shallow copy: adding to or + * removing from it does not touch the buffer, but the {@link AuditEvent} + * instances are the staged ones, not clones. That is safe because events + * are immutable value types (see {@link AuditEvent#of}). * * @param sessionId session id, non-null. - * @return an immutable copy of the staged events, or {@code null} when - * nothing was staged for the session on the current thread. + * @return the staged events, empty when nothing was staged for the + * session on the current thread. */ - @Nullable + @NotNull List peek(@NotNull String sessionId) { Map bySession = tl.get(); if (bySession == null) { - return null; + return List.of(); } SessionBuffer sb = bySession.get(sessionId); - return (sb == null) ? null : List.copyOf(sb.events); + return (sb == null) ? List.of() : List.copyOf(sb.events); } /** - * Detaches and returns the staged events for {@code sessionId}, - * leaving the buffer empty for that session. + * Detaches and returns the events staged for {@code sessionId}, leaving + * the buffer empty for that session. * * @param sessionId session id, non-null. - * @return the staged events, or {@code null} when nothing was - * staged for the session on the current thread. + * @return the staged events, empty when nothing was staged for the + * session on the current thread. */ - @Nullable + @NotNull List drain(@NotNull String sessionId) { Map bySession = tl.get(); if (bySession == null) { - return null; + return List.of(); } SessionBuffer drained = bySession.remove(sessionId); if (bySession.isEmpty()) { tl.remove(); } - return (drained == null) ? null : drained.events; + return (drained == null) ? List.of() : drained.events; } /** @@ -210,7 +213,7 @@ public void onRefresh(@NotNull String sessionId) { * session slot (re-armed when the slot is recreated after a drain). */ private static final class SessionBuffer { - final List events = new ArrayList<>(4); + final List events = new ArrayList<>(); boolean overflowWarned; } } diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java index e8d17250fa9..7713984bc39 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java @@ -52,7 +52,7 @@ * a peer node, or the initial replay invocation at {@code addObserver()} * time with {@link CommitInfo#EMPTY_EXTERNAL}), the observer returns * immediately. External commits did not originate any local - * {@code AuditEvents.record(...)} calls, so there is nothing in the + * {@code AuditDispatch.record(...)} calls, so there is nothing in the * per-session buffer to drain. Explicit short-circuit; cleaner than * relying on the buffer to return empty. *

      @@ -169,7 +169,7 @@ private void doContentChanged(@NotNull CommitInfo info) { // the class Javadoc. The early return below then discards the drained // events when there is nothing to dispatch OR the toggle is now off. List events = buffer.drain(sessionId); - if (events == null || events.isEmpty() || !featureToggle.isEnabled()) { + if (events.isEmpty() || !featureToggle.isEnabled()) { return; } List listeners = registry.getListeners(); diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java index 5c11c7ee595..4653b241955 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java @@ -19,13 +19,13 @@ import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.jetbrains.annotations.NotNull; import org.osgi.service.component.annotations.Component; /** * OSGi service implementation of {@link AuditEventEmitter}. Thin wrapper - * around the static {@link AuditEvents} façade so consumer bundles do not + * around the static {@link AuditDispatch} façade so consumer bundles do not * need to know about the façade. *

      * A single instance is registered per OSGi container at activation of @@ -37,11 +37,11 @@ public class AuditEventEmitterImpl implements AuditEventEmitter { @Override public void emit(@NotNull AuditEvent event) { - AuditEvents.dispatch(event); + AuditDispatch.dispatch(event); } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { - return AuditEvents.isEnabledFor(domain); + return AuditDispatch.isEnabledFor(domain); } } diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java index 5203d699d89..2e7df4ad23d 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java @@ -27,7 +27,7 @@ import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.commit.Observer; import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; @@ -50,7 +50,7 @@ *

    • Registers a {@link Feature} toggle gating capture and dispatch.
    • *
    • Installs a per-session buffer ({@link AuditBuffer}) into * {@link AuditBufferLifecycle}.
    • - *
    • Installs the {@link AuditEvents} sink that routes capture-site + *
    • Installs the {@link AuditDispatch} sink that routes capture-site * calls into the buffer.
    • *
    • Tracks {@code AuditEventListener} services on the Whiteboard * via {@link WhiteboardAuditEventListenerRegistry}.
    • @@ -69,7 +69,7 @@ * {@link #initialize(Whiteboard)} returns, and stays wired until * {@link #dispose()}. The feature toggle decides whether capture and dispatch * actually do anything: with it disabled, capture is a no-op and the observer - * short-circuits (see {@link AuditEvents#isEnabled()} and + * short-circuits (see {@link AuditDispatch#isEnabled()} and * {@link AuditDrainObserver#contentChanged}). {@link #isActive()} is narrower * still — it reports {@code true} only when the toggle is enabled * and at least one listener is registered, so a wired @@ -103,8 +103,8 @@ public class AuditPipeline implements AuditConfiguration { // they are mutated only by initialize(Whiteboard) and dispose(), which // the contract specifies must each run exactly once and on the same // thread; reads happen either - // (a) on that same thread — the static AuditEvents.record / dispatch - // facade reads through the AuditEvents.sink field (itself volatile, + // (a) on that same thread — the static AuditDispatch.record / dispatch + // facade reads through the AuditDispatch.sink field (itself volatile, // providing the publication barrier), and getDrainObserver() is // called by activate() on the SCR thread AFTER initialize() on the // SCR thread; or @@ -188,13 +188,13 @@ private void activate(@NotNull BundleContext bundleContext, * Must be called exactly once per instance. Calling * it more than once orphans the previous {@code Feature} toggle and * registry tracker, and silently overwrites the static - * {@link AuditEvents} / {@link AuditBufferLifecycle} sinks. To rewire, + * {@link AuditDispatch} / {@link AuditBufferLifecycle} sinks. To rewire, * call {@link #dispose()} first. *

      * Activation ordering rationale. * {@link AuditBufferLifecycle#install AuditBufferLifecycle.install(buffer)} * runs before - * {@link AuditEvents#install AuditEvents.install(BufferSink)} so that any + * {@link AuditDispatch#install AuditDispatch.install(BufferSink)} so that any * concurrent capture arriving in the install window goes through the * NOOP sink (no buffer write) rather than through a live {@code BufferSink} * with an orphaned lifecycle handle. The inverse ordering would minimize @@ -221,7 +221,7 @@ public void initialize(@NotNull Whiteboard whiteboard) { buffer = new AuditBuffer(monitor); AuditBufferLifecycle.install(buffer); - AuditEvents.install(new BufferSink(featureToggle, registry, buffer, monitor)); + AuditDispatch.install(new BufferSink(featureToggle, registry, buffer, monitor)); // Constructed last so the observer exists before activate() publishes // it as a service: ObserverTracker subscribes on a background thread @@ -343,7 +343,7 @@ public void dispose() { "direct callers must unregister first."); } - // 1. Close the feature toggle FIRST. AuditEvents.isEnabled() + // 1. Close the feature toggle FIRST. AuditDispatch.isEnabled() // immediately returns false, so any new capture-site call // that races with deactivation short-circuits before reaching // the buffer (which we're about to dismantle). @@ -366,13 +366,13 @@ public void dispose() { registry = null; } } - // 3. Route AuditEvents/AuditBufferLifecycle to NOOP. Now even + // 3. Route AuditDispatch/AuditBufferLifecycle to NOOP. Now even // callers that already passed the isEnabled() gate land on // no-ops. try { - AuditEvents.install(null); + AuditDispatch.install(null); } catch (RuntimeException e) { - log.warn("Audit deactivate: AuditEvents.install(null) failed; continuing.", e); + log.warn("Audit deactivate: AuditDispatch.install(null) failed; continuing.", e); } try { AuditBufferLifecycle.install(null); @@ -404,9 +404,9 @@ public void dispose() { //------------------------------------------------< AuditConfiguration >--- /** - * Delegates to {@link AuditEvents#isEnabled()} — the single source of + * Delegates to {@link AuditDispatch#isEnabled()} — the single source of * truth for "is the audit pipeline up?". The static - * {@code AuditEvents.sink} field is {@code volatile}, so any thread + * {@code AuditDispatch.sink} field is {@code volatile}, so any thread * reading {@code isActive()} sees a JMM-safe value without depending * on the OSGi activation publication barrier. *

      @@ -418,18 +418,18 @@ public void dispose() { */ @Override public boolean isActive() { - return AuditEvents.isEnabled(); + return AuditDispatch.isEnabled(); } //-----------------------------------------------------------< internal >--- /** - * Composite gate exposed to capture sites via {@link AuditEvents}. + * Composite gate exposed to capture sites via {@link AuditDispatch}. * Both predicates ({@link Feature#isEnabled()} and * {@link WhiteboardAuditEventListenerRegistry#hasAnyListener()}) are * single volatile reads; together they keep the disabled path free * of allocation. */ - private static final class BufferSink implements AuditEvents.Sink { + private static final class BufferSink implements AuditDispatch.Sink { private final Feature toggle; private final WhiteboardAuditEventListenerRegistry registry; diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java index 44f8408bb3e..887647f994e 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java @@ -34,7 +34,7 @@ import org.apache.jackrabbit.oak.plugins.value.jcr.PartialValueFactory; import org.apache.jackrabbit.oak.security.user.monitor.UserMonitor; import org.apache.jackrabbit.oak.security.user.query.UserQueryManager; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; import org.apache.jackrabbit.oak.spi.security.SecurityProvider; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; @@ -381,7 +381,7 @@ void onImpersonation(@NotNull User user, @NotNull Principal principal, boolean g * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable member) throws RepositoryException { - if (AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)) { + if (AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)) { recordSingleMembershipAuditEvent(group, isRemove, member); } for (GroupAction action : filterGroupActions()) { @@ -406,7 +406,7 @@ void onGroupUpdate(@NotNull Group group, boolean isRemove, @NotNull Authorizable * @throws RepositoryException If an error occurs. */ void onGroupUpdate(@NotNull Group group, boolean isRemove, boolean isContentId, @NotNull Set memberIds, @NotNull Set failedIds) throws RepositoryException { - if (AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)) { + if (AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)) { recordBulkMembershipAuditEvent(group, isRemove, isContentId, memberIds, failedIds); } for (GroupAction action : filterGroupActions()) { @@ -432,7 +432,7 @@ private void recordSingleMembershipAuditEvent(@NotNull Group group, boolean isRe String groupPath = group.getPath(); String memberId = member.getID(); String memberPath = member.getPath(); - AuditEvents.record(root, isRemove + AuditDispatch.record(root, isRemove ? UserAuditEvents.memberRemoved(groupPath, memberId, memberPath) : UserAuditEvents.memberAdded(groupPath, memberId, memberPath)); } catch (RepositoryException e) { @@ -457,7 +457,7 @@ private void recordBulkMembershipAuditEvent(@NotNull Group group, boolean isRemo } try { String groupPath = group.getPath(); - AuditEvents.record(root, isRemove + AuditDispatch.record(root, isRemove ? UserAuditEvents.membersRemovedBulk(groupPath, memberIds, isContentId, failedIds) : UserAuditEvents.membersAddedBulk(groupPath, memberIds, isContentId, failedIds)); } catch (RepositoryException e) { diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java index d876a8f5dfb..66be8588934 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java @@ -29,7 +29,6 @@ import org.slf4j.event.Level; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -81,7 +80,7 @@ public void recordThenPeekReturnsStagedEventsInOrder() { @Test public void peekReturnsNullWhenNothingStaged() { - assertNull(buffer.peek(SESSION)); + assertTrue(buffer.peek(SESSION).isEmpty()); } @Test @@ -125,12 +124,12 @@ public void drainReturnsStagedEventsAndEmptiesSession() { assertEquals(2, drained.size()); assertSame(a, drained.get(0)); assertSame(b, drained.get(1)); - assertNull("session must be empty after drain", buffer.peek(SESSION)); + assertTrue("session must be empty after drain", buffer.peek(SESSION).isEmpty()); } @Test public void drainReturnsNullWhenNothingStaged() { - assertNull(buffer.drain(SESSION)); + assertTrue(buffer.drain(SESSION).isEmpty()); } @Test @@ -139,7 +138,7 @@ public void drainIsScopedToTheRequestedSession() { buffer.record(OTHER_SESSION, event(AuditType.of("x"))); buffer.drain(SESSION); - assertNull(buffer.peek(SESSION)); + assertTrue(buffer.peek(SESSION).isEmpty()); assertEquals("other session must be untouched", 1, buffer.peek(OTHER_SESSION).size()); } @@ -149,14 +148,14 @@ public void drainIsScopedToTheRequestedSession() { public void onRefreshDrainsSession() { buffer.record(SESSION, event(AuditType.of("a"))); buffer.onRefresh(SESSION); - assertNull(buffer.peek(SESSION)); + assertTrue(buffer.peek(SESSION).isEmpty()); } @Test public void onCommitFailedDrainsSession() { buffer.record(SESSION, event(AuditType.of("a"))); buffer.onCommitFailed(SESSION); - assertNull(buffer.peek(SESSION)); + assertTrue(buffer.peek(SESSION).isEmpty()); } @Test @@ -164,8 +163,8 @@ public void clearAllRemovesCurrentThreadEvents() { buffer.record(SESSION, event(AuditType.of("a"))); buffer.record(OTHER_SESSION, event(AuditType.of("x"))); buffer.clearAll(); - assertNull(buffer.peek(SESSION)); - assertNull(buffer.peek(OTHER_SESSION)); + assertTrue(buffer.peek(SESSION).isEmpty()); + assertTrue(buffer.peek(OTHER_SESSION).isEmpty()); } //--------------------------------------------------< soft per-session cap >--- @@ -242,8 +241,8 @@ public void stagedEventsAreThreadConfined() throws InterruptedException { other.start(); other.join(); - assertNull("another thread must not observe this thread's events", otherPeek.get()); - assertNull("drain from another thread must return null", otherDrain.get()); + assertTrue("another thread must not observe this thread's events", otherPeek.get().isEmpty()); + assertTrue("drain from another thread must return empty", otherDrain.get().isEmpty()); // The wrong-thread drain must not have touched this thread's slot. assertEquals("this thread's events must survive a wrong-thread drain", 1, buffer.peek(SESSION).size()); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java index 2425222cd9b..41fd5ba9984 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java @@ -45,7 +45,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** @@ -157,8 +156,8 @@ public void toggleDisabledDrainsButDoesNotDispatch() { assertTrue("toggle-off must not invoke listeners", listener.received.isEmpty()); - assertNull("toggle-off must STILL drain the buffer (no toggle-flicker leak)", - buffer.peek(SESSION_ID)); + assertTrue("toggle-off must STILL drain the buffer (no toggle-flicker leak)", + buffer.peek(SESSION_ID).isEmpty()); } /** @@ -196,8 +195,8 @@ public void noListenersShortCircuitsAfterDrain() { // prevent leaking into a subsequent commit. Otherwise a buffer-full // session could re-dispatch the same events the next time a listener // got registered. - assertNull("drain must run even when no listeners are registered", - buffer.peek(SESSION_ID)); + assertTrue("drain must run even when no listeners are registered", + buffer.peek(SESSION_ID).isEmpty()); } //----------------------------------------------------------< grouping >--- @@ -502,8 +501,8 @@ public void successfulCommitDrainsAndDecoratesAndDispatches() { payload.containsKey(CommitMetadataDecorator.KEY_TIMESTAMP)); // Buffer drained — events no longer staged for this session. - assertNull("buffer must be drained on successful dispatch", - buffer.peek(SESSION_ID)); + assertTrue("buffer must be drained on successful dispatch", + buffer.peek(SESSION_ID).isEmpty()); } //------------------------------------< immutable dispatch list >--- @@ -567,8 +566,8 @@ public void toggleFlipMidFlightDoesNotLeakStaleEvent() { observer.contentChanged(ROOT, localCommit()); assertTrue("toggle-off observer-fire must not dispatch", listener.received.isEmpty()); - assertNull("E1 must be drained (not stranded) during the toggle-off window", - buffer.peek(SESSION_ID)); + assertTrue("E1 must be drained (not stranded) during the toggle-off window", + buffer.peek(SESSION_ID).isEmpty()); // Commit #2: toggle back ON, capture E2, observer fires. Only E2. setToggle(true); diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java index c4c792d2d1e..7acb6a1007c 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java @@ -23,7 +23,7 @@ import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.jetbrains.annotations.NotNull; import org.junit.After; @@ -41,7 +41,7 @@ public class AuditEventEmitterImplTest { @Before public void installSink() { dispatched = new AtomicReference<>(); - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return "yes".equals(domain.name()); } @Override public void record(@NotNull Root root, @NotNull AuditEvent event) { /* unused */ } @@ -51,7 +51,7 @@ public void installSink() { @After public void tearDown() { - AuditEvents.install(null); + AuditDispatch.install(null); } private static AuditEvent fixedEvent(@NotNull AuditDomain domain) { diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java index 184bdab6107..e7c4d00542f 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java @@ -37,7 +37,7 @@ import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; import org.apache.jackrabbit.oak.spi.audit.AuditEventListener; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.commit.Observable; import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; @@ -77,7 +77,7 @@ * Mongo-available check) variants belong in a module that already depends on * those stores — {@code oak-jcr} or {@code oak-it}. The audit wiring used * here is fully public ({@link AuditPipeline#initialize}, - * {@link AuditPipeline#getDrainObserver}, {@link AuditEvents}, + * {@link AuditPipeline#getDrainObserver}, {@link AuditDispatch}, * {@link AuditEventListener}), so this class can be lifted there as-is and * the extra fixtures added to {@link #fixtures()}. The DOCUMENT_NS row will * additionally need to await asynchronous dispatch (the drain observer is @@ -205,7 +205,7 @@ private static AuditEvent eventFor(@NotNull AuditType type, @NotNull Map diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java index 6f950d95154..4d96a3dd847 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java @@ -63,12 +63,12 @@ * End-to-end integration test for the AUDIT-SPI production wiring path. *

      * Unlike {@link AuditPipelineTest} which records events directly via - * {@code AuditEvents.record}, this test exercises the path that real Oak + * {@code AuditDispatch.record}, this test exercises the path that real Oak * consumers traverse: *

        *
      1. JCR {@link UserManager#createGroup(String)} → {@link Group#addMember(org.apache.jackrabbit.api.security.user.Authorizable)}.
      2. *
      3. {@code UserManagerImpl.recordSingleMembershipAuditEvent} → - * {@code AuditEvents.record(root, UserAuditEvents.memberAdded(...))}.
      4. + * {@code AuditDispatch.record(root, UserAuditEvents.memberAdded(...))}. *
      5. {@code AuditDrainObserver} (fires on commit success) → the registered * listener.
      6. *
      @@ -283,7 +283,7 @@ public void groupRemoveMemberFiresUserMemberRemovedEndToEnd() throws Exception { /** * With the feature toggle disabled, the capture-site - * {@code AuditEvents.isEnabled()} check in + * {@code AuditDispatch.isEnabled()} check in * {@code UserManagerImpl.recordSingleMembershipAuditEvent} short-circuits; * no event is delivered even though the group update succeeds. */ diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java index d4fb46e11c2..c912d716639 100644 --- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java @@ -32,7 +32,7 @@ import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; import org.apache.jackrabbit.oak.spi.audit.AuditDomain; import org.apache.jackrabbit.oak.spi.audit.AuditEvent; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.audit.AuditType; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.spi.security.user.UserAuditTypes; @@ -57,7 +57,7 @@ * not satisfy the unit-test coverage gate that applies to * {@code org.apache.jackrabbit.oak.security.user}. *

      - * This test installs a stub {@link AuditEvents.Sink} so the capture sites + * This test installs a stub {@link AuditDispatch.Sink} so the capture sites * exercise their on-path branches (toggle-on, isRemove true/false, single * vs bulk, RepositoryException catch) directly. No commit hooks needed. *

      @@ -76,7 +76,7 @@ public class UserManagerImplAuditTest extends AbstractSecurityTest { @Before public void installStubSink() { recordedEvents.clear(); - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; @@ -101,7 +101,7 @@ public void dispatch(@NotNull AuditEvent event) { @After public void resetSink() { - AuditEvents.install(null); + AuditDispatch.install(null); } @Test @@ -197,7 +197,7 @@ public void bulkMemberRemoveRecordsEvent() throws Exception { @Test public void auditDisabledShortCircuitsCapture() throws Exception { // Sink reports disabled — capture sites must short-circuit before record(). - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return false; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return false; } @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { @@ -228,7 +228,7 @@ public void domainPreciseGateSkipsCaptureWhenNoSecurityListener() throws Excepti // skip entirely — no event built, no path resolution, no record(). // Reverting the guard to the coarse isEnabled() would capture here, so // this test fails on such a regression. - AuditEvents.install(new AuditEvents.Sink() { + AuditDispatch.install(new AuditDispatch.Sink() { @Override public boolean isEnabled() { return true; } @Override public boolean isEnabledFor(@NotNull AuditDomain domain) { return false; } @Override public void record(@NotNull Root r, @NotNull AuditEvent event) { diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java index de237536efc..b59c0023242 100644 --- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java +++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/fixture/OakFixture.java @@ -700,7 +700,7 @@ static Oak newOak(NodeStore nodeStore) { * Domain-scoped no-op listener used by the audit-enabled benchmark * fixture ({@link #getMemoryNSWithAudit(long)}). Returning a real * listener (rather than relying on the JVM-static NOOP sink) is what - * flips {@code AuditEvents.isEnabledFor(domain)} to {@code true} and + * flips {@code AuditDispatch.isEnabledFor(domain)} to {@code true} and * causes capture sites to allocate and buffer events — see the * {@code BufferSink.isEnabledFor} short-circuit in * {@code AuditPipeline}. diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java index 062876b987b..e43c9d728bb 100644 --- a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/MemoryNSWithAuditFixtureTest.java @@ -21,7 +21,7 @@ import static org.junit.Assert.assertTrue; import org.apache.jackrabbit.oak.Oak; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.apache.jackrabbit.oak.stats.StatisticsProvider; import org.junit.After; @@ -32,7 +32,7 @@ *

      * Phase 3 of the audit-SPI work measured the wrong shape because the * default {@code Oak-MemoryNS} fixture does not wire the audit pipeline - * — capture sites silently routed to {@code AuditEvents.NOOP}. This test + * — capture sites silently routed to {@code AuditDispatch.NOOP}. This test * fails the build if the audit-enabled fixture ever regresses into the * same silent-audit-OFF state. */ @@ -55,11 +55,11 @@ public void getOakWiresAuditPipelineOn() throws Exception { assertNotNull(oak); assertTrue("FT_OAK-12331 toggle + a 'security'-domain listener must be live; " - + "AuditEvents.isEnabled() must return true", - AuditEvents.isEnabled()); + + "AuditDispatch.isEnabled() must return true", + AuditDispatch.isEnabled()); assertTrue("Capture sites in UserManagerImpl gate on isEnabledFor('security'); " + "must return true so audit-ON capture exercise the buffer path", - AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); + AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test @@ -70,23 +70,23 @@ public void setUpClusterAlsoWiresAuditPipeline() throws Exception { assertTrue("cluster must contain the requested number of Oak instances", cluster.length == 2); - assertTrue(AuditEvents.isEnabled()); - assertTrue(AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); + assertTrue(AuditDispatch.isEnabled()); + assertTrue(AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test public void tearDownClusterDisposesAuditPipeline() throws Exception { fixture = OakFixture.getMemoryNSWithAudit(0); fixture.getOak(0); - assertTrue(AuditEvents.isEnabled()); + assertTrue(AuditDispatch.isEnabled()); fixture.tearDownCluster(); fixture = null; - assertFalse("After tearDownCluster, AuditEvents must route to NOOP", - AuditEvents.isEnabled()); + assertFalse("After tearDownCluster, AuditDispatch must route to NOOP", + AuditDispatch.isEnabled()); assertFalse("Domain-scoped probe must also revert to NOOP", - AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); + AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)); } @Test diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java index d02266da9eb..0084e9a9006 100644 --- a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java +++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/fixture/OakFixturePropertyTest.java @@ -22,7 +22,7 @@ import org.apache.jackrabbit.oak.Oak; import org.apache.jackrabbit.oak.spi.audit.AuditBufferLifecycle; -import org.apache.jackrabbit.oak.spi.audit.AuditEvents; +import org.apache.jackrabbit.oak.spi.audit.AuditDispatch; import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain; import org.junit.After; import org.junit.Before; @@ -37,7 +37,7 @@ * branch that lets existing benchmarks opt into audit without switching * methods. *

      - * Both {@link AuditEvents#install} and {@link AuditBufferLifecycle#install} + * Both {@link AuditDispatch#install} and {@link AuditBufferLifecycle#install} * mutate JVM-static state, so the tests defensively reset both in * {@link #before()} / {@link #after()} on top of the per-test * {@code fixture.tearDownCluster()} call (which calls @@ -56,7 +56,7 @@ public void before() { System.clearProperty(OakFixture.AUDIT_ENABLED_PROPERTY); // Hermetic baseline: NOOP both global façades before each test, // regardless of any upstream test's cleanup quality. - AuditEvents.install(null); + AuditDispatch.install(null); AuditBufferLifecycle.install(null); } @@ -77,7 +77,7 @@ public void after() { } // Belt-and-braces: even if tearDownCluster() somehow left a // façade installed, force both back to NOOP. - AuditEvents.install(null); + AuditDispatch.install(null); AuditBufferLifecycle.install(null); } } @@ -95,9 +95,9 @@ public void propertyAbsentLeavesGetMemoryNSAuditOff() throws Exception { assertNotNull(oak); assertFalse("default getMemoryNS(0) without the property must be audit-OFF", - AuditEvents.isEnabled()); + AuditDispatch.isEnabled()); assertFalse("audit must remain OFF for every domain probe", - AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); + AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)); } /** @@ -117,11 +117,11 @@ public void propertySetTrueEnablesAuditOnGetMemoryNS() throws Exception { assertNotNull(oak); assertTrue("getMemoryNS(0) with -Doak.audit.enabled=true must wire the audit pipeline; " - + "AuditEvents.isEnabled() must return true", - AuditEvents.isEnabled()); + + "AuditDispatch.isEnabled() must return true", + AuditDispatch.isEnabled()); assertTrue("the security-domain listener must be live so capture sites in " + "UserManagerImpl actually allocate / buffer / dispatch events", - AuditEvents.isEnabledFor(SecurityAuditDomain.DOMAIN)); + AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)); } /** @@ -139,7 +139,7 @@ public void propertySetFalseLeavesGetMemoryNSAuditOff() throws Exception { assertNotNull(oak); assertFalse("getMemoryNS(0) with -Doak.audit.enabled=false must stay audit-OFF", - AuditEvents.isEnabled()); + AuditDispatch.isEnabled()); } /** @@ -163,6 +163,6 @@ public void propertyIsReadAtConstructionNotAtGetOak() throws Exception { assertFalse("property mutation after construction must not retroactively " + "enable audit on this fixture", - AuditEvents.isEnabled()); + AuditDispatch.isEnabled()); } }