diff --git a/README.md b/README.md index 27786d78..8bdecce2 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ See [opa-services/README.md](opa-services/README.md#tls-and-mtls) for a full mTL | `service` | string | - | Service for log uploads | | `console` | boolean | false | Enable console logging | | `resource` | string | `/logs` | Resource path for uploads | -| `mask_decision` | string | `system/log/mask` | Policy path for masking | +| `mask_decision` | string | `system/log/mask` | Policy path for masking (must be a plan entrypoint; see [masking](opa-services/README.md#decision-log-masking)) | | `drop_decision` | string | `system/log/drop` | Policy path for filtering | #### Status diff --git a/opa-services/README.md b/opa-services/README.md index 6d35ecf5..83052cda 100644 --- a/opa-services/README.md +++ b/opa-services/README.md @@ -279,6 +279,45 @@ Opa opa = new Opa.Builder() .build(); ``` +### Decision Log Masking + +Decision events can be redacted before they are buffered, uploaded, or written to the console. The +`decision_logs.mask_decision` path (default `system/log/mask`) is evaluated with the decision event +as its input, and the rules it returns are applied to the event: + +```rego +package system.log + +import rego.v1 + +# Shorthand form: remove the field. +mask contains "/input/password" if { + input.input.password +} + +# Structured form: remove or upsert a value. +mask contains {"op": "upsert", "path": "/result/token", "value": "**REDACTED**"} if { + input.result.token +} +``` + +Rule paths must be slash-prefixed and start with `input`, `result`, or `nd_builtin_cache` — the +decision's input, its result, and the non-deterministic builtin cache, as they appear in the event. +Removed paths are recorded in the event's `erased` array, upserted paths in `masked`. Paths that are +undefined in the event are skipped. + +Because this SDK evaluates compiled IR plans, **the mask policy has to be built as an entrypoint**: + +```bash +opa build -t plan -e authz/allow -e system/log/mask -o bundle.tar.gz policies/ +``` + +If no plan for the configured path is present, masking is skipped — and if `mask_decision` was set +to something other than the default, that is logged as a warning, since it means events are being +logged unmasked. If the mask policy fails to evaluate, or returns a rule the SDK cannot parse, the +error is logged and the event is dropped rather than logged unmasked (matching OPA's Go +implementation). + ## Decision Options For fine-grained control over individual decisions: diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java index 4564c775..96893be8 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java @@ -198,7 +198,7 @@ public DecisionResult makeDecision(DecisionOptions options) { // Bridge JsonNode <-> POJO here at the opa-services boundary. Object pojoInput = JSON_MAPPER.convertValue(options.getInput(), Object.class); List rawResults = engine.evaluate(ctx, pojoInput); - JsonNode resultNode = JSON_MAPPER.valueToTree(rawResults.get(0)); + JsonNode resultNode = unwrapResultKey(JSON_MAPPER.valueToTree(rawResults.get(0))); logDecision(decisionId, options.getInput(), resultNode, options, ctx); @@ -282,6 +282,15 @@ private static String resolveDecisionId(DecisionOptions options) { return UUID.randomUUID().toString(); } + // IR plans wrap every result as {"result": }, the same envelope Engine strips on its typed + // result path. Callers and decision log events both see the decision itself. + private static JsonNode unwrapResultKey(JsonNode result) { + if (result != null && result.isObject() && result.has("result")) { + return result.get("result"); + } + return result; + } + private void logDecision( String decisionId, JsonNode input, diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java index ed96d4ec..14a1fd11 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java @@ -157,12 +157,16 @@ public String toString() { } public static class DecisionLogsConfig { + + /** Default {@code mask_decision} path, applied when the configuration does not set one. */ + public static final String DEFAULT_MASK_DECISION = "system/log/mask"; + private Boolean console = false; private ReportingConfig reporting; @JsonProperty("mask_decision") - private String maskDecision = "system/log/mask"; + private String maskDecision = DEFAULT_MASK_DECISION; @JsonProperty("drop_decision") private String dropDecision = "system/log/drop"; diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java index 2748c20b..d0705d9f 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java @@ -8,6 +8,7 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; @@ -17,8 +18,11 @@ import java.util.concurrent.TimeUnit; import io.github.open_policy_agent.opa.bundle.Bundle; import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.ir.policy.Policy; import io.github.open_policy_agent.opa.logging.Logger; import io.github.open_policy_agent.opa.metrics.Metrics; +import io.github.open_policy_agent.opa.rego.Engine; +import io.github.open_policy_agent.opa.storage.Store; /** * Plugin that logs policy decision events. @@ -37,6 +41,7 @@ public final class DecisionLogPlugin implements Plugin { private DecisionLogs decisionLogs; private PluginManager manager; private ScheduledExecutorService scheduler; + private PluginManager.BundleActivationListener maskListener; public DecisionLogPlugin() {} @@ -125,6 +130,11 @@ public Plugin initialize(PluginManager manager) { .setMaxDelaySeconds(logsConfig.getMaxDelaySeconds()) .setResource(logsConfig.getResource()) .setReporting(logsConfig.getReporting()); + + // A new bundle means a new policy, so the cached mask query has to be rebuilt. + DecisionLogs logs = plugin.decisionLogs; + plugin.maskListener = bundleName -> logs.dropMaskQuery(); + manager.registerBundleActivationListener(plugin.maskListener); } return plugin; @@ -184,6 +194,11 @@ private void scheduleNextFlush(int minDelay, int maxDelay) { @Override public void stop() { + if (maskListener != null) { + manager.deregisterBundleActivationListener(maskListener); + maskListener = null; + } + if (scheduler != null) { manager.getLogger().info("Stopping decision logs plugin..."); @@ -258,14 +273,18 @@ public static class DecisionLogs { private final Logger logger; private final PluginManager manager; private final ConcurrentLinkedQueue buffer = new ConcurrentLinkedQueue<>(); + private final Object maskLock = new Object(); private Boolean console; private String service; - private String maskDecision; + private String maskEntrypoint; private String dropDecision; private Integer minDelaySeconds; private Integer maxDelaySeconds; private String resource; private Config.ReportingConfig reporting; + private Engine.PreparedQuery maskQuery; + private String maskQueryFailure; + private boolean maskQueryPrepared; private DecisionLogs(Logger logger, PluginManager manager) { this.logger = logger; @@ -287,7 +306,10 @@ public DecisionLogs setService(String service) { } public DecisionLogs setMaskDecision(String maskDecision) { - this.maskDecision = maskDecision; + // Configured as a data path ("/system/log/mask"); plan entrypoints have no leading slash. + String entrypoint = maskDecision == null ? "" : maskDecision.trim().replaceAll("^/+|/+$", ""); + this.maskEntrypoint = entrypoint.isEmpty() ? null : entrypoint; + dropMaskQuery(); return this; } @@ -361,9 +383,12 @@ public void logDecision( buildDecisionEvent( decisionId, input, result, path, requestedBy, timestamp, metrics, ndCacheValues); - // TODO: Apply mask decision policy if configured // TODO: Apply drop decision policy if configured + if (!applyMask(event)) { + return; // masking failed: drop the event rather than log it unmasked, as OPA Go does + } + // Add to buffer (thread-safe) buffer.add(event); @@ -387,6 +412,111 @@ public void logDecision( } } + /** + * Evaluate the configured {@code mask_decision} policy against the event and apply the + * redactions it returns. The event is the policy's input, so rules address the decision's data + * as {@code /input/...}, {@code /result/...} or {@code /nd_builtin_cache/...}. + * + * @param event the decision event to redact in place + * @return true when the event may be logged, false when masking failed and it must be dropped + */ + private boolean applyMask(ObjectNode event) { + String entrypoint = maskEntrypoint; + if (entrypoint == null) { + return true; + } + + try { + Engine.PreparedQuery query = maskQuery(entrypoint); + if (query == null) { + return true; // no mask policy in the bundle + } + + // The typed overload strips the {"result": } envelope IR plans add. + List results = query.eval(MAPPER.convertValue(event, Object.class), Object.class); + if (results.isEmpty()) { + return true; // mask rule undefined for this event + } + + MaskRuleSet.parse(MAPPER.valueToTree(results.get(0))).apply(event); + return true; + } catch (Exception e) { + logger.error("Log event masking failed: %s", describe(e)); + return false; + } + } + + // Prepared on first use and cached until a new bundle is activated (Go-OPA's prepareOnce). + // Returns null when no plan holds the mask entrypoint. A preparation failure is cached too and + // re-raised per event, so a broken mask policy drops events instead of logging them unmasked. + private Engine.PreparedQuery maskQuery(String entrypoint) { + synchronized (maskLock) { + if (!maskQueryPrepared) { + try { + maskQuery = prepareMaskQuery(entrypoint); + } catch (RuntimeException e) { + maskQueryFailure = describe(e); + } + // Set only after the attempt yields a query or a cached failure: an Error leaves the + // flag unset and propagates, rather than quietly disabling masking. + maskQueryPrepared = true; + } + if (maskQueryFailure != null) { + throw new IllegalStateException(maskQueryFailure); + } + return maskQuery; + } + } + + private Engine.PreparedQuery prepareMaskQuery(String entrypoint) { + Store store = manager.getStore(); + Policy policy = store.getIrPolicyForEntrypoint(entrypoint); + // getIrPolicyForEntrypoint falls back to the first policy it finds, so the plan itself has + // to be looked up to tell "no mask policy" apart from "some other policy". + if (policy == null + || policy.getPlans() == null + || policy.getPlans().getPlans() == null + || policy.getPlans().getPlanByName(entrypoint) == null) { + reportMissingMaskPolicy(entrypoint); + return null; + } + + return new Engine.Builder() + .withStore(store) + .withEntrypoint(entrypoint) + .build() + .prepareForEvaluation() + .build(); + } + + // A mask policy not built as a plan entrypoint leaves masking silently inactive. The default + // path is absent in most deployments, so only an explicitly configured one is worth a warning. + private void reportMissingMaskPolicy(String entrypoint) { + if (Config.DecisionLogsConfig.DEFAULT_MASK_DECISION.equals(entrypoint)) { + logger.debug("No decision log mask policy found for entrypoint '%s'", entrypoint); + return; + } + + logger.warn( + "Decision log masking is inactive: no plan for mask_decision entrypoint '%s'." + + " Was the bundle built with 'opa build -t plan -e %s ...'?", + entrypoint, entrypoint); + } + + /** Discard the cached mask query so the next masked event re-prepares it. */ + void dropMaskQuery() { + synchronized (maskLock) { + maskQueryPrepared = false; + maskQuery = null; + maskQueryFailure = null; + } + } + + // Not every exception carries a message, so fall back to the type name rather than "null". + private static String describe(Throwable t) { + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + /** * Build a decision event JSON object per OPA specification. * diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/MaskRuleSet.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/MaskRuleSet.java new file mode 100644 index 00000000..ec78ed76 --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/MaskRuleSet.java @@ -0,0 +1,335 @@ +package io.github.open_policy_agent.opa.plugins; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Set of mask rules produced by the configured {@code mask_decision} policy, applied to a decision + * log event before it is buffered or written to the console. A rule is either the shorthand string + * form ({@code "/input/password"}, which removes the field) or the structured form + * ({@code {"op": "upsert", "path": "/input/password", "value": x}}). + * + *

Mirrors {@code plugins/logs/mask.go} in OPA's Go implementation, including its silent skipping + * of undefined paths. The one deliberate divergence is noted in {@link MaskRule#remove}. + */ +final class MaskRuleSet { + + private static final String OP_REMOVE = "remove"; + private static final String OP_UPSERT = "upsert"; + + /** Event fields a mask rule is allowed to target. */ + private static final Set TARGETS = Set.of("input", "result", "nd_builtin_cache"); + + private final List rules; + + private MaskRuleSet(List rules) { + this.rules = rules; + } + + /** + * Parse the value returned by the mask policy. + * + * @param value the mask decision, expected to be an array of rules + * @return the parsed rule set + * @throws IllegalArgumentException if the value is not an array of well-formed rules, which OPA + * Go treats as a masking failure that drops the event + */ + static MaskRuleSet parse(JsonNode value) { + if (value == null || !value.isArray()) { + throw new IllegalArgumentException("unexpected rule format " + describe(value)); + } + + List parsed = new ArrayList<>(); + for (JsonNode raw : value) { + if (raw.isTextual()) { + parsed.add(new MaskRule(OP_REMOVE, raw.textValue(), null)); + } else if (raw.isObject()) { + parsed.add(new MaskRule(stringField(raw, "op"), stringField(raw, "path"), raw.get("value"))); + } else { + throw new IllegalArgumentException("invalid mask rule format encountered: " + describe(raw)); + } + } + return new MaskRuleSet(parsed); + } + + /** + * Apply every rule to the event in order, skipping rules whose path is undefined. Modified event + * fields are copied first, so the input and result nodes owned by the caller stay untouched. + * + * @param event the decision event to redact in place + */ + void apply(ObjectNode event) { + Set copied = new HashSet<>(); + for (MaskRule rule : rules) { + rule.apply(event, copied); + } + } + + // Like Go's getString: absent is "" (rejected downstream), present but unusable is an error. + private static String stringField(JsonNode rule, String field) { + JsonNode value = rule.get(field); + if (value == null) { + return ""; + } + if (!value.isTextual() || value.textValue().isEmpty()) { + throw new IllegalArgumentException("invalid \"" + field + "\" value: " + value); + } + return value.textValue(); + } + + private static String describe(JsonNode value) { + return value == null ? "null" : value + " (" + value.getNodeType() + ")"; + } + + /** A single mask rule. */ + private static final class MaskRule { + + private final String op; + private final String[] parts; + private final JsonNode value; + /** True when the rule targets a whole event field (e.g. {@code /input}). */ + private final boolean modifyFullObj; + + MaskRule(String op, String path, JsonNode value) { + if (path.isEmpty()) { + throw new IllegalArgumentException("mask must be non-empty"); + } + if (!path.startsWith("/")) { + throw new IllegalArgumentException("mask must be slash-prefixed"); + } + + String[] rawParts = path.substring(1).split("/", -1); + if (!TARGETS.contains(rawParts[0])) { + throw new IllegalArgumentException("mask prefix not allowed: " + rawParts[0]); + } + if (!OP_REMOVE.equals(op) && !OP_UPSERT.equals(op)) { + throw new IllegalArgumentException("mask op is not supported: " + op); + } + + this.op = op; + this.value = value; + this.parts = new String[rawParts.length]; + for (int i = 0; i < rawParts.length; i++) { + // As in Go: an invalid escape rejects the rule, and the escaped form is what is used to + // look segments up and to report the rule in erased/masked. + checkEscaping(rawParts[i]); + this.parts[i] = pathEscape(rawParts[i]); + } + this.modifyFullObj = rawParts.length == 1; + } + + void apply(ObjectNode event, Set copied) { + String field = parts[0]; + JsonNode target = event.get(field); + // An absent field is Go's nil Input/Result/NDBuiltinCache pointer; a present null one is + // still a target for a whole-field rule. + if (target == null) { + return; + } + + if (OP_REMOVE.equals(op)) { + if (modifyFullObj) { + event.remove(field); + } else if (!remove(mutable(event, field, copied))) { + return; + } + record(event, "erased"); + return; + } + + if (modifyFullObj) { + event.set(field, value == null ? NullNode.getInstance() : value); + } else { + JsonNode node = mutable(event, field, copied); + // Go only upserts into an object; an array or scalar target is left alone. + if (!node.isObject() || !upsert((ObjectNode) node)) { + return; + } + } + record(event, "masked"); + } + + // Returns false when the path is undefined, leaving the event untouched. + private boolean remove(JsonNode root) { + JsonNode node = root; + for (int i = 1; i < parts.length - 1; i++) { + node = child(node, parts[i]); + if (node == null) { + return false; + } + } + + String target = parts[parts.length - 1]; + if (node.isObject()) { + return ((ObjectNode) node).remove(target) != null; + } + if (node.isArray()) { + // Deliberate divergence: for an array-valued field ("/input/1" with an array input), Go + // cannot write the shortened slice back through a nil parent and skips the rule. + ArrayNode array = (ArrayNode) node; + int index = index(target, array.size()); + if (index < 0) { + return false; + } + array.remove(index); + return true; + } + return false; + } + + // Creates missing intermediate objects; returns false when the path cannot be created. + private boolean upsert(ObjectNode root) { + JsonNode node = root; + for (int i = 1; i < parts.length - 1; i++) { + if (node.isObject()) { + ObjectNode object = (ObjectNode) node; + JsonNode next = object.get(parts[i]); + node = next != null ? next : object.putObject(parts[i]); + } else if (node.isArray()) { + // Go grows an undersized slice into a local copy that is never written back, so an + // out-of-range index can never resolve to a usable node. + int index = index(parts[i], ((ArrayNode) node).size()); + if (index < 0) { + return false; + } + node = node.get(index); + } else { + return false; + } + } + + JsonNode inserted = value == null ? NullNode.getInstance() : value; + String target = parts[parts.length - 1]; + if (node.isObject()) { + ((ObjectNode) node).set(target, inserted); + return true; + } + if (node.isArray()) { + ArrayNode array = (ArrayNode) node; + int index = index(target, array.size()); + if (index < 0) { + return false; + } + array.set(index, inserted); + return true; + } + return false; + } + + // Copy the field on first modification so the caller's nodes are never mutated. + private static JsonNode mutable(ObjectNode event, String field, Set copied) { + if (copied.add(field)) { + JsonNode copy = event.get(field).deepCopy(); + event.set(field, copy); + return copy; + } + return event.get(field); + } + + private static JsonNode child(JsonNode node, String key) { + if (node.isObject()) { + return node.get(key); + } + if (node.isArray()) { + int index = index(key, node.size()); + return index < 0 ? null : node.get(index); + } + return null; + } + + private static int index(String key, int size) { + int index; + try { + index = Integer.parseInt(key); + } catch (NumberFormatException e) { + return -1; + } + return (index < 0 || index >= size) ? -1 : index; + } + + private void record(ObjectNode event, String field) { + JsonNode existing = event.get(field); + ArrayNode applied = existing instanceof ArrayNode ? (ArrayNode) existing : event.putArray(field); + applied.add(path()); + } + + private String path() { + return "/" + String.join("/", parts); + } + + @Override + public String toString() { + return path(); + } + } + + // Mirrors Go's url.PathUnescape validation: '%' must introduce two hex digits. + private static void checkEscaping(String part) { + int i = 0; + while (i < part.length()) { + if (part.charAt(i) == '%') { + if (i + 2 >= part.length() || !isHex(part.charAt(i + 1)) || !isHex(part.charAt(i + 2))) { + throw new IllegalArgumentException( + "invalid URL escape \"" + part.substring(i, Math.min(i + 3, part.length())) + "\""); + } + i += 3; + } else { + i++; + } + } + } + + private static boolean isHex(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + // Mirrors Go's url.PathEscape: unreserved characters and the sub-delimiters a path segment may + // carry are left alone, everything else is percent-encoded. + private static String pathEscape(String part) { + byte[] bytes = part.getBytes(StandardCharsets.UTF_8); + StringBuilder escaped = new StringBuilder(bytes.length); + for (byte b : bytes) { + char c = (char) (b & 0xFF); + if (shouldEscape(c)) { + escaped.append('%').append(Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16))); + escaped.append(Character.toUpperCase(Character.forDigit(b & 0xF, 16))); + } else { + escaped.append(c); + } + } + return escaped.toString(); + } + + private static boolean shouldEscape(char c) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + return false; + } + switch (c) { + case '-': + case '_': + case '.': + case '~': + case '$': + case '&': + case '+': + case ':': + case '=': + case '@': + return false; + default: + return true; + } + } + + @Override + public String toString() { + return "MaskRuleSet" + rules; + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaDecisionLogMaskTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaDecisionLogMaskTest.java new file mode 100644 index 00000000..bf47b133 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaDecisionLogMaskTest.java @@ -0,0 +1,164 @@ +package io.github.open_policy_agent.opa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.GZIPOutputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.logging.Logger; + +/** + * End-to-end decision logging through a real {@link Opa} instance, with bundles loaded from disk. + * Covers what {@code DecisionLogPluginMaskTest} cannot: that the event handed to the mask policy, + * and the result handed to the caller, hold the decision rather than the plan's + * {@code {"result": }} envelope. + */ +class OpaDecisionLogMaskTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir private Path bundleDir; + + @Test + void makeDecision_masksEventWithoutTouchingTheCallersResult() throws Exception { + writeBundle("authz.tar.gz", "/mask/plan-authz.json", "[\"authz\"]"); + writeBundle("mask.tar.gz", "/mask/plan.json", "[\"system\", \"test\"]"); + + // A spy over the real logger so a bundle or plugin failure still shows up in the test output. + Logger logger = spy(new Logger.StandardLogger()); + Opa opa = + new Opa.Builder() + .withConfig(config()) + .withDefaultEntrypoint("authz/decision") + .withLogger(logger) + .build(); + + try { + ObjectNode input = MAPPER.createObjectNode(); + input.put("user", "alice"); + input.put("password", "secret"); + input.put("token", "eyJhbGciOi"); + + Opa.DecisionResult decision = + opa.makeDecision(new Opa.DecisionOptions().setInput(input).setDecisionID("decision-1")); + + assertEquals("eyJhbGciOi", decision.getResult().get("token").asText()); + assertTrue(decision.getResult().get("allow").asBoolean()); + assertEquals("secret", input.get("password").asText(), "caller's input should be untouched"); + + JsonNode event = loggedEvent(logger); + assertEquals("decision-1", event.get("decision_id").asText()); + assertEquals("alice", event.get("input").get("user").asText()); + assertFalse(event.get("input").has("password"), "password should have been erased"); + // Fails if the event carries the plan envelope: /result/token would not resolve. + assertEquals("**REDACTED**", event.get("result").get("token").asText()); + assertTrue(event.get("result").get("allow").asBoolean()); + assertEquals("[\"/input/password\"]", event.get("erased").toString()); + assertEquals("[\"/result/token\"]", event.get("masked").toString()); + } finally { + opa.close(); + } + } + + @Test + void makeDecision_scalarDecision_returnsTheValueNotThePlanEnvelope() throws Exception { + // The usage both READMEs document: getResult().asBoolean() on a boolean rule. + writeBundle("authz.tar.gz", "/mask/plan-authz.json", "[\"authz\"]"); + writeBundle("mask.tar.gz", "/mask/plan.json", "[\"system\", \"test\"]"); + + Logger logger = spy(new Logger.StandardLogger()); + Opa opa = + new Opa.Builder() + .withConfig(config()) + .withDefaultEntrypoint("authz/allow") + .withLogger(logger) + .build(); + + try { + Opa.DecisionResult decision = + opa.makeDecision(MAPPER.createObjectNode().put("user", "alice")); + + assertTrue(decision.getResult().asBoolean()); + assertTrue(decision.getResultAs(Boolean.class)); + assertTrue(loggedEvent(logger).get("result").asBoolean()); + } finally { + opa.close(); + } + } + + private Config config() { + Config config = new Config(); + config.setServices( + Map.of( + "local", + new Config.ServiceConfig().setName("local").setUrl(bundleDir.toUri().toString()))); + + Map bundles = new LinkedHashMap<>(); + bundles.put( + "authz", new Config.BundleConfig().setService("local").setResource("authz.tar.gz")); + bundles.put("mask", new Config.BundleConfig().setService("local").setResource("mask.tar.gz")); + config.setBundles(bundles); + + config.setDecisionLogs(new Config.DecisionLogsConfig().setConsole(true)); + return config; + } + + /** The console event, which is the same object that gets buffered. */ + private static JsonNode loggedEvent(Logger logger) throws IOException { + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(logger).info(eq("Decision: %s"), captor.capture()); + return MAPPER.readTree(captor.getValue()); + } + + /** Bundle tarball for BundlePlugin to load over file://; roots keep the two from conflicting. */ + private void writeBundle(String name, String planResource, String roots) throws IOException { + byte[] plan; + try (InputStream in = getClass().getResourceAsStream(planResource)) { + assertNotNull(in, "missing plan fixture " + planResource); + plan = in.readAllBytes(); + } + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(bytes); + TarArchiveOutputStream tar = new TarArchiveOutputStream(gzip)) { + addEntry(tar, "plan.json", plan); + addEntry( + tar, + ".manifest", + ("{\"revision\": \"" + name + "-1\", \"roots\": " + roots + "}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + tar.finish(); + } + + Files.write(bundleDir.resolve(name), bytes.toByteArray()); + } + + private static void addEntry(TarArchiveOutputStream tar, String name, byte[] content) + throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name); + entry.setSize(content.length); + tar.putArchiveEntry(entry); + tar.write(content); + tar.closeArchiveEntry(); + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/DecisionLogPluginMaskTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/DecisionLogPluginMaskTest.java new file mode 100644 index 00000000..c3be52dc --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/DecisionLogPluginMaskTest.java @@ -0,0 +1,324 @@ +package io.github.open_policy_agent.opa.plugins; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.ServiceLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.bundle.Bundle; +import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.ir.PolicyReader; +import io.github.open_policy_agent.opa.logging.Logger; +import io.github.open_policy_agent.opa.storage.InMem; +import io.github.open_policy_agent.opa.storage.Store; + +/** + * Tests for applying the configured {@code mask_decision} policy to decision log events. The mask + * policies come from the compiled plans in {@code src/test/resources/mask}. + */ +class DecisionLogPluginMaskTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final PolicyReader POLICY_READER = + ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); + + private Logger mockLogger; + private Store store; + private Config config; + private PluginManager manager; + + @BeforeEach + void setUp() { + mockLogger = mock(Logger.class); + store = new InMem(); + config = new Config(); + config.setServices( + Collections.singletonMap( + "test-service", + new Config.ServiceConfig().setName("test-service").setUrl("https://example.com"))); + } + + private void loadMaskPolicies() throws IOException { + loadPlan("/mask/plan.json"); + } + + private void loadPlan(String resource) throws IOException { + try (InputStream plan = getClass().getResourceAsStream(resource)) { + assertNotNull(plan, "missing mask plan fixture " + resource); + Bundle bundle = new Bundle.Builder().withIrPolicy(POLICY_READER.read(plan)).build(); + store.write("mask-bundle", bundle, new RegoObject()); + } + } + + private DecisionLogPlugin startPlugin(String maskDecision) { + config.setDecisionLogs( + new Config.DecisionLogsConfig() + .setConsole(true) + .setService("test-service") + .setMaskDecision(maskDecision)); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + DecisionLogPlugin plugin = (DecisionLogPlugin) new DecisionLogPlugin().initialize(manager); + plugin.start(); + manager.registerPlugin("decision_logs", plugin); + return plugin; + } + + private static JsonNode input() { + ObjectNode input = MAPPER.createObjectNode(); + input.put("user", "alice"); + input.put("password", "secret"); + return input; + } + + private static JsonNode result() { + ObjectNode result = MAPPER.createObjectNode(); + result.put("allow", true); + result.put("token", "eyJhbGciOi"); + return result; + } + + private void logDecision(DecisionLogPlugin plugin, JsonNode input, JsonNode result) { + plugin + .getDecisionLogs() + .logDecision("decision-1", input, result, "authz/allow", null, 0, null, null); + } + + /** The console event, which is the same object that gets buffered. */ + private JsonNode loggedEvent() throws IOException { + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(mockLogger).info(eq("Decision: %s"), captor.capture()); + return MAPPER.readTree(captor.getValue()); + } + + @Test + void logDecision_maskPolicyApplied_removesAndUpsertsFields() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + logDecision(plugin, input(), result()); + + JsonNode event = loggedEvent(); + assertFalse(event.get("input").has("password"), "password should have been erased"); + assertEquals("alice", event.get("input").get("user").asText()); + assertEquals("**REDACTED**", event.get("result").get("token").asText()); + assertTrue(event.get("result").get("allow").asBoolean()); + assertEquals("[\"/input/password\"]", event.get("erased").toString()); + assertEquals("[\"/result/token\"]", event.get("masked").toString()); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_maskPolicyLeadingSlashConfig_isAccepted() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("/system/log/mask"); + + logDecision(plugin, input(), result()); + + assertFalse(loggedEvent().get("input").has("password")); + } + + @Test + void logDecision_maskPolicyDoesNotMatch_leavesEventUnchanged() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + ObjectNode input = MAPPER.createObjectNode().put("user", "alice"); + ObjectNode result = MAPPER.createObjectNode().put("allow", true); + logDecision(plugin, input, result); + + JsonNode event = loggedEvent(); + assertEquals("alice", event.get("input").get("user").asText()); + assertFalse(event.has("erased")); + assertFalse(event.has("masked")); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_noMaskPolicyInBundle_logsEventUnmaskedAndWarns() throws IOException { + // Bundles are loaded, but none of them was built with the mask entrypoint. + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/other_mask"); + + logDecision(plugin, input(), result()); + + assertEquals("secret", loggedEvent().get("input").get("password").asText()); + verify(mockLogger) + .warn( + contains("masking is inactive"), + eq("system/log/other_mask"), + eq("system/log/other_mask")); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_noBundlesLoaded_logsEventUnmasked() throws IOException { + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + logDecision(plugin, input(), result()); + + assertEquals("secret", loggedEvent().get("input").get("password").asText()); + // The default applies to every deployment, so its absence is not worth a warning. + verify(mockLogger, never()).warn(anyString(), any(), any()); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_uploadedBatch_containsTheMaskedEvent() throws Exception { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + ServicePlugin services = mock(ServicePlugin.class); + ServicePlugin.Service service = mock(ServicePlugin.Service.class); + when(services.getService("test-service")).thenReturn(service); + manager.registerPlugin("services", services); + + logDecision(plugin, input(), result()); + plugin.flush(); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(service).post(eq("/logs"), body.capture()); + JsonNode batch = MAPPER.readTree(body.getValue()); + assertEquals(1, batch.size()); + assertFalse(batch.get(0).get("input").has("password"), "password should have been erased"); + assertEquals("**REDACTED**", batch.get(0).get("result").get("token").asText()); + } + + @Test + void logDecision_missingMaskPolicy_warnsOncePerPreparation() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/other_mask"); + + logDecision(plugin, input(), result()); + logDecision(plugin, input(), result()); + + verify(mockLogger, times(1)).warn(contains("masking is inactive"), any(), any()); + } + + @Test + void logDecision_bundleActivationAfterFailure_reenablesMasking() throws IOException { + loadPlan("/mask/plan-error.json"); + DecisionLogPlugin plugin = startPlugin("test/log/mask_error"); + + logDecision(plugin, input(), result()); + verify(mockLogger).error(eq("Log event masking failed: %s"), contains("regex.match")); + + loadPlan("/mask/plan-fixed.json"); + manager.notifyBundleActivation("mask-bundle"); + reset(mockLogger); + logDecision(plugin, input(), result()); + + assertFalse(loggedEvent().get("input").has("password")); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_maskDecisionWithTrailingSlash_isAccepted() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/mask/"); + + logDecision(plugin, input(), result()); + + assertFalse(loggedEvent().get("input").has("password")); + } + + @Test + void logDecision_maskDecisionEmpty_logsEventUnmasked() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin(""); + + logDecision(plugin, input(), result()); + + assertEquals("secret", loggedEvent().get("input").get("password").asText()); + verify(mockLogger, never()).warn(anyString(), any(), any()); + } + + @Test + void logDecision_maskDecisionUnset_logsEventUnmasked() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin(null); + + logDecision(plugin, input(), result()); + + assertEquals("secret", loggedEvent().get("input").get("password").asText()); + verify(mockLogger, never()).error(anyString(), any()); + } + + @Test + void logDecision_invalidMaskRule_dropsEvent() throws IOException { + // test/log/mask_invalid returns "/labels/environment", which is not a maskable path. + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("test/log/mask_invalid"); + + logDecision(plugin, input(), result()); + + verify(mockLogger).error(eq("Log event masking failed: %s"), contains("mask prefix not allowed")); + verify(mockLogger, never()).info(eq("Decision: %s"), anyString()); + assertNothingBuffered(plugin); + } + + @Test + void logDecision_maskEvaluationFails_dropsEvent() throws IOException { + // test/log/mask_error calls regex.match, which is not on this module's classpath. + loadPlan("/mask/plan-error.json"); + DecisionLogPlugin plugin = startPlugin("test/log/mask_error"); + + // Twice: the cached preparation failure has to keep dropping events. + logDecision(plugin, input(), result()); + logDecision(plugin, input(), result()); + + verify(mockLogger, times(2)).error(eq("Log event masking failed: %s"), contains("regex.match")); + verify(mockLogger, never()).info(eq("Decision: %s"), anyString()); + assertNothingBuffered(plugin); + } + + @Test + void logDecision_doesNotMutateCallerNodes() throws IOException { + loadMaskPolicies(); + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + JsonNode input = input(); + JsonNode result = result(); + logDecision(plugin, input, result); + + assertEquals("secret", input.get("password").asText()); + assertEquals("eyJhbGciOi", result.get("token").asText()); + } + + @Test + void logDecision_bundleActivation_rebuildsMaskQuery() throws IOException { + DecisionLogPlugin plugin = startPlugin("system/log/mask"); + + logDecision(plugin, input(), result()); + assertEquals("secret", loggedEvent().get("input").get("password").asText()); + + loadMaskPolicies(); + manager.notifyBundleActivation("mask-bundle"); + + reset(mockLogger); + logDecision(plugin, input(), result()); + assertFalse(loggedEvent().get("input").has("password"), "mask policy should apply after reload"); + } + + /** A dropped event never reaches the buffer, so flushing has nothing to report. */ + private void assertNothingBuffered(DecisionLogPlugin plugin) { + plugin.flush(); + verify(mockLogger, never()).debug(eq("Flushed %d decision log events"), anyInt()); + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/MaskRuleSetTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/MaskRuleSetTest.java new file mode 100644 index 00000000..b462537a --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/MaskRuleSetTest.java @@ -0,0 +1,271 @@ +package io.github.open_policy_agent.opa.plugins; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.Test; + +/** Checked against the behavior of {@code plugins/logs/mask.go} in OPA's Go implementation. */ +class MaskRuleSetTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static ObjectNode json(String raw) throws JsonProcessingException { + return (ObjectNode) MAPPER.readTree(raw); + } + + private static void assertMasked(String rules, String event, String expected) throws Exception { + ObjectNode actual = json(event); + MaskRuleSet.parse(MAPPER.readTree(rules)).apply(actual); + assertEquals(json(expected), actual); + } + + private static String parseError(String rules) { + JsonNode parsed; + try { + parsed = MAPPER.readTree(rules); + } catch (JsonProcessingException e) { + throw new AssertionError(e); + } + return assertThrows(IllegalArgumentException.class, () -> MaskRuleSet.parse(parsed)).getMessage(); + } + + @Test + void apply_shorthandRule_removesFieldAndRecordsErased() throws Exception { + assertMasked( + "[\"/input/password\"]", + "{\"input\": {\"user\": \"alice\", \"password\": \"secret\"}}", + "{\"input\": {\"user\": \"alice\"}, \"erased\": [\"/input/password\"]}"); + } + + @Test + void apply_removeOp_removesFieldAndRecordsErased() throws Exception { + assertMasked( + "[{\"op\": \"remove\", \"path\": \"/input/password\"}]", + "{\"input\": {\"user\": \"alice\", \"password\": \"secret\"}}", + "{\"input\": {\"user\": \"alice\"}, \"erased\": [\"/input/password\"]}"); + } + + @Test + void apply_removeOp_ignoresValue() throws Exception { + assertMasked( + "[{\"op\": \"remove\", \"path\": \"/input/password\", \"value\": \"x\"}]", + "{\"input\": {\"password\": \"secret\"}}", + "{\"input\": {}, \"erased\": [\"/input/password\"]}"); + } + + @Test + void apply_removeNestedPath_removesLeafOnly() throws Exception { + assertMasked( + "[\"/input/foo/0/bar\"]", + "{\"input\": {\"foo\": [{\"bar\": 1, \"baz\": 2}]}}", + "{\"input\": {\"foo\": [{\"baz\": 2}]}, \"erased\": [\"/input/foo/0/bar\"]}"); + } + + @Test + void apply_removeArrayElement_shrinksArray() throws Exception { + assertMasked( + "[\"/input/foo/1\"]", + "{\"input\": {\"foo\": [1, 2, 3]}}", + "{\"input\": {\"foo\": [1, 3]}, \"erased\": [\"/input/foo/1\"]}"); + } + + @Test + void apply_removeElementOfArrayValuedField_shrinksArray() throws Exception { + // Deliberate divergence from Go, which skips the rule here. See MaskRule.remove. + assertMasked( + "[\"/input/1\"]", + "{\"input\": [1, 2, 3]}", + "{\"input\": [1, 3], \"erased\": [\"/input/1\"]}"); + } + + @Test + void apply_removeWholeField_dropsFieldFromEvent() throws Exception { + assertMasked( + "[\"/input\"]", + "{\"input\": {\"password\": \"secret\"}, \"result\": true}", + "{\"result\": true, \"erased\": [\"/input\"]}"); + } + + @Test + void apply_upsert_replacesValueAndRecordsMasked() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/input/password\", \"value\": \"**REDACTED**\"}]", + "{\"input\": {\"user\": \"alice\", \"password\": \"secret\"}}", + "{\"input\": {\"user\": \"alice\", \"password\": \"**REDACTED**\"}," + + " \"masked\": [\"/input/password\"]}"); + } + + @Test + void apply_upsert_createsMissingIntermediateObjects() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/input/a/b\", \"value\": 1}]", + "{\"input\": {}}", + "{\"input\": {\"a\": {\"b\": 1}}, \"masked\": [\"/input/a/b\"]}"); + } + + @Test + void apply_upsertWithoutValue_setsNull() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/input/password\"}]", + "{\"input\": {\"password\": \"secret\"}}", + "{\"input\": {\"password\": null}, \"masked\": [\"/input/password\"]}"); + } + + @Test + void apply_upsertWholeField_replacesField() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/result\", \"value\": {\"allow\": false}}]", + "{\"input\": {}, \"result\": {\"allow\": true, \"token\": \"t\"}}", + "{\"input\": {}, \"result\": {\"allow\": false}, \"masked\": [\"/result\"]}"); + } + + @Test + void apply_upsertIntoArrayTarget_isNoOp() throws Exception { + // Go only upserts into an object, so an array-valued input is left alone. + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/input/0\", \"value\": 1}]", + "{\"input\": [\"a\"]}", + "{\"input\": [\"a\"]}"); + } + + @Test + void apply_upsertThroughNonContainer_isNoOp() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/input/foo/bar\", \"value\": 1}]", + "{\"input\": {\"foo\": \"scalar\"}}", + "{\"input\": {\"foo\": \"scalar\"}}"); + } + + @Test + void apply_undefinedPath_isNoOpAndRecordsNothing() throws Exception { + assertMasked( + "[\"/input/password\", \"/input/a/b\"]", + "{\"input\": {\"user\": \"alice\"}}", + "{\"input\": {\"user\": \"alice\"}}"); + } + + @Test + void apply_absentTarget_isNoOp() throws Exception { + assertMasked("[\"/result/token\"]", "{\"input\": {}}", "{\"input\": {}}"); + assertMasked("[\"/result\"]", "{\"input\": {}}", "{\"input\": {}}"); + } + + @Test + void apply_nullTarget_masksWholeFieldOnly() throws Exception { + // Go nils out (and records) the whole field, but cannot descend into a null one. + assertMasked("[\"/result\"]", "{\"result\": null}", "{\"erased\": [\"/result\"]}"); + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/result\", \"value\": 1}]", + "{\"result\": null}", + "{\"result\": 1, \"masked\": [\"/result\"]}"); + assertMasked("[\"/result/token\"]", "{\"result\": null}", "{\"result\": null}"); + } + + @Test + void apply_ndBuiltinCacheTarget_isSupported() throws Exception { + assertMasked( + "[{\"op\": \"upsert\", \"path\": \"/nd_builtin_cache/rand.intn\", \"value\": \"x\"}]", + "{\"nd_builtin_cache\": {\"rand.intn\": {\"[\\\"z\\\",15]\": 7}}}", + "{\"nd_builtin_cache\": {\"rand.intn\": \"x\"}," + + " \"masked\": [\"/nd_builtin_cache/rand.intn\"]}"); + } + + @Test + void apply_multipleRules_recordErasedAndMaskedSeparately() throws Exception { + assertMasked( + "[\"/input/password\"," + + " {\"op\": \"upsert\", \"path\": \"/input/jwt\", \"value\": \"redacted\"}," + + " \"/result/token\"]", + "{\"input\": {\"password\": \"secret\", \"jwt\": \"a.b.c\"}," + + " \"result\": {\"token\": \"t\", \"allow\": true}}", + "{\"input\": {\"jwt\": \"redacted\"}, \"result\": {\"allow\": true}," + + " \"erased\": [\"/input/password\", \"/result/token\"]," + + " \"masked\": [\"/input/jwt\"]}"); + } + + @Test + void apply_doesNotMutateOriginalNodes() throws Exception { + ObjectNode input = MAPPER.createObjectNode().put("password", "secret"); + ObjectNode result = MAPPER.createObjectNode().put("token", "t"); + ObjectNode event = MAPPER.createObjectNode(); + event.set("input", input); + event.set("result", result); + + MaskRuleSet.parse( + MAPPER.readTree( + "[\"/input/password\"," + + " {\"op\": \"upsert\", \"path\": \"/result/token\", \"value\": \"r\"}]")) + .apply(event); + + assertEquals(json("{\"password\": \"secret\"}"), input); + assertEquals(json("{\"token\": \"t\"}"), result); + assertEquals(json("{}"), event.get("input")); + assertEquals(json("{\"token\": \"r\"}"), event.get("result")); + } + + @Test + void apply_escapedPathSegment_usesEscapedForm() throws Exception { + // Go escapes each segment with url.PathEscape and matches keys against the escaped form. + assertMasked( + "[\"/input/a/%2F%2F/b\"]", + "{\"input\": {\"a\": {\"%252F%252F\": {\"b\": 1}}}}", + "{\"input\": {\"a\": {\"%252F%252F\": {}}}, \"erased\": [\"/input/a/%252F%252F/b\"]}"); + } + + @Test + void parse_notAnArray_throws() { + assertTrue(parseError("{\"op\": \"remove\"}").startsWith("unexpected rule format")); + assertTrue(parseError("\"/input/password\"").startsWith("unexpected rule format")); + } + + @Test + void parse_ruleOfUnsupportedType_throws() { + assertTrue(parseError("[[1, 2]]").startsWith("invalid mask rule format encountered")); + } + + @Test + void parse_emptyPath_throws() { + assertEquals("mask must be non-empty", parseError("[\"\"]")); + assertEquals("mask must be non-empty", parseError("[{\"op\": \"remove\"}]")); + } + + @Test + void parse_pathWithoutLeadingSlash_throws() { + assertEquals("mask must be slash-prefixed", parseError("[\"input/password\"]")); + } + + @Test + void parse_pathOutsideMaskableFields_throws() { + assertEquals("mask prefix not allowed: labels", parseError("[\"/labels/environment\"]")); + } + + @Test + void parse_missingOp_throws() { + // The structured form has no default op in OPA; only the shorthand string form implies remove. + assertEquals("mask op is not supported: ", parseError("[{\"path\": \"/input/password\"}]")); + } + + @Test + void parse_unsupportedOp_throws() { + assertEquals( + "mask op is not supported: replace", + parseError("[{\"op\": \"replace\", \"path\": \"/input/password\"}]")); + } + + @Test + void parse_nonStringOpOrPath_throws() { + assertEquals( + "invalid \"op\" value: 1", parseError("[{\"op\": 1, \"path\": \"/input/password\"}]")); + assertEquals( + "invalid \"path\" value: \"\"", parseError("[{\"op\": \"remove\", \"path\": \"\"}]")); + } + + @Test + void parse_invalidEscapeInPath_throws() { + assertEquals("invalid URL escape \"%F\"", parseError("[\"/input/a/%F/b\"]")); + } +} diff --git a/opa-services/src/test/resources/mask/authz.rego b/opa-services/src/test/resources/mask/authz.rego new file mode 100644 index 00000000..8ad9c526 --- /dev/null +++ b/opa-services/src/test/resources/mask/authz.rego @@ -0,0 +1,11 @@ +# Decision policy for OpaDecisionLogMaskTest. Regenerate plan-authz.json with: +# +# opa build -t plan -e authz/decision -e authz/allow -o bundle.tar.gz authz.rego \ +# && tar xzf bundle.tar.gz -O /plan.json > plan-authz.json +package authz + +import rego.v1 + +decision := {"allow": input.user == "alice", "token": input.token} + +allow if input.user == "alice" diff --git a/opa-services/src/test/resources/mask/errormask.rego b/opa-services/src/test/resources/mask/errormask.rego new file mode 100644 index 00000000..549742a8 --- /dev/null +++ b/opa-services/src/test/resources/mask/errormask.rego @@ -0,0 +1,12 @@ +# Mask policy whose evaluation fails: regex.match lives in opa-builtins-regex, which is not on the +# opa-services classpath. A separate plan because a missing builtin invalidates every entrypoint. +# +# opa build -t plan -e test/log/mask_error -o bundle.tar.gz errormask.rego \ +# && tar xzf bundle.tar.gz -O /plan.json > plan-error.json +package test.log + +import rego.v1 + +mask_error contains "/input/password" if { + regex.match("^secret", input.input.password) +} diff --git a/opa-services/src/test/resources/mask/fixedmask.rego b/opa-services/src/test/resources/mask/fixedmask.rego new file mode 100644 index 00000000..fc45d142 --- /dev/null +++ b/opa-services/src/test/resources/mask/fixedmask.rego @@ -0,0 +1,12 @@ +# Working stand-in for errormask.rego's entrypoint, to check that masking recovers once a good +# bundle is activated. Regenerate plan-fixed.json with: +# +# opa build -t plan -e test/log/mask_error -o bundle.tar.gz fixedmask.rego \ +# && tar xzf bundle.tar.gz -O /plan.json > plan-fixed.json +package test.log + +import rego.v1 + +mask_error contains "/input/password" if { + input.input.password +} diff --git a/opa-services/src/test/resources/mask/mask.rego b/opa-services/src/test/resources/mask/mask.rego new file mode 100644 index 00000000..053231c6 --- /dev/null +++ b/opa-services/src/test/resources/mask/mask.rego @@ -0,0 +1,17 @@ +# Mask policies for DecisionLogPluginMaskTest. Regenerate plan.json with: +# +# opa build -t plan -e system/log/mask -e test/log/mask_invalid \ +# -o bundle.tar.gz mask.rego testmask.rego && tar xzf bundle.tar.gz -O /plan.json > plan.json +package system.log + +import rego.v1 + +# Shorthand form. +mask contains "/input/password" if { + input.input.password +} + +# Structured form. +mask contains {"op": "upsert", "path": "/result/token", "value": "**REDACTED**"} if { + input.result.token +} diff --git a/opa-services/src/test/resources/mask/plan-authz.json b/opa-services/src/test/resources/mask/plan-authz.json new file mode 100644 index 00000000..00ea9c1a --- /dev/null +++ b/opa-services/src/test/resources/mask/plan-authz.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"user"},{"value":"alice"},{"value":"token"},{"value":"allow"}],"builtin_funcs":[{"name":"equal","decl":{"args":[{"name":"x","type":"any"},{"name":"y","type":"any"}],"result":{"description":"true if `x` is equal to `y`; false otherwise","name":"result","type":"boolean"},"type":"function"}}],"files":[{"value":"authz.rego"}]},"plans":{"plans":[{"name":"authz/decision","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.decision","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]},{"name":"authz/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.authz.decision","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":23,"row":13}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":5,"file":0,"col":23,"row":13}},{"type":"CallStmt","stmt":{"func":"equal","args":[{"type":"local","value":5},{"type":"string_index","value":2}],"result":6,"file":0,"col":23,"row":13}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":6},"target":7,"file":0,"col":23,"row":13}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":8,"file":0,"col":55,"row":13}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":8},"target":9,"file":0,"col":55,"row":13}},{"type":"MakeObjectStmt","stmt":{"target":10,"file":0,"col":13,"row":13}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":4},"value":{"type":"local","value":7},"object":10,"file":0,"col":13,"row":13}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"local","value":9},"object":10,"file":0,"col":13,"row":13}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":10},"target":11,"file":0,"col":13,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":11},"target":3,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":13}}]}],"path":["g0","authz","decision"]},{"name":"g0.data.authz.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":16}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":10,"row":16}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":2},"file":0,"col":10,"row":16}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":16}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":16}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":16}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":16}}]}],"path":["g0","authz","allow"]}]}} \ No newline at end of file diff --git a/opa-services/src/test/resources/mask/plan-error.json b/opa-services/src/test/resources/mask/plan-error.json new file mode 100644 index 00000000..5eb4a86b --- /dev/null +++ b/opa-services/src/test/resources/mask/plan-error.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"input"},{"value":"password"},{"value":"^secret"},{"value":"/input/password"}],"builtin_funcs":[{"name":"regex.match","decl":{"args":[{"description":"regular expression","name":"pattern","type":"string"},{"description":"value to match against `pattern`","name":"value","type":"string"}],"result":{"description":"true if `value` matches `pattern`","name":"result","type":"boolean"},"type":"function"}}],"files":[{"value":"errormask.rego"}]},"plans":{"plans":[{"name":"test/log/mask_error","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.log.mask_error","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.test.log.mask_error","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"MakeSetStmt","stmt":{"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":25,"row":14}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":25,"row":14}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":25,"row":14}},{"type":"CallStmt","stmt":{"func":"regex.match","args":[{"type":"string_index","value":3},{"type":"local","value":6}],"result":7,"file":0,"col":2,"row":14}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":7},"b":{"type":"bool","value":false},"file":0,"col":2,"row":14}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":4},"set":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":13}}]}],"path":["g0","test","log","mask_error"]}]}} \ No newline at end of file diff --git a/opa-services/src/test/resources/mask/plan-fixed.json b/opa-services/src/test/resources/mask/plan-fixed.json new file mode 100644 index 00000000..a03dc603 --- /dev/null +++ b/opa-services/src/test/resources/mask/plan-fixed.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"input"},{"value":"password"},{"value":"/input/password"}],"files":[{"value":"fixedmask.rego"}]},"plans":{"plans":[{"name":"test/log/mask_error","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.log.mask_error","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.test.log.mask_error","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"MakeSetStmt","stmt":{"target":2,"file":0,"col":1,"row":10}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":10}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":2,"row":11}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":2,"row":11}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":5},"b":{"type":"bool","value":false},"file":0,"col":2,"row":11}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":3},"set":2,"file":0,"col":1,"row":10}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":10}}]}],"path":["g0","test","log","mask_error"]}]}} \ No newline at end of file diff --git a/opa-services/src/test/resources/mask/plan.json b/opa-services/src/test/resources/mask/plan.json new file mode 100644 index 00000000..29954fc9 --- /dev/null +++ b/opa-services/src/test/resources/mask/plan.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"input"},{"value":"password"},{"value":"/input/password"},{"value":"token"},{"value":"op"},{"value":"upsert"},{"value":"path"},{"value":"/result/token"},{"value":"value"},{"value":"**REDACTED**"},{"value":"/labels/environment"}],"files":[{"value":"mask.rego"},{"value":"testmask.rego"}]},"plans":{"plans":[{"name":"system/log/mask","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.system.log.mask","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]},{"name":"test/log/mask_invalid","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.log.mask_invalid","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.system.log.mask","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"MakeSetStmt","stmt":{"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":2,"row":14}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":2,"row":14}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":5},"b":{"type":"bool","value":false},"file":0,"col":2,"row":14}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":3},"set":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":18}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":0},"target":4,"file":0,"col":2,"row":19}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":4},"target":5,"file":0,"col":2,"row":19}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":5},"b":{"type":"bool","value":false},"file":0,"col":2,"row":19}},{"type":"MakeObjectStmt","stmt":{"target":6,"file":0,"col":1,"row":18}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":5},"value":{"type":"string_index","value":6},"object":6,"file":0,"col":1,"row":18}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":7},"value":{"type":"string_index","value":8},"object":6,"file":0,"col":1,"row":18}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":9},"value":{"type":"string_index","value":10},"object":6,"file":0,"col":1,"row":18}},{"type":"SetAddStmt","stmt":{"value":{"type":"local","value":6},"set":2,"file":0,"col":1,"row":18}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":13}}]}],"path":["g0","system","log","mask"]},{"name":"g0.data.test.log.mask_invalid","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"MakeSetStmt","stmt":{"target":2,"file":1,"col":1,"row":6}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":1,"col":1,"row":6}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":11},"set":2,"file":1,"col":1,"row":6}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":1,"col":1,"row":6}}]}],"path":["g0","test","log","mask_invalid"]}]}} \ No newline at end of file diff --git a/opa-services/src/test/resources/mask/testmask.rego b/opa-services/src/test/resources/mask/testmask.rego new file mode 100644 index 00000000..d31771a8 --- /dev/null +++ b/opa-services/src/test/resources/mask/testmask.rego @@ -0,0 +1,6 @@ +package test.log + +import rego.v1 + +# Path outside input/result/nd_builtin_cache: rejected while parsing the rule set. +mask_invalid contains "/labels/environment"