Module: Woods::Console::Redactor
- Defined in:
- lib/woods/console/redactor.rb
Overview
Shape-aware Layer 3 (column + EAV) redaction for console tool responses.
Extracted from Server so the response-redaction logic can live next to the ResponseContext that invokes it and be unit-tested without the full server construction path.
Redaction is shape-aware:
- {record: Hash} (find)
- {records: [Hash]} (sample, recent)
- {columns: [...], rows: [[...]]} (sql, query)
- {columns: [...], values: [...|[...]]} (pluck)
- Plain Hash (redact top-level keys)
- Array<Hash> (redact each hash)
Constant Summary collapse
- DATA_ENVELOPE_KEYS =
Data-shape keys used by console tool responses. When any of these keys appear at the top of a Hash result we treat the value as row data and descend into it instead of redacting at the envelope level.
Full recursive descent is intentionally NOT used here. Some tools return Hashes whose keys happen to be column names but whose values are metadata objects, not row data — e.g. ‘console_schema` returns {col_name => {type:…, null:…}}. Recursing into that Hash would incorrectly replace schema metadata with “[REDACTED]” whenever a column name matches a redacted_columns entry. Keeping a closed list of envelope keys that carry actual row data is therefore the safer choice.
When adding a new Tier 2/3 tool that returns row data under a new envelope key, add that key here AND add a matching ‘when` branch in `redact_envelope_value` that applies the appropriate redaction strategy.
%w[record records rows values associations].freeze
Class Method Summary collapse
-
.apply(result, ctx) ⇒ Object
Apply SafeContext column redaction to a result value.
- .apply_mask(row, mask) ⇒ Object
-
.positional_kv_rules(columns, ctx) ⇒ Object
Resolve EAV patterns against a ‘columns` header into concrete index pairs.
-
.positional_mask(columns, ctx) ⇒ Object
Precompute the positional redaction mask from a ‘columns` header.
-
.positional_plan(columns, ctx) ⇒ Object
Precompute everything needed to redact positional rows for a given ‘columns` header: the column-name mask plus any EAV key-value rules resolved to column indexes.
-
.redact_association_map(value, ctx) ⇒ Object
Redact an associations map returned by console_data_snapshot.
- .redact_envelope_value(key, value, plan, ctx) ⇒ Object
- .redact_hash(hash, ctx) ⇒ Object
- .redact_hash_array(value, ctx) ⇒ Object
-
.redact_positional(rows, plan) ⇒ Object
Redact positional row data using a precomputed plan.
- .redact_row(row, plan) ⇒ Object
- .redact_scalar(value, mask) ⇒ Object
Class Method Details
.apply(result, ctx) ⇒ Object
Apply SafeContext column redaction to a result value.
43 44 45 46 47 48 49 50 51 52 |
# File 'lib/woods/console/redactor.rb', line 43 def apply(result, ctx) case result when Array result.map { |item| item.is_a?(Hash) ? apply(item, ctx) : item } when Hash redact_hash(result, ctx) else result end end |
.apply_mask(row, mask) ⇒ Object
148 149 150 151 152 |
# File 'lib/woods/console/redactor.rb', line 148 def apply_mask(row, mask) return row.dup unless mask row.each_with_index.map { |value, idx| mask[idx] ? '[REDACTED]' : value } end |
.positional_kv_rules(columns, ctx) ⇒ Object
Resolve EAV patterns against a ‘columns` header into concrete index pairs. A rule only fires when both key_column and value_column are present in the header, and costs nothing per row otherwise.
115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/woods/console/redactor.rb', line 115 def positional_kv_rules(columns, ctx) return [] unless columns.is_a?(Array) index = columns.each_with_index.to_h { |name, idx| [name.to_s, idx] } ctx.redacted_key_values.filter_map do |pattern| key_idx = index[pattern['key_column']] val_idx = index[pattern['value_column']] next unless key_idx && val_idx { key_idx: key_idx, val_idx: val_idx, sensitive: pattern['sensitive_keys'] } end end |
.positional_mask(columns, ctx) ⇒ Object
Precompute the positional redaction mask from a ‘columns` header. Returns nil when there is nothing to redact so callers can short-circuit.
102 103 104 105 106 107 108 109 110 |
# File 'lib/woods/console/redactor.rb', line 102 def positional_mask(columns, ctx) return nil unless columns.is_a?(Array) redacted = ctx.redacted_columns return nil if redacted.empty? mask = columns.map { |name| redacted.include?(name.to_s) } mask.any? ? mask : nil end |
.positional_plan(columns, ctx) ⇒ Object
Precompute everything needed to redact positional rows for a given ‘columns` header: the column-name mask plus any EAV key-value rules resolved to column indexes.
95 96 97 98 |
# File 'lib/woods/console/redactor.rb', line 95 def positional_plan(columns, ctx) { mask: positional_mask(columns, ctx), kv_rules: positional_kv_rules(columns, ctx) } end |
.redact_association_map(value, ctx) ⇒ Object
Redact an associations map returned by console_data_snapshot.
The associations payload has the shape:
{ "assoc_name" => [Hash, ...], ... }
Each value is an Array of record Hashes. We redact each record the same way we handle ‘records` (column-name + EAV rules).
84 85 86 87 88 89 90 |
# File 'lib/woods/console/redactor.rb', line 84 def redact_association_map(value, ctx) return value unless value.is_a?(Hash) value.each_with_object({}) do |(assoc_name, assoc_records), out| out[assoc_name] = redact_hash_array(assoc_records, ctx) end end |
.redact_envelope_value(key, value, plan, ctx) ⇒ Object
64 65 66 67 68 69 70 71 72 |
# File 'lib/woods/console/redactor.rb', line 64 def redact_envelope_value(key, value, plan, ctx) case key when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value when 'records' then redact_hash_array(value, ctx) when 'rows', 'values' then redact_positional(value, plan) when 'associations' then redact_association_map(value, ctx) else value end end |
.redact_hash(hash, ctx) ⇒ Object
54 55 56 57 58 59 60 61 62 |
# File 'lib/woods/console/redactor.rb', line 54 def redact_hash(hash, ctx) string_keyed = hash.transform_keys(&:to_s) return ctx.redact(string_keyed) unless (string_keyed.keys & DATA_ENVELOPE_KEYS).any? plan = positional_plan(string_keyed['columns'], ctx) string_keyed.each_with_object({}) do |(key, value), out| out[key] = redact_envelope_value(key, value, plan, ctx) end end |
.redact_hash_array(value, ctx) ⇒ Object
74 75 76 |
# File 'lib/woods/console/redactor.rb', line 74 def redact_hash_array(value, ctx) Array(value).map { |row| row.is_a?(Hash) ? ctx.redact(row) : row } end |
.redact_positional(rows, plan) ⇒ Object
Redact positional row data using a precomputed plan. Handles both nested arrays (multi-column pluck, sql/query rows) and flat scalar arrays (pluck with a single column — Rails collapses the result).
131 132 133 134 135 136 137 138 |
# File 'lib/woods/console/redactor.rb', line 131 def redact_positional(rows, plan) return rows unless rows.is_a?(Array) return rows if plan[:mask].nil? && plan[:kv_rules].empty? rows.map do |row| row.is_a?(Array) ? redact_row(row, plan) : redact_scalar(row, plan[:mask]) end end |
.redact_row(row, plan) ⇒ Object
140 141 142 143 144 145 146 |
# File 'lib/woods/console/redactor.rb', line 140 def redact_row(row, plan) result = apply_mask(row, plan[:mask]) plan[:kv_rules].each do |rule| result[rule[:val_idx]] = '[REDACTED]' if rule[:sensitive].include?(row[rule[:key_idx]].to_s) end result end |
.redact_scalar(value, mask) ⇒ Object
154 155 156 157 158 |
# File 'lib/woods/console/redactor.rb', line 154 def redact_scalar(value, mask) return value unless mask mask.first ? '[REDACTED]' : value end |