Skip to content

[OPIK-7834] [BE] feat: mask trace content by field name at read time - #8043

Draft
thiagohora wants to merge 2 commits into
mainfrom
thiaghora/OPIK-7834-field-masking-redaction
Draft

[OPIK-7834] [BE] feat: mask trace content by field name at read time#8043
thiagohora wants to merge 2 commits into
mainfrom
thiaghora/OPIK-7834-field-masking-redaction

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

Second iteration of read-time redaction, replacing the regex mechanism that shipped in #7973. Masking moves out of the Jackson serializer and into the DAO row mappers, keyed on configured leaf field names rather than patterns, and carried on the reactive context the way workspaceId and visibility already are.

Net 853 insertions, 2030 deletions — the mechanism swap removes far more than it adds, and it resolves several review threads from #7973 by construction rather than by argument:

  • No regex, so no ReDoS and nothing to police. MatchBudget and its 199 lines of tests are gone, along with the per-character allowance, the ceiling and the output-growth bound.
  • No exemption list, so no name-collision hazard in either direction. The structural fields the API addresses by (thread_id, project_name, model, id) are separate columns and were never candidates, so there is nothing to exempt and nothing to keep in sync. The Experiment.promptVersions[].commit paged-vs-streamed divergence disappears with it.
  • Configuration is a real YAML list (redaction.maskFields) instead of a JSON array smuggled through a scalar.
  • The dataset CSV export gap closes — it merged as a TODO in [OPIK-7834] [BE] feat: redact trace content on read for callers without the original-data permission #7973 and is now refused alongside attachment download.
  • Free-form SQL is masked rather than refused. The caller chooses the projection, so a name or a pattern can be walked past (map('x', input), base64(input)); masking every string in the result cannot be, and it leaves counts and grouping over non-content dimensions working.

Carried forward from #7973 unchanged: permissions resolved from the platform's workspace permissions API per credential type, the original_data_view rename, the opt-in permission model, and the session-cookie coverage. The Streamer no longer redacts, so the ProvisionException narrowing and cause-chain handling it needed are gone with it.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves OPIK-7834

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Opus 5
  • Scope: the port onto merged main (mechanism swap, runner-job masking, config and test reconciliation), plus the commit messages and this description.
  • Human verification: design decisions — the masking mechanism, the opt-in permission model, the permission rename — were directed and approved by the author in review. Test runs below were executed locally and their results read; the platform-side counterpart (comet-ml/comet-backend#5650) is not compile-verified locally, see Testing.

Testing

mvn test in apps/opik-backend, local process mode, macOS, Testcontainers for MySQL/ClickHouse/Redis/Zookeeper:

  • TracesResourceTest392 passed. The important one: every trace and span row mapper signature changed, so this is the regression surface for normal, unmasked reads.
  • ReadTimeRedactionResourceTest — 12 passed. Covers masked vs stored per credential type (api key and session cookie), spans, the streamed/paged parity, the refusal paths, and the two long-poll runner-job cases.
  • ReadTimeRedactionDisabledResourceTest — 2 passed. The feature off, end to end.
  • RedactionConfigTest, FieldMaskerTest, RedactionGuardTest, RedactionServiceTest, RequestContextConstructionArchTest, RemoteAuthServiceTest (58), Local/RemoteWorkspacePermissionsServiceTest95 passed together.
  • DatasetsResourceIntegrationTest — 2 passed.

Coverage boundary, worth reviewing deliberately. Masking is applied where a content column becomes a JsonNode, which means it is applied where it is wired rather than everywhere by construction — the opposite of the serializer approach. Currently wired: trace, span, thread, dataset-item, dataset-item-version and experiment-item DAOs, analytics queries, and runner jobs. Two consequences:

  1. Runner jobs needed wiring explicitly and are included here (second commit). They are read from Redis, not through a DAO, so the DAO move had left their inputs and result returning stored content — a regression against [OPIK-7834] [BE] feat: redact trace content on read for callers without the original-data permission #7973, which covered them by construction. Masked in the resource, with the masker captured before nextJob subscribes so the reactor thread that resumes the long poll never touches the request scope.
  2. Prompt content is not masked, deliberately, per review — prompts are not expected to carry the content this feature withholds.

A new endpoint or DAO is therefore unmasked by default. That is the trade for losing the exemption list, and it wants a convention or an ArchUnit rule if this stops being a PoC.

Not run: the full backend suite, and the frontend. Scope was the redaction mechanism, the auth/permissions path it reads from, and the DAOs it touches.

Two fixes to inconsistencies in the pre-port branch, found while reconciling: its RedactionGuard javadoc still listed free-form SQL as refused after its own final commit changed that to masking, and the analytics resource ended up carrying both the refusal and the masking.

Documentation

config.yml documents maskFields and replacement in full: why names rather than paths (the same field sits at different depths depending on the integration — messages[].content, messages[].kwargs.message.content, function.arguments), that the shipped seven names are a starting point and not a guarantee, that keys are never rewritten and therefore sensitive data must live in values, and that switching this on changes what a public project shows. config-test.yml carries the keys so the two cannot drift apart, including a comment on why the placeholder list entry exists (Dropwizard's indexed overrides only replace an index that already exists).

Depends on

The platform counterpart, comet-ml/comet-backend#5650, must deploy first — it defines original_data_view and serves it from the workspace permissions API. With redaction.enabled=false there is no permission traffic at all, so an older platform runs against this build unchanged. That PR still has the rename open for discussion, which should settle before either side merges.

Replaces the read-time regex redaction that shipped in #7973. Masking moves from the response
serializer into the DAO row mappers, keyed on configured leaf field names rather than patterns,
and carried on the reactive context the way workspaceId and visibility already are.

What this removes: the Jackson module and its exemption list, the writer interceptor, the
streamed-tree walk, the regex rule set, and MatchBudget with it. No regex means no ReDoS and no
work bound to police, and no exemption list means no name-collision hazard in either direction -
the structural fields the API addresses by (thread_id, project_name, model, id) are separate
columns and were never candidates. Configuration becomes a YAML list of field names instead of
JSON-in-a-scalar.

What carries forward from the merged branch: permissions resolved from the workspace permissions
API per credential type, the original_data_view rename, the opt-in permission model, the
session-cookie coverage, and the config-test.yml keys. The Streamer no longer redacts, so the
ProvisionException and cause-chain handling it needed is gone with it.

Free-form SQL is now masked by shape rather than refused, since the caller chooses the
projection; the dataset CSV export and attachment download are still refused, and the guard's
javadoc is corrected to say so.
Runner jobs are read from Redis rather than through the masked DAOs, so moving masking into the
DAOs left them returning stored content - a coverage regression against what the serializer
approach covered by construction. Their inputs are the payload an agent was invoked with and
their result is what it produced, which is the same caller content a trace carries.

Masked in the resource, which is where the decision is readable: all three read paths already
take workspaceId and userName from the request context, and nextJob captures the masker before
subscribing so the reactor thread that resumes the long poll never touches the request scope.

The two long-poll tests from the merged branch come back with it, restated for field masking -
the configured field is replaced, the unnamed one survives, and a permitted caller still reads
stored content.
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. labels Aug 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 5.25s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 4.04s
⚓ helm-docs Regenerate Helm chart README 3.34s
Total (3 ran) 12.63s
⏭️ 41 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

@thiagohora

Copy link
Copy Markdown
Contributor Author

Why the matching is hand-written and not Jayway JsonPath

Recording this up front, because it was raised on #7973 and this PR is where the answer now lives. Short version: JsonPath is available and the instinct behind the suggestion was right, but it does not fit this specific job, for reasons already recorded in this codebase.

json-path 3.0.0 is a declared dependency and already used in OnlineScoringEngine, FilterEvaluationServiceBase and a test util — so there is no dependency-weight argument.

1. It needs the one construct this codebase bans. The mechanism is "mask the value of every leaf whose configured field name matches, at any depth", i.e. $..content. VariablePathUtils.findUnsupportedConstructInJsonPath rejects RECURSIVE_DESCENT = "..", and the comment at its call site gives the reason: "Recursive descent and filter predicates walk the whole section, and scoring shares a scheduler across workspaces, so the cost of one rule's expression is not confined to that rule." Masking runs on every content column of every row of every read — a hotter path than online scoring, which is where that guard was thought necessary.

2. It does not operate on the type we hold. OnlineScoringEngine:901: "JsonPath didn't work with JsonNode, even explicitly using JacksonJsonProvider, so we convert to a plain Object." FieldMasker rewrites a JsonNode in place; going through JsonPath means materialising each content column to Map/List and back, per row.

In fairness, that note names JacksonJsonProvider (the POJO provider) rather than JacksonJsonNodeJsonProvider, so it is possible the JsonNode-native provider would have worked and the wrong one was reached for. I have not tested it, and I would not assert "JsonPath cannot touch JsonNode" as fact. Points 1 and 3 hold either way.

3. Two of the three operations have no JsonPath expression.

Operation Used for JsonPath equivalent
mask — configured name at any depth trace/span/thread/dataset/experiment content $..name, i.e. the banned construct
maskEveryString — mask by shape Agent Insights, where the caller chooses projection and key names none; the nearest is a regex filter, which is what this PR removes
"a configured name masks everything beneath it" nested objects and arrays under a matched name none; needs its own traversal

So the tree walk would not disappear — we would maintain it and a path grammar we then have to restrict.

What the suggestion did get right is the level above the tool: declare what to mask rather than enumerate what to exempt. This PR makes exactly that change, and it is what removes the regex, MatchBudget, the exemption list, and the paged-versus-streamed divergence in one go. What remains is a 120-line walk with no configuration surface of its own.

Happy to be pushed back on — particularly on the provider caveat, which is testable if someone thinks it is worth the measurement.

Comment on lines +299 to +303
// Same path, same nonexistent job, caller who may see originals: anything but 403 proves the 403 above
// came from the masking decision rather than from authentication or the path itself.
try (var response = downloadExport(ADMIN_API_KEY)) {
assertThat(response.getStatus()).isNotEqualTo(HttpStatus.SC_FORBIDDEN);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Admin export regression remains undetected

The admin assertion only checks for a non-403 response on a nonexistent job ID, so unrelated 401/404/500 failures pass and the permitted download path remains untested — should we generate a real export, assert its successful status and content, and retain the member 403 assertion?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/ReadTimeRedactionResourceTest.java
around lines 299-303, update `theRefusalIsTheMaskingDecision` so it exercises a real
generated export rather than a random nonexistent job. Assert the permitted admin
response’s expected success status and returned export content, not merely that it is
different from 403; retain the member test’s explicit 403 assertion.

Comment on lines +214 to +218
// deepCopy because the job may be served from a cached deserialisation, and mask rewrites in place.
return job.toBuilder()
.inputs(job.inputs() == null ? null : masker.mask(job.inputs().deepCopy()))
.result(job.result() == null ? null : masker.mask(job.result().deepCopy()))
.build();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Masked callers receive raw runner content

LocalRunnersResource masks only inputs and result, so runner-supplied error and persisted LocalRunnerLogEntry.text returned by getJobLogs reach callers lacking original_data_view unchanged after response-wide redaction was removed — should we apply one explicit masking boundary to all returned runner job content?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/LocalRunnersResource.java
around lines 209-218, update the `masked` helper so caller redaction also masks the
`LocalRunnerJob.error` field instead of copying it unchanged. In the `getJobLogs` logic
around the reported lines 209-218, apply the same caller-specific masker to every
`LocalRunnerLogEntry.text` before returning logs, while preserving each entry’s
metadata and the existing no-op behavior. Ensure `listJobs`, `getJob`, and `getJobLogs`
all pass through this explicit masking boundary.

Comment on lines +114 to +116
return new AnalyticsQueryResponse(response.results().stream()
.map(result -> masker.maskEveryString(result.deepCopy()))
.toList());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doubles peak memory for large queries

result.deepCopy() creates a second JSON tree before maskEveryString traverses it, so large SQL responses briefly consume roughly twice the tree memory and incur a full extra traversal — should we mask in place when the response is unshared or during construction instead?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/internal/AnalyticsQueriesResource.java`
around lines 114-116, refactor `maskIfRequired` so each analytics result is not fully
duplicated with `deepCopy()` before `maskEveryString` traverses it. Determine whether
the service-owned response can be safely mutated and mask the existing nodes in place;
otherwise apply masking while constructing the results or use a streaming/structural
approach that avoids retaining both complete JSON trees. Preserve the current redaction
behavior and response immutability guarantees where required.

Comment on lines +1010 to +1012
// The file was generated by a background job with no caller, so it holds stored content whatever this
// caller may see. One artifact, many readers: it can only be withheld, not masked.
RedactionGuard.rejectUnmaskable(requestContext.get().isRedactResponse(), "Dataset CSV export download");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Undocumented 403 breaks export clients

downloadDatasetExport now returns 403 when redaction is enabled and the caller lacks original_data_view, but @Operation advertises only 200, 400, and 404, so released clients classify this expected refusal as generic ApiError/OpikApiError and the frontend provides no permission-specific handling. Should we document the 403 and original_data_view requirement in the source operation and regenerate the API docs, leaving client/UI handling to the appropriate follow-up if generated artifacts are intentionally deferred?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/DatasetsResource.java`
around lines 1010-1012, update `downloadDatasetExport` and its OpenAPI annotations to
document that redaction-enabled callers must have the `original_data_view` permission
and otherwise receive a 403 response. Add the 403 response to the operation metadata and
clearly describe the prerequisite, then regenerate and publish the API documentation
through the normal workflow; if generated client/UI artifacts are intentionally
deferred, record the corresponding follow-up handling.

Comment on lines +167 to +169
public static DatasetItem buildItemFromRow(Row row, RowMetadata rowMetadata, FieldMasker masker) {

Map<String, JsonNode> data = getData(row);
Map<String, JsonNode> data = getData(row, masker);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dataset comparison leaks nested experiment content

buildItemFromRow applies the request FieldMasker only to top-level data, while getExperimentItems(...) parses experiment_items_array directly into ExperimentItem.input, output, and traceMetadata; those fields then reach ExperimentItem.View.Compare on /items/experiments/items, so callers without original_data_view receive unmasked nested content. Should we pass the masker through nested reconstruction and apply it to those JSON nodes before building each ExperimentItem?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/domain/DatasetItemResultMapper.java
around lines 167-169, update `buildItemFromRow` so the request `FieldMasker` is also
passed into nested `experimentItems` reconstruction. Refactor `getExperimentItems` and
its callers to apply the masker to each `ExperimentItem`’s `input`, `output`, and
`traceMetadata` JSON nodes before constructing the objects, while preserving the
existing unmasked behavior when appropriate. Add or update tests to verify that requests
without `original_data_view` cannot receive nested experiment content.

* The last is chosen because it is free and cannot be defeated by the input. If partial masking is ever required,
* write-time detection is the route — not a return to matching on read.
*/
public record FieldMasker(@NonNull Set<String> maskedFields, @NonNull String replacement) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redaction policy can drift at runtime

FieldMasker retains the caller-supplied mutable maskedFields reference, so later mutations can change a singleton's masking rules at runtime even though RedactionConfig.compile() currently uses Set.copyOf(...) — should we add a canonical constructor that snapshots maskedFields with Set.copyOf(...) and test that source-set mutations do not affect the record?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redaction/FieldMasker.java
around line 40, update the `FieldMasker` record’s canonical construction so
`maskedFields` is defensively copied with `Set.copyOf(...)`, preventing caller mutations
from changing redaction behavior after construction. Add a focused test in the
corresponding FieldMasker test file that mutates the original set after creating the
record and verifies the record’s masking rules remain unchanged.

Comment on lines +152 to +153
// Numbers, booleans and nulls carry no free text and are left as stored.
return node;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sensitive numeric values remain exposed

maskWholeValue leaves numeric and boolean scalar nodes unchanged, so configured sensitive fields remain exposed when redactResponse is true — should we replace matched scalars regardless of JSON type while preserving object/array structure?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redaction/FieldMasker.java
around lines 152-153, fix the maskWholeValue logic so values under configured sensitive
fields cannot expose numeric, boolean, or other scalar content. Refactor the masking
helpers so matched fields replace every scalar with the replacement while preserving
object and array structure, and keep maskEveryString’s documented behavior separate if
it should continue leaving non-string values unchanged. Add or update tests covering
numeric and boolean sensitive fields.

Comment on lines 62 to 64
RequestContext current = requestContext.get();
boolean redact = redactionService.shouldRedactFor(current.getPermissions());

current.setRedactResponse(redact);
context.setProperty(REDACT_RESPONSE_PROPERTY, redact);
current.setRedactResponse(redactionService.shouldRedactFor(current.getPermissions()));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dataset expansion leaks configured content fields

RedactionRequestFilter records the decision only on RequestContext, while DatasetExpansionService.buildDatasetItem copies parsed LLM fields into DatasetItem.data unchanged, so DatasetsResource.expandDataset returns generated_samples[].data.prompt (or content) verbatim to callers without original_data_view now that the global RedactionModule/RedactionWriterInterceptor is gone — should we mask generated samples before building the response or add an equivalent explicit response-path mask?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redaction/RedactionRequestFilter.java
around lines 62-64, the `filter` method records the redaction decision only on
`RequestContext`, but the dataset expansion response does not consume it. Trace
`DatasetsResource.expandDataset` and `DatasetExpansionService.buildDatasetItem`, then
apply the same configured-field redaction to each generated sample’s `data` before
constructing the response, while honoring `original_data_view`. Add or reuse an explicit
response-path masking mechanism so callers without permission cannot receive
`generated_samples[].data.prompt` or `content` verbatim.`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend baz: pending java Pull requests that update Java code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant