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: + * + * + * 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: + *
      + *
    1. Not initialised — default NOOP sink → {@code false}.
    2. + *
    3. Initialised, toggle OFF — short-circuits on toggle.
    4. + *
    5. Initialised, toggle ON, no listener — short-circuits on + * {@code registry.hasAnyListener()}.
    6. + *
    7. Initialised, toggle ON, listener registered → {@code true}.
    8. + *
    9. After {@code dispose()} — sink reset to NOOP → {@code false}.
    10. + *
  • + *
  • Lifecycle + drain-observer accessor — 7 cases (a)–(g): + *
      + *
    1. (a) {@code @Activate} registers the {@link Observer} service.
    2. + *
    3. (b) {@code @Deactivate} unregisters it.
    4. + *
    5. (c) {@code getDrainObserver()} returns a non-null Observer + * post-{@code initialize}.
    6. + *
    7. (d) {@code getDrainObserver()} returns the SAME instance on + * repeat calls — singleton invariant. Guards against accidental + * factory revert: the {@code AuditBuffer} {@code ThreadLocal} is + * buffer-instance-scoped and the {@code drain(sessionId)} + * contract is destructive, so two observers sharing the same + * buffer would silently turn double-attach into double-dispatch + * under any future non-destructive drain refactor.
    8. + *
    9. (e) {@code getDrainObserver()} throws + * {@link IllegalStateException} pre-{@code initialize}.
    10. + *
    11. (f) {@code getDrainObserver()} throws ISE post-{@code dispose}.
    12. + *
    13. (g) {@code dispose()} throws ISE when {@code observerRegistration} + * is still non-null — defense-in-depth precondition guard. + * The @{@code Deactivate} path zeros the field + * before invoking {@code dispose()}; misuse paths (a test + * that calls {@code dispose()} directly without invoking + * {@code @Deactivate}) get a loud failure with an actionable + * message rather than a silent leak.
    14. + *
  • + *
  • Tear-down ordering — Mockito {@link InOrder} + * verifies that {@code @Deactivate} runs + * {@code observerRegistration.unregister() → featureToggle.close() + * → registry.stop() → buffer.clearAll()} in that exact order. The + * "detach first, internals second" policy mirrors + * {@code ChangeProcessor.java:289-295}; the precondition guard in + * {@code dispose()} catches the misuse case separately (case (g)).
  • + *
+ * MockOsgi (org.apache.sling.testing.osgi-mock.junit4) drives the + * {@code @Activate}/{@code @Deactivate} flow for cases (a)/(b). Unit-level + * cases (c)–(g) call {@code initialize}/{@code dispose} directly. The + * InOrder test injects spies via the package-private fields exposed for + * test access (production callers MUST NOT touch those fields). + */ +public class 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: + *

    + *
  1. {@link AuditPipeline#initialize(Whiteboard)} installs the + * audit feature toggle, listener registry, buffer and capture-time + * sink onto the whiteboard.
  2. + *
  3. {@code store.addObserver(audit.getDrainObserver())} attaches the + * {@link AuditDrainObserver} directly to the {@code MemoryNodeStore}. + * We don't use {@code Oak.with(Observer)} because we pass a custom + * whiteboard via {@code Oak.with(Whiteboard)}, which replaces Oak's + * default anonymous-override whiteboard and bypasses the auto-attach + * at {@code Oak.java:300-302}.
  4. + *
  5. Teardown closes the {@code Observable.addObserver} {@code Closeable} + * and calls {@link AuditPipeline#dispose()} — the same code + * path that OSGi {@code @Deactivate} uses.
  6. + *
+ * No test mirror of {@code BufferSink} or observer wiring exists in this + * class; a bug in either pipeline will surface here. + */ +public class AuditPipelineTest { + + private static final 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: + *

    + *
  1. JCR {@link UserManager#createGroup(String)} → {@link Group#addMember(org.apache.jackrabbit.api.security.user.Authorizable)}.
  2. + *
  3. {@code UserManagerImpl.recordSingleMembershipAuditEvent} → + * {@code AuditDispatch.record(root, UserAuditEvents.memberAdded(...))}.
  4. + *
  5. {@code AuditDrainObserver} (fires on commit success) → the registered + * listener.
  6. + *
+ * Asserts the entire chain: capture-site → buffer → observer drain → + * decorator → listener. + */ +public class AuditWiringTest { + + private Whiteboard whiteboard; + private 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); + } +}