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..5db2e3a4258
--- /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 AuditDispatch.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..ea13b77c240
--- /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 AuditDispatch.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..20778c72b58
--- /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 AuditDispatch#isEnabled()}.
+ *
+ *
Equivalent in semantics to {@code AuditDispatch.isEnabled()}, but
+ * reachable via the typed handle. Drift-prevention: both paths read
+ * through the volatile {@code AuditDispatch.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/AuditDispatch.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatch.java
new file mode 100644
index 00000000000..fa8d780db71
--- /dev/null
+++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatch.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 AuditDispatch {
+
+ /**
+ * 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 AuditDomain 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 AuditDomain 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 AuditDispatch() {
+ // 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 AuditDomain 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/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
new file mode 100644
index 00000000000..6c781585899
--- /dev/null
+++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEvent.java
@@ -0,0 +1,250 @@
+/*
+ * 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.apache.jackrabbit.oak.spi.audit.impl.AuditEventImpl;
+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 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
+ * 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(AuditDomain, AuditType, Map)} (or the no-payload
+ * overload {@link #of(AuditDomain, AuditType)}) to construct an immutable
+ * event with the current wall-clock timestamp. The unexported
+ * {@code spi.audit.impl.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 (e.g. {@code "oak.security"}).
+ */
+ @NotNull
+ AuditDomain getDomain();
+
+ /**
+ * Returns the event type within the domain. Types are stable across
+ * releases for any given domain.
+ *
+ * @return non-null type.
+ */
+ @NotNull
+ AuditType 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 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 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
+ * {@code CommitInfo} at drain time (via
+ * {@code CommitMetadataDecorator}).
+ * On the fire-and-forget path
+ * ({@link AuditEventEmitter#emit(AuditEvent)} /
+ * {@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
+ * 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:
+ *
+ * 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 AuditDomain domain,
+ @NotNull AuditType type,
+ @NotNull Map payload) {
+ return new AuditEventImpl(domain, type, System.currentTimeMillis(), Map.copyOf(payload));
+ }
+
+ /**
+ * Convenience overload for events with no payload.
+ *
+ * @param domain the event domain.
+ * @param type the event type within {@code domain}.
+ * @return non-null event instance with an empty payload.
+ */
+ @NotNull
+ 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
new file mode 100644
index 00000000000..410e618c828
--- /dev/null
+++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventEmitter.java
@@ -0,0 +1,81 @@
+/*
+ * 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;
+ *
+ * private static final AuditDomain MY_DOMAIN = AuditDomain.of("example.content");
+ *
+ * void onSomething() {
+ * if (audit.isEnabledFor(MY_DOMAIN)) {
+ * audit.emit(AuditEvent.of(MY_DOMAIN, MY_TYPE, payload));
+ * }
+ * }
+ * }
+ *
+ * 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.
+ *
+ * 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. 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 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 AuditDomain domain);
+}
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..049ba1c62ee
--- /dev/null
+++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListener.java
@@ -0,0 +1,108 @@
+/*
+ * 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 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
+ * 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 should call
+ * {@link AuditEvent#isCommitAttested(AuditEvent)} — 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.
+ */
+ @NotNull
+ AuditDomain 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/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/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java
new file mode 100644
index 00000000000..0b48716666b
--- /dev/null
+++ b/oak-core-spi/src/main/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImpl.java
@@ -0,0 +1,86 @@
+/*
+ * 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.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;
+
+/**
+ * Immutable {@link AuditEvent} backing the
+ * {@link AuditEvent#of(AuditDomain, AuditType, Map) AuditEvent.of} static
+ * factories.
+ *
+ * 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.
+ */
+public final class AuditEventImpl implements AuditEvent {
+
+ private final AuditDomain domain;
+ private final AuditType type;
+ private final long timestamp;
+ private final Map payload;
+
+ public AuditEventImpl(@NotNull AuditDomain domain,
+ @NotNull AuditType 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 AuditDomain getDomain() {
+ return domain;
+ }
+
+ @NotNull
+ @Override
+ public AuditType 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/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/AuditDispatchTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatchTest.java
new file mode 100644
index 00000000000..4407232af0a
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditDispatchTest.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 AuditDispatchTest {
+
+ @After
+ public void tearDown() {
+ AuditDispatch.install(null);
+ }
+
+ private static AuditEvent fixedEvent(@NotNull AuditDomain domain) {
+ return new AuditEvent() {
+ @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(); }
+ };
+ }
+
+ @Test
+ public void facadeNoOpWhenNoSinkInstalled() {
+ 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<>();
+ 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"));
+ AuditDispatch.record(mock(Root.class), e);
+ assertSame(e, received.get());
+ }
+
+ @Test
+ public void dispatchRoutesThroughInstalledSink() {
+ AtomicReference received = new AtomicReference<>();
+ 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"));
+ AuditDispatch.dispatch(e);
+ assertSame(e, received.get());
+ }
+
+ @Test
+ public void installNullResetsToNoOp() {
+ 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(AuditDispatch.isEnabled());
+ AuditDispatch.install(null);
+ assertFalse(AuditDispatch.isEnabled());
+ }
+}
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/AuditEventListenerTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventListenerTest.java
new file mode 100644
index 00000000000..07690f0cb00
--- /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 AuditDomain getDomain() {
+ return AuditDomain.of("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 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);
+
+ final List[] received = new List[]{null};
+ AuditEventListener listener = new AuditEventListener() {
+ @Override
+ public @NotNull AuditDomain getDomain() {
+ return AuditDomain.of("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..1b2b4f54450
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/AuditEventTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.assertFalse;
+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 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());
+ }
+
+ //---------------------------< 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(DOMAIN, AuditType.of("membership.added"),
+ Map.of("groupPath", "/g", "memberPath", "/u"));
+ long after = System.currentTimeMillis();
+
+ assertNotNull(e);
+ 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.
+ assertTrue("captured timestamp must be in [before, after]",
+ before <= e.getTimestamp() && e.getTimestamp() <= after);
+ }
+
+ @Test
+ public void factoryWithoutPayloadReturnsEventWithEmptyPayload() {
+ 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 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 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);
+
+ 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 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 isCommitAttestedFalseForPlainEvent() {
+ assertFalse(AuditEvent.isCommitAttested(AuditEvent.of(DOMAIN, TYPE)));
+ }
+
+ @Test
+ 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 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/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-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java
new file mode 100644
index 00000000000..174edd43563
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/spi/audit/impl/AuditEventImplTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.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;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+/**
+ * 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 {
+
+ @Test
+ public void ctorStoresFields() {
+ Map payload = Map.of("k", "v");
+ AuditEventImpl e = new AuditEventImpl(AuditDomain.of("test.domain"), AuditType.of("t"), 42L, payload);
+
+ 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());
+ }
+
+ @Test
+ public void ctorAcceptsEmptyPayload() {
+ AuditEventImpl e = new AuditEventImpl(AuditDomain.of("test.domain"), AuditType.of("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(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/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..6d5a2e20387
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditBuffer.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.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.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.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
+ * a lifecycle event clears it.
+ *
+ * The buffer also implements {@link AuditBufferLifecycle.Listener}; it is
+ * installed via {@code AuditBufferLifecycle.install(this)} by
+ * {@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
+ * 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 an empty list); 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;
+
+ /**
+ * 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}
+ * 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) {
+ monitor.eventDropped(event.getDomain());
+ 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 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 the staged events, empty when nothing was staged for the
+ * session on the current thread.
+ */
+ @NotNull
+ List peek(@NotNull String sessionId) {
+ Map bySession = tl.get();
+ if (bySession == null) {
+ return List.of();
+ }
+ SessionBuffer sb = bySession.get(sessionId);
+ return (sb == null) ? List.of() : List.copyOf(sb.events);
+ }
+
+ /**
+ * 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, empty when nothing was staged for the
+ * session on the current thread.
+ */
+ @NotNull
+ List drain(@NotNull String sessionId) {
+ Map bySession = tl.get();
+ if (bySession == null) {
+ return List.of();
+ }
+ SessionBuffer drained = bySession.remove(sessionId);
+ if (bySession.isEmpty()) {
+ tl.remove();
+ }
+ return (drained == null) ? List.of() : drained.events;
+ }
+
+ /**
+ * Drops all staged events for the current thread .
+ * Called by {@link AuditPipeline#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<>();
+ 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
new file mode 100644
index 00000000000..7713984bc39
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserver.java
@@ -0,0 +1,248 @@
+/*
+ * 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.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;
+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.jetbrains.annotations.Nullable;
+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 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.
+ *
+ * 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;
+ 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
+ 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.isEmpty() || !featureToggle.isEnabled()) {
+ return;
+ }
+ List listeners = registry.getListeners();
+ if (listeners.isEmpty()) {
+ return;
+ }
+ 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) {
+ AuditDomain to = dispatchOne(listener, byDomain);
+ if (to != null) {
+ delivered.add(to);
+ }
+ }
+ for (AuditDomain domain : delivered) {
+ monitor.eventsDispatched(domain, byDomain.get(domain).size());
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * @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 {
+ AuditDomain domain = listener.getDomain();
+ List forListener = byDomain.get(domain);
+ if (forListener == null || forListener.isEmpty()) {
+ 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);
+ }
+ 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
+ // 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.
+ 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/AuditEventEmitterImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java
new file mode 100644
index 00000000000..4653b241955
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImpl.java
@@ -0,0 +1,47 @@
+/*
+ * 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.AuditDomain;
+import org.apache.jackrabbit.oak.spi.audit.AuditEvent;
+import org.apache.jackrabbit.oak.spi.audit.AuditEventEmitter;
+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 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
+ * 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) {
+ AuditDispatch.dispatch(event);
+ }
+
+ @Override
+ public boolean isEnabledFor(@NotNull AuditDomain domain) {
+ return AuditDispatch.isEnabledFor(domain);
+ }
+}
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
new file mode 100644
index 00000000000..2e7df4ad23d
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/AuditPipeline.java
@@ -0,0 +1,520 @@
+/*
+ * 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.AuditDomain;
+import org.apache.jackrabbit.oak.spi.audit.AuditEvent;
+import org.apache.jackrabbit.oak.spi.audit.AuditEventListener;
+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;
+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;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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
+ * {@link AuditBufferLifecycle}.
+ * Installs the {@link AuditDispatch} 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()}.
+ *
+ * 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 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
+ * pipeline with the toggle on but no consumers reports {@code false}.
+ */
+@Component(service = AuditConfiguration.class)
+public class AuditPipeline 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";
+
+ private static final Logger log = LoggerFactory.getLogger(AuditPipeline.class);
+
+ // Package-private (not private) on the internal-state fields so
+ // 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().
+ //
+ // 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 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
+ // (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;
+
+ /**
+ * 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()}.
+ * Exposed via {@link #getDrainObserver()} as an {@link Observer}.
+ *
+ * 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
+ * {@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 AuditPipeline() {
+ super();
+ }
+
+ @SuppressWarnings("UnusedDeclaration")
+ @Activate
+ private void activate(@NotNull BundleContext bundleContext,
+ @NotNull Map properties) {
+ // Wire everything first: capture sites reach the buffer as soon as
+ // initialize() returns, and it also constructs the drain observer.
+ initialize(new OsgiWhiteboard(bundleContext));
+ // 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.
+ // 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);
+ }
+
+ /**
+ * 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 AuditDispatch} / {@link AuditBufferLifecycle} sinks. To rewire,
+ * call {@link #dispose()} first.
+ *
+ * Activation ordering rationale.
+ * {@link AuditBufferLifecycle#install AuditBufferLifecycle.install(buffer)}
+ * runs before
+ * {@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
+ * 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) {
+ // 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();
+ registry.start(whiteboard);
+
+ // 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);
+
+ 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
+ // and can fire before activate() returns.
+ 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
+ // 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 AuditPipeline} instance; zeroed by {@link #dispose()}.
+ *
+ * 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,
+ * 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(
+ "AuditPipeline.initialize(...) must be called first" +
+ " (or dispose() has already run)");
+ }
+ return drainObserver;
+ }
+
+ // 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;
+ // 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
+ // 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
+ // 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. 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).
+ 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 AuditDispatch/AuditBufferLifecycle to NOOP. Now even
+ // callers that already passed the isEnabled() gate land on
+ // no-ops.
+ try {
+ AuditDispatch.install(null);
+ } catch (RuntimeException e) {
+ log.warn("Audit deactivate: AuditDispatch.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;
+ // 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.");
+ }
+
+ //------------------------------------------------< AuditConfiguration >---
+
+ /**
+ * Delegates to {@link AuditDispatch#isEnabled()} — the single source of
+ * truth for "is the audit pipeline up?". The static
+ * {@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.
+ *
+ * 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 AuditDispatch.isEnabled();
+ }
+
+ //-----------------------------------------------------------< internal >---
+ /**
+ * 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 AuditDispatch.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 AuditMonitor monitor) {
+ this.toggle = toggle;
+ this.registry = registry;
+ this.buffer = buffer;
+ this.monitor = monitor;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return toggle.isEnabled() && registry.hasAnyListener();
+ }
+
+ @Override
+ public boolean isEnabledFor(@NotNull AuditDomain 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);
+ 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
+ // would otherwise escape into the emitter, breaching the
+ // published AuditEventEmitter contract ("never propagates
+ // back to the caller").
+ try {
+ if (!domain.equals(listener.getDomain())) {
+ continue;
+ }
+ 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
+ // 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);
+ }
+ }
+ // One event, counted once, and only when something consumed it.
+ if (delivered) {
+ monitor.eventsDispatched(domain, 1);
+ }
+ }
+ }
+}
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..9d74f64e78a
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecorator.java
@@ -0,0 +1,223 @@
+/*
+ * 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.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;
+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);
+
+ // 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
+ * 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 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; }
+ }
+
+ /**
+ * 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 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
new file mode 100644
index 00000000000..e813c156a08
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistry.java
@@ -0,0 +1,199 @@
+/*
+ * 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.AuditDomain;
+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 AuditDomain 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..9f30f3ff8e2
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuditEvents.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.util.List;
+import java.util.Map;
+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;
+
+/**
+ * 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 AuditType 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.DOMAIN,
+ 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 AuditType 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.DOMAIN,
+ 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..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,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.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;
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 (AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)) {
+ 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 (AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN)) {
+ 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();
+ AuditDispatch.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, not recording the audit event", 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();
+ AuditDispatch.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, not recording audit event", 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..66be8588934
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditBufferTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.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;
+import org.slf4j.event.Level;
+
+import static org.junit.Assert.assertEquals;
+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 AuditDomain DOMAIN = AuditDomain.of("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(AuditType type) {
+ return AuditEvent.of(DOMAIN, type);
+ }
+
+ //--------------------------------------------------< record / peek / drain >---
+
+ @Test
+ public void recordThenPeekReturnsStagedEventsInOrder() {
+ 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().name());
+ assertEquals("b", staged.get(1).getType().name());
+ }
+
+ @Test
+ public void peekReturnsNullWhenNothingStaged() {
+ assertTrue(buffer.peek(SESSION).isEmpty());
+ }
+
+ @Test
+ public void recordIsPerSession() {
+ 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());
+ }
+
+ /**
+ * {@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(AuditType.of("a")));
+ List first = buffer.peek(SESSION);
+
+ // The returned list is immutable (List.copyOf) — structural mutation throws.
+ 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(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(AuditType.of("a"));
+ AuditEvent b = event(AuditType.of("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));
+ assertTrue("session must be empty after drain", buffer.peek(SESSION).isEmpty());
+ }
+
+ @Test
+ public void drainReturnsNullWhenNothingStaged() {
+ assertTrue(buffer.drain(SESSION).isEmpty());
+ }
+
+ @Test
+ public void drainIsScopedToTheRequestedSession() {
+ buffer.record(SESSION, event(AuditType.of("a")));
+ buffer.record(OTHER_SESSION, event(AuditType.of("x")));
+
+ buffer.drain(SESSION);
+ assertTrue(buffer.peek(SESSION).isEmpty());
+ assertEquals("other session must be untouched", 1, buffer.peek(OTHER_SESSION).size());
+ }
+
+ //--------------------------------------------------< lifecycle callbacks >---
+
+ @Test
+ public void onRefreshDrainsSession() {
+ buffer.record(SESSION, event(AuditType.of("a")));
+ buffer.onRefresh(SESSION);
+ assertTrue(buffer.peek(SESSION).isEmpty());
+ }
+
+ @Test
+ public void onCommitFailedDrainsSession() {
+ buffer.record(SESSION, event(AuditType.of("a")));
+ buffer.onCommitFailed(SESSION);
+ assertTrue(buffer.peek(SESSION).isEmpty());
+ }
+
+ @Test
+ public void clearAllRemovesCurrentThreadEvents() {
+ buffer.record(SESSION, event(AuditType.of("a")));
+ buffer.record(OTHER_SESSION, event(AuditType.of("x")));
+ buffer.clearAll();
+ assertTrue(buffer.peek(SESSION).isEmpty());
+ assertTrue(buffer.peek(OTHER_SESSION).isEmpty());
+ }
+
+ //--------------------------------------------------< 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(AuditType.of("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(AuditType.of("first" + i)));
+ }
+ buffer.drain(SESSION);
+ for (int i = 0; i <= AuditBuffer.MAX_EVENTS_PER_SESSION; i++) {
+ buffer.record(SESSION, event(AuditType.of("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(AuditType.of("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();
+
+ 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
new file mode 100644
index 00000000000..41fd5ba9984
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditDrainObserverTest.java
@@ -0,0 +1,661 @@
+/*
+ * 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.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.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.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 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;
+ private AuditBuffer buffer;
+ private WhiteboardAuditEventListenerRegistry registry;
+ private AuditDrainObserver observer;
+
+ @Before
+ public void setUp() {
+ whiteboard = new DefaultWhiteboard();
+ featureToggle = Feature.newFeature(AuditPipeline.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, AuditType.of("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, AuditType.of("type-1")));
+
+ observer.contentChanged(ROOT, localCommit());
+
+ assertTrue("toggle-off must not invoke listeners",
+ listener.received.isEmpty());
+ assertTrue("toggle-off must STILL drain the buffer (no toggle-flicker leak)",
+ buffer.peek(SESSION_ID).isEmpty());
+ }
+
+ /**
+ * 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, AuditType.of("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.
+ assertTrue("drain must run even when no listeners are registered",
+ buffer.peek(SESSION_ID).isEmpty());
+ }
+
+ //----------------------------------------------------------< 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, 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().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().name());
+ }
+
+ /**
+ * 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 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 AuditDomain 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, AuditType.of("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 AuditDomain 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, AuditType.of("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 AuditDomain 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, AuditType.of("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 AuditDomain getDomain() {
+ throw new RuntimeException("synthetic-poisoned-event");
+ }
+ @Override public @NotNull AuditType getType() { return AuditType.of("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, 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().name());
+
+ 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.
+ assertTrue("buffer must be drained on successful dispatch",
+ buffer.peek(SESSION_ID).isEmpty());
+ }
+
+ //------------------------------------< 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 AuditDomain getDomain() { return DOMAIN_A; }
+ @Override public void onEvents(@NotNull List events) {
+ try {
+ events.add(AuditEvent.of(DOMAIN_A, AuditType.of("injected")));
+ } catch (Throwable t) {
+ caught.set(t);
+ }
+ }
+ };
+ whiteboard.register(AuditEventListener.class, mutating, Map.of());
+
+ 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());
+ 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, AuditType.of("E1"), Map.of("trace.id", "E1")));
+ setToggle(false);
+ observer.contentChanged(ROOT, localCommit());
+
+ assertTrue("toggle-off observer-fire must not dispatch", listener.received.isEmpty());
+ 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);
+ 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().name());
+ 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 (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) {
+ ft.setEnabled(enabled);
+ }
+ }
+ } finally {
+ tracker.stop();
+ }
+ }
+
+ private CapturingListener registerCapturingListener(@NotNull AuditDomain 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 AuditDomain domain;
+ final List received = new ArrayList<>();
+
+ CapturingListener(@NotNull AuditDomain domain) {
+ this.domain = domain;
+ }
+
+ @Override
+ public @NotNull AuditDomain 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..7acb6a1007c
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditEventEmitterImplTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.AuditDomain;
+import org.apache.jackrabbit.oak.spi.audit.AuditEvent;
+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;
+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<>();
+ 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 */ }
+ @Override public void dispatch(@NotNull AuditEvent event) { dispatched.set(event); }
+ });
+ }
+
+ @After
+ public void tearDown() {
+ AuditDispatch.install(null);
+ }
+
+ private static AuditEvent fixedEvent(@NotNull AuditDomain domain) {
+ return new AuditEvent() {
+ @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(); }
+ };
+ }
+
+ @Test
+ public void emitRoutesToFacadeDispatch() {
+ AuditEventEmitterImpl impl = new AuditEventEmitterImpl();
+ AuditEvent e = fixedEvent(AuditDomain.of("yes"));
+ impl.emit(e);
+ assertSame(e, dispatched.get());
+ }
+
+ @Test
+ public void isEnabledForRoutesToFacade() {
+ AuditEventEmitterImpl impl = new AuditEventEmitterImpl();
+ 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
new file mode 100644
index 00000000000..e7c4d00542f
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditFixtureTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.AuditDomain;
+import org.apache.jackrabbit.oak.spi.audit.AuditEvent;
+import org.apache.jackrabbit.oak.spi.audit.AuditEventListener;
+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;
+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 AuditPipeline#initialize},
+ * {@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
+ * 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 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.
+ */
+ @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 AuditPipeline 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 AuditPipeline();
+ auditConfig.initialize(whiteboard);
+ securityProvider = SecurityProviderBuilder.newBuilder()
+ .withWhiteboard(whiteboard)
+ .build();
+
+ Configuration.setConfiguration(
+ ConfigurationUtil.getDefaultConfiguration(ConfigurationParameters.EMPTY));
+
+ setToggle(true);
+
+ AuditEventListener listener = new AuditEventListener() {
+ @Override public @NotNull AuditDomain 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 AuditType type, @NotNull Map payload) {
+ return new AuditEvent() {
+ @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; }
+ };
+ }
+
+ @Test
+ public void recordedEventReachesListenerWithCommitSessionId() throws Exception {
+ try (ContentSession session = repository.login(adminCredentials(), null)) {
+ Root root = session.getLatestRoot();
+ AuditDispatch.record(root, eventFor(AuditType.of("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().name());
+ assertEquals("[" + fixtureName + "] commit.sessionId must match the committing session",
+ session.toString(), e.getPayload().get("oak.commit.sessionId"));
+ assertEquals("v", e.getPayload().get("note"));
+ }
+ }
+}
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++;
+ }
+ }
+}
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineLifecycleTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineLifecycleTest.java
new file mode 100644
index 00000000000..ff7f9181a44
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineLifecycleTest.java
@@ -0,0 +1,437 @@
+/*
+ * 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.AuditDomain;
+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 AuditPipeline} — both the
+ * {@link AuditPipeline#isActive() isActive()} reporter and the
+ * lifecycle / drain-observer accessor surface.
+ *
+ * Two test layers:
+ *
+ * {@code isActive()} state machine — 5 states
+ * reachable from the wiring lifecycle:
+ *
+ * Not initialised — default NOOP sink → {@code false}.
+ * Initialised, toggle OFF — short-circuits on toggle.
+ * Initialised, toggle ON, no listener — short-circuits on
+ * {@code registry.hasAnyListener()}.
+ * Initialised, toggle ON, listener registered → {@code true}.
+ * After {@code dispose()} — sink reset to NOOP → {@code false}.
+ *
+ * Lifecycle + drain-observer accessor — 7 cases (a)–(g):
+ *
+ * (a) {@code @Activate} registers the {@link Observer} service.
+ * (b) {@code @Deactivate} unregisters it.
+ * (c) {@code getDrainObserver()} returns a non-null Observer
+ * post-{@code initialize}.
+ * (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.
+ * (e) {@code getDrainObserver()} throws
+ * {@link IllegalStateException} pre-{@code initialize}.
+ * (f) {@code getDrainObserver()} throws ISE post-{@code dispose}.
+ * (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.
+ *
+ * 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 AuditPipelineLifecycleTest {
+
+ @Rule
+ public final OsgiContext osgiContext = new OsgiContext();
+
+ private Whiteboard whiteboard;
+ private AuditPipeline config;
+
+ @Before
+ public void setUp() {
+ whiteboard = new DefaultWhiteboard();
+ config = new AuditPipeline();
+ }
+
+ @After
+ public void tearDown() {
+ // Always dispose to reset the static AuditDispatch.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
+ // AuditPipeline 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() {
+ AuditPipeline audit = osgiContext.registerInjectActivateService(
+ new AuditPipeline());
+ 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() {
+ AuditPipeline audit = osgiContext.registerInjectActivateService(
+ new AuditPipeline());
+ 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 AuditPipeline#getDrainObserver()} returns
+ * a non-null {@link Observer} after {@code initialize(...)} ran.
+ */
+ @Test
+ public void getDrainObserverReturnsObserverAfterInitialize() {
+ AuditPipeline audit = new AuditPipeline();
+ try {
+ audit.initialize(whiteboard);
+ Observer observer = audit.getDrainObserver();
+ assertNotNull("getDrainObserver() must return non-null post-initialize",
+ observer);
+ } finally {
+ audit.dispose();
+ }
+ }
+
+ /**
+ * 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
+ * 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() {
+ AuditPipeline audit = new AuditPipeline();
+ 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 AuditPipeline#getDrainObserver()} throws
+ * {@link IllegalStateException} when called before
+ * {@link AuditPipeline#initialize(Whiteboard)}.
+ */
+ @Test
+ public void getDrainObserverThrowsBeforeInitialize() {
+ AuditPipeline audit = new AuditPipeline();
+ 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 AuditPipeline#getDrainObserver()} throws
+ * {@link IllegalStateException} when called after
+ * {@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() {
+ AuditPipeline audit = new AuditPipeline();
+ 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 AuditPipeline#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() {
+ 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
+ // 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 AuditPipeline instance go out of scope is enough —
+ // the static AuditDispatch/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() {
+ AuditPipeline audit = new AuditPipeline();
+ 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 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 (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) {
+ ft.setEnabled(enabled);
+ }
+ }
+ } finally {
+ tracker.stop();
+ }
+ }
+
+ private void registerTestListener() {
+ AuditEventListener listener = new AuditEventListener() {
+ @NotNull
+ @Override
+ public AuditDomain getDomain() {
+ return AuditDomain.of("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/AuditPipelineTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java
new file mode 100644
index 00000000000..c190c910ec2
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditPipelineTest.java
@@ -0,0 +1,1214 @@
+/*
+ * 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.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.AuditDispatch;
+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;
+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 AuditPipeline} install. Uses {@link MemoryNodeStore}
+ * for a real but in-process Oak instance.
+ *
+ * Wiring path:
+ *
+ * {@link AuditPipeline#initialize(Whiteboard)} installs the
+ * audit feature toggle, listener registry, buffer and capture-time
+ * sink onto the whiteboard.
+ * {@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}.
+ * Teardown closes the {@code Observable.addObserver} {@code Closeable}
+ * and calls {@link AuditPipeline#dispose()} — the same code
+ * path that OSGi {@code @Deactivate} uses.
+ *
+ * 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 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 AuditPipeline 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 AuditPipeline();
+ // 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
+ // AuditPipeline.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 AuditDomain 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 AuditDomain domain,
+ @NotNull AuditType type,
+ @NotNull Map payload) {
+ return new AuditEvent() {
+ @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; }
+ };
+ }
+
+ private ContentSession login() throws Exception {
+ return repository.login(adminCredentials(), null);
+ }
+
+ //--------------------------------------------------------< original 3 >---
+
+ @Test
+ public void fireAndForgetEventCarriesNoCommitMetadata() {
+ emitter.emit(eventFor(DOMAIN, AuditType.of("forget"), Map.of("key", "v")));
+ assertEquals(1, received.size());
+ AuditEvent e = received.get(0);
+ assertEquals("forget", e.getType().name());
+ assertFalse("fire-and-forget event must not carry commit.sessionId",
+ 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, AuditType.of("x"), Map.of()));
+ assertTrue(received.isEmpty());
+ }
+
+ @Test
+ public void commitAttachedEventCarriesCommitMetadata() throws Exception {
+ try (ContentSession session = login()) {
+ Root root = session.getLatestRoot();
+ AuditDispatch.record(
+ 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().name());
+ Map p = e.getPayload();
+ assertTrue("commit-attached event must carry commit.sessionId",
+ p.containsKey("oak.commit.sessionId"));
+ assertTrue(p.containsKey("oak.commit.userId"));
+ assertTrue(p.containsKey("oak.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, 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("oak.commit.sessionId"));
+ assertFalse("forged commit.userId must be stripped on fire-and-forget dispatch",
+ p.containsKey("oak.commit.userId"));
+ assertFalse("forged commit.timestamp must be stripped on fire-and-forget dispatch",
+ 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().name());
+ }
+
+ /**
+ * 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, AuditType.of("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();
+ AuditDispatch.record(
+ r1, eventFor(DOMAIN, AuditType.of("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();
+ AuditDispatch.record(
+ 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().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("oak.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();
+ AuditDispatch.record(
+ 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();
+ AuditDispatch.record(
+ 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().name());
+ 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();
+ AuditDispatch.record(
+ r1, eventFor(DOMAIN, AuditType.of("preserved"),
+ Map.of("trace.id", "E1-survives-rebase")));
+ r1.rebase();
+
+ Root r2 = session.getLatestRoot();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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 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.
+ //
+ // 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 AuditDispatch.isEnabled()}.
+ */
+ @Test
+ public void refreshDiscardsStagedEventsAcrossToggleFlicker() throws Exception {
+ try (ContentSession session = login()) {
+ // Capture E1 with gate=ON.
+ Root r1 = session.getLatestRoot();
+ AuditDispatch.record(
+ r1, eventFor(DOMAIN, AuditType.of("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();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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().name());
+ 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();
+ AuditDispatch.record(
+ r1, eventFor(DOMAIN, AuditType.of("staged-by-r1"),
+ Map.of("trace.id", "E1-survives-rebase-toggle")));
+
+ setToggle(false);
+ r1.rebase();
+ setToggle(true);
+
+ Root r2 = session.getLatestRoot();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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();
+ AuditDispatch.record(
+ 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.
+ // 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();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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 AuditDispatch.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();
+ AuditDispatch.record(
+ 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.
+ 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 AuditDomain getDomain() { return DOMAIN; }
+ @Override public void onEvents(@NotNull List events) {
+ received.addAll(events);
+ }
+ }, Map.of());
+
+ Root r2 = session.getLatestRoot();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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();
+ AuditDispatch.record(
+ 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 AuditDomain getDomain() { return DOMAIN; }
+ @Override public void onEvents(@NotNull List events) {
+ received.addAll(events);
+ }
+ }, Map.of());
+
+ Root r2 = session.getLatestRoot();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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();
+ AuditDispatch.record(
+ r1, eventFor(DOMAIN, AuditType.of("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 AuditDomain getDomain() { return DOMAIN; }
+ @Override public void onEvents(@NotNull List events) {
+ received.addAll(events);
+ }
+ }, Map.of());
+
+ Root r2 = session.getLatestRoot();
+ AuditDispatch.record(
+ r2, eventFor(DOMAIN, AuditType.of("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, AuditType.of("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();
+ AuditDispatch.record(
+ 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",
+ 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 AuditDomain 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();
+ AuditDispatch.record(
+ root, eventFor(DOMAIN, AuditType.of("a-1"), Map.of()));
+ AuditDispatch.record(
+ root, eventFor(OTHER_DOMAIN, AuditType.of("b-1"), Map.of()));
+ AuditDispatch.record(
+ 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().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().name());
+ } 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();
+ AuditDispatch.record(
+ 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();
+ AuditDispatch.record(
+ 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().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",
+ "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 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 AuditDomain 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();
+ AuditDispatch.record(
+ root, eventFor(DOMAIN, AuditType.of("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 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 AuditDomain 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();
+ AuditDispatch.record(
+ root, eventFor(DOMAIN, AuditType.of("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 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 AuditDomain 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, AuditType.of("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 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 AuditDomain 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, AuditType.of("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 AuditDomain 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, AuditType.of("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 AuditDomain 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(AuditDomain.of("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 AuditDomain getDomain() {
+ if (calls.incrementAndGet() <= 1) {
+ return DOMAIN;
+ }
+ throw new RuntimeException("synthetic-drain-time-poison");
+ }
+ @Override public @NotNull AuditType getType() { return AuditType.of("poison.type"); }
+ @Override public long getTimestamp() { return 0L; }
+ @Override public @NotNull Map getPayload() {
+ return Map.of();
+ }
+ };
+
+ try (ContentSession session = login()) {
+ Root root = session.getLatestRoot();
+ AuditDispatch.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 AuditDispatch#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..4d96a3dd847
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/AuditWiringTest.java
@@ -0,0 +1,312 @@
+/*
+ * 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.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;
+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 AuditDispatch.record}, this test exercises the path that real Oak
+ * consumers traverse:
+ *
+ * JCR {@link UserManager#createGroup(String)} → {@link Group#addMember(org.apache.jackrabbit.api.security.user.Authorizable)}.
+ * {@code UserManagerImpl.recordSingleMembershipAuditEvent} →
+ * {@code AuditDispatch.record(root, UserAuditEvents.memberAdded(...))}.
+ * {@code AuditDrainObserver} (fires on commit success) → the registered
+ * listener.
+ *
+ * Asserts the entire chain: capture-site → buffer → observer drain →
+ * decorator → listener.
+ */
+public class AuditWiringTest {
+
+ private Whiteboard whiteboard;
+ private AuditPipeline 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 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
+ // 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 AuditDomain getDomain() { return SecurityAuditDomain.DOMAIN; }
+ @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 (AuditPipeline.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.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("oak.commit.sessionId"));
+ assertTrue("commit.userId must be decorated",
+ payload.containsKey("oak.commit.userId"));
+ assertTrue("commit.timestamp must be decorated",
+ 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.
+ 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.DOMAIN, event.getDomain());
+ assertEquals(UserAuditTypes.MEMBER_REMOVED, event.getType());
+
+ Map payload = event.getPayload();
+ assertTrue("commit.sessionId must be decorated",
+ 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));
+ }
+ }
+
+ /**
+ * With the feature toggle disabled, the capture-site
+ * {@code AuditDispatch.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..274853902b5
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/CommitMetadataDecoratorTest.java
@@ -0,0 +1,409 @@
+/*
+ * 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.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;
+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 AuditDomain domain, @NotNull AuditType type, @NotNull Map payload) {
+ return new AuditEvent() {
+ @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; }
+ };
+ }
+
+ @Test
+ public void decoratesPayloadWithCommitMetadata() {
+ 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("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"));
+ // 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(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("example.content", decorated.getDomain().name());
+ assertEquals("fragment.published", decorated.getType().name());
+ assertEquals(12345L, decorated.getTimestamp());
+ }
+
+ @Test
+ public void systemCommitUserIdIsOakUnknown() {
+ 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("oak.commit.userId"));
+ }
+
+ @Test
+ public void preservesOrderOfEvents() {
+ 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().name());
+ assertEquals("b", out.get(1).getType().name());
+ assertEquals("c", out.get(2).getType().name());
+ }
+
+ //--------------------------------------------< 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(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);
+ 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(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);
+ 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(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
+ // 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(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);
+ 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(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));
+ 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(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...
+ 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(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));
+ 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(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 {
+ 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(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,
+ "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().name());
+ assertEquals("x", stripped.getType().name());
+ assertEquals(12345L, stripped.getTimestamp());
+ // Input event untouched.
+ assertEquals("forged-session", in.getPayload().get(CommitMetadataDecorator.KEY_SESSION_ID));
+ }
+
+ @Test
+ public void stripWithSingleReservedKeyStripsIt() {
+ 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();
+ 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(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));
+ }
+
+ /**
+ * 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(AuditDomain.of("oak.security"), AuditType.of("strip.warn.type"),
+ Map.of(CommitMetadataDecorator.KEY_SESSION_ID, "forged-session-value", "k", "v")));
+ 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();
+ 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(AuditDomain.of("oak.security"), AuditType.of("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 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) {
+ 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..1937aa65111
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/audit/WhiteboardAuditEventListenerRegistryTest.java
@@ -0,0 +1,380 @@
+/*
+ * 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.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;
+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 AuditDomain domain;
+ private final int rank;
+ StubListener(AuditDomain domain, int rank) {
+ this.domain = domain;
+ this.rank = rank;
+ }
+ @Override public @NotNull AuditDomain 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(AuditDomain.of("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(AuditDomain.of("oak.security"), 0), Map.of());
+ assertEquals(1, reg.getListeners().size());
+ assertTrue(reg.hasAnyListener());
+ assertTrue(reg.hasListenerFor(AuditDomain.of("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(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());
+ 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(AuditDomain.of("oak.security")));
+ // Register and re-check — must observe the new registration.
+ 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(AuditDomain.of("example.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(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());
+ 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 AuditDomain 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 AuditDomain domain;
+ ThrowingRankListener(AuditDomain domain) {
+ this.domain = domain;
+ }
+ @Override public @NotNull AuditDomain 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(AuditDomain.of("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(AuditDomain.of("oak.security"), 0), Map.of());
+ assertTrue("healthy listener must be found despite broken peer",
+ reg.hasListenerFor(AuditDomain.of("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(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());
+ 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(AuditDomain.of("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(AuditDomain.of("oak.security"));
+ reg.hasListenerFor(AuditDomain.of("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(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(AuditDomain.of("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(AuditDomain.of("oak.security"), 0), Map.of());
+ assertEquals(1, reg.getListeners().size());
+ assertTrue(reg.hasListenerFor(AuditDomain.of("oak.security")));
+
+ r.unregister();
+
+ assertEquals(0, reg.getListeners().size());
+ 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 677106825a5..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
@@ -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 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
new file mode 100644
index 00000000000..4f3c607755d
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserAuditEventsTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.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;
+
+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.DOMAIN, 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.DOMAIN, 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.DOMAIN, 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.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));
+
+ @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..c912d716639
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserManagerImplAuditTest.java
@@ -0,0 +1,383 @@
+/*
+ * 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.AuditDomain;
+import org.apache.jackrabbit.oak.spi.audit.AuditEvent;
+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;
+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 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.
+ *
+ * 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();
+ 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 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() {
+ AuditDispatch.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.DOMAIN, 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.DOMAIN, 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.DOMAIN, 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.DOMAIN, 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().
+ 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) {
+ 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.
+ 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) {
+ 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..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
@@ -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,23 @@
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.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;
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 +77,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 +100,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 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,
+ * 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 +154,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 AuditPipeline auditConfig;
+ private final List drainObserverSubscriptions = new ArrayList<>();
+
+ private synchronized void initAuditPipelineIfNeeded() {
+ if (!withAudit || auditConfig != null) {
+ return;
+ }
+ whiteboard = new DefaultWhiteboard();
+ auditConfig = new AuditPipeline();
+ 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 (AuditPipeline.FEATURE_TOGGLE_NAME.equals(ft.getName())) {
+ ft.setEnabled(true);
+ }
+ }
+ } finally {
+ tracker.stop();
+ }
+
+ whiteboard.register(AuditEventListener.class,
+ new BenchmarkNoopListener(SecurityAuditDomain.DOMAIN),
+ 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 +696,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 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}.
+ */
+ private static final class BenchmarkNoopListener implements AuditEventListener {
+
+ private final AuditDomain domain;
+
+ BenchmarkNoopListener(@NotNull AuditDomain domain) {
+ this.domain = domain;
+ }
+
+ @NotNull
+ @Override
+ public AuditDomain 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..e43c9d728bb
--- /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.AuditDispatch;
+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 AuditDispatch.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; "
+ + "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",
+ AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN));
+ }
+
+ @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(AuditDispatch.isEnabled());
+ assertTrue(AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN));
+ }
+
+ @Test
+ public void tearDownClusterDisposesAuditPipeline() throws Exception {
+ fixture = OakFixture.getMemoryNSWithAudit(0);
+ fixture.getOak(0);
+ assertTrue(AuditDispatch.isEnabled());
+
+ fixture.tearDownCluster();
+ fixture = null;
+
+ assertFalse("After tearDownCluster, AuditDispatch must route to NOOP",
+ AuditDispatch.isEnabled());
+ assertFalse("Domain-scoped probe must also revert to NOOP",
+ AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN));
+ }
+
+ @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..0084e9a9006
--- /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.AuditDispatch;
+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 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
+ * {@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.
+ */
+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.
+ AuditDispatch.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.
+ AuditDispatch.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",
+ AuditDispatch.isEnabled());
+ assertFalse("audit must remain OFF for every domain probe",
+ AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN));
+ }
+
+ /**
+ * 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; "
+ + "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",
+ AuditDispatch.isEnabledFor(SecurityAuditDomain.DOMAIN));
+ }
+
+ /**
+ * 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",
+ AuditDispatch.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",
+ AuditDispatch.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..6734eabe04f
--- /dev/null
+++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomain.java
@@ -0,0 +1,45 @@
+/*
+ * 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 org.apache.jackrabbit.oak.spi.audit.AuditDomain;
+
+/**
+ * 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 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 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/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..5ae14ae8a68
--- /dev/null
+++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypes.java
@@ -0,0 +1,133 @@
+/*
+ * 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.audit.AuditType;
+import org.apache.jackrabbit.oak.spi.security.audit.SecurityAuditDomain;
+
+/**
+ * Stable event types and payload keys for user-management audit
+ * events. All events declared here share the
+ * {@link SecurityAuditDomain#DOMAIN oak.security} domain.
+ *
+ * Listener bundles discriminate user-management events by combining
+ * {@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
+ * 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 event-type 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 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 AuditType MEMBER_REMOVED = AuditType.of("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..bf7dd6de446
--- /dev/null
+++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/audit/SecurityAuditDomainTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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 domainConstantIsStable() {
+ // Pins the wire value: listener bundles match on it.
+ assertEquals("oak.security", SecurityAuditDomain.DOMAIN.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..581ad13f6ce
--- /dev/null
+++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/UserAuditTypesTest.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.security.user;
+
+import java.lang.reflect.Constructor;
+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;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+
+public class UserAuditTypesTest {
+
+ @Test
+ 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
+ public void allPayloadKeysAreNonBlank() {
+ for (String value : payloadKeys()) {
+ assertFalse("payload key must not be blank: " + value, value.isBlank());
+ }
+ }
+
+ @Test
+ public void typesAreUnique() {
+ List values = types();
+ assertEquals("types 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 types() {
+ 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);
+ }
+}