ActiveSupport::ParameterFilter: root keys and deep filter specifications

Hey all! :wave:

The Problem

I love the flexibility of the ActiveSupport::ParameterFilter. It’s useful to be able to specify filters as regexes or strings. I also appreciate the ability to target specific hash subkeys using dot notation.

However, I’m finding it difficult to target only top-level keys. For example:

{
  data: "filter me"
  nested: {
    data: "keep me"
  }
}

I can initialize ActiveSupport::ParameterFilter with:

  • 'data', /data/, or even /^data$/ to redact both “keep me” and “filter me”, or
  • 'nested.data' to redact only “keep me”,

but in order to only redact “filter me,” I need to circumvent the “deep filter” inference by pretending I’m using dot notation:

/^data\.{0}$/

The \.{0} in the above regex is designed to match the “includes \.” rule in the code linked above, and does nothing else. This feels hacky.

Possible Solutions?

I would love suggestions for how the interface might be changed or augmented to accommodate slightly more general subkey matching that is both backwards-compatible and solves the problem described above. Here’s what I’ve vaguely imagined so far:

A) Add an explicit deep_filters: keyword argument to the ActiveSupport::ParameterFilter initializer.

This would allow callers to specify the initial deep filters directly, thereby bypassing inference during compilation for those filters. Callers still passing dot-notation filters through the filters positional argument could continue doing so; we’d tack on any inferred deep filters to the explicit ones. (We’d still compile the supplied deep filter strings and regexes.)

Example usage:

params = {
  data: "filter me"
  nested: {
    data: "keep me"
  }
}
ActiveSupport::ParameterFilters.new(deep_filters: ['data']).filter(params)
# => {data: "[FILTERED]", nested: {data: "keep me"}}

B) Prepend a root symbol to the path string.

Currently, as we traverse the parameter tree, we maintain a path segment stack, then join each segment with . to match the full path against deep filters, like so:

{
  data: "filter me" # 'data'
  nested: { # 'nested'
    data: "keep me" # 'nested.data'
  }
}

What if we prepended a symbol to indicate the root, à la JSONPath? Using $, for example:

{
  data: "filter me" # '$.data'
  nested: { # '$.nested'
    data: "keep me" # '$.nested.data'
  }
}

Then, to redact:

  • only “filter me”, pass '$.data'
  • only “keep me”, pass '$.nested.data'
  • both “keep me” and “filter me,” pass data, /data/, or similar, as before

This approach seems a bit more complicated, as we’d have to tweak deep string filter compilation (probably by prepending $. at some point), and we’d have to think hard about how this would affect existing matching behavior against keys with dots or dollar signs, e.g.:

{
  "$$": "dollars" # '$.$$'
  "$.$": "dollars" # '$.$.$'
  ".nested.": { # '$..nested.'
    "$$": "dollars" # '$..nested.$$'
  }
}

So…

Any thoughts?