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

Class Method Details

.apply(result, ctx) ⇒ Object

Apply SafeContext column redaction to a result value.

Parameters:

  • result (Object)

    The result from the bridge or embedded executor

  • ctx (SafeContext)

    The context with redacted_columns configured

Returns:

  • (Object)

    Redacted result, same shape as input



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