集計で独裁者以外がほとんど検索条件を指定できないバグ (#444) (#452)

Reviewed-on: #452
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #452 でマージされました。
このコミットが含まれているのは:
2026-11-09 00:03:26 +09:00
committed by みてるぞ
コミット f05d8a4b3a
10個のファイルの変更、528行の追加、57行の削除
+1 -1
ファイルの表示
@@ -4,7 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user
class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'],
+56 -13
ファイルの表示
@@ -1,12 +1,41 @@
class CountsAxes
MAX_AXES = 8
MASKED_TEXT = '********'.freeze
MASKED_VALUE = '__counts_private__'.freeze
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
'created_at'].freeze
class TooManyAxes < ArgumentError; end
class PrivateCondition < ArgumentError; end
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.private_field?(field)
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
end
def self.visible_ids_sql(definition)
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
HistoryAccess.sql(definition.model, 'counts_visible')
end
def self.visibility_sql(definition, field)
return 'TRUE' if HistoryAccess.admin?
source = visible_ids_sql(definition)
visible = "v.id IN (#{ source })"
return visible unless field == 'changed_field'
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
end
def self.masked_sql(expression, definition, field)
return expression if HistoryAccess.admin? || !private_field?(field)
visible = visibility_sql(definition, field)
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
end
def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain)
@@ -60,13 +89,9 @@ class CountsAxes
@definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
visible = HistoryAccess.sql(@definition.model, 'v')
if field == 'changed_field'
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
if !HistoryAccess.admin? && self.class.private_field?(field)
expression = self.class.masked_sql(
"CAST((#{ expression }) AS CHAR)", @definition, field)
end
expression
end
@@ -77,10 +102,12 @@ class CountsAxes
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
identities = ['v.id']
if rows.any? { _1['field'] == 'tags' }
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))"
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql(
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
end
end
members = identities.each_index.map { "counts_member_#{ _1 }" }
selected += identities.each_with_index.map { |expression, index|
@@ -98,8 +125,17 @@ class CountsAxes
def joins(fields = all.map { _1['field'] })
result = ''
if fields.include?('tags')
document = 'CAST(v.tags_json AS JSON)'
unless HistoryAccess.admin?
visible = self.class.visible_ids_sql(@definition)
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
'ON counts_tags_visible.id = v.id '
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
"THEN #{ document } ELSE JSON_ARRAY(" +
"JSON_OBJECT('tag_id', 0)) END"
end
result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
@@ -112,6 +148,13 @@ class CountsAxes
condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ')
unless HistoryAccess.admin?
visible = self.class.visibility_sql(@definition, 'changed_field')
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
fields += " UNION ALL SELECT #{ marker } AS field_key"
condition = "((#{ visible }) AND (#{ condition })) OR " +
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
end
result += " JOIN (#{ fields }) changed ON (#{ condition })"
end
result
+41 -2
ファイルの表示
@@ -82,14 +82,32 @@ class CountsCondition
def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash)
if entry['operator'] == 'axis_eq'
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
value = entry['value']
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
[true, false].include?(value))
raise ArgumentError
end
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
(value == CountsAxes::MASKED_VALUE ||
(value == CountsAxes::MASKED_TEXT &&
(@definition.fields[entry['field']] != :string ||
['tags', 'changed_field'].include?(entry['field'])))))
field = entry.fetch('field')
raise ArgumentError unless @definition.axes(@population.history).include?(field)
if entry.key?('grain')
unless (@definition.fields[field] == :datetime &&
CountsAxes::GRAINS.include?(entry['grain']))
raise ArgumentError
end
end
return scope.none
end
scope = @population.condition_scope(scope, entry.fetch('field'))
return @population.axis_condition(scope, entry)
end
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
@@ -111,6 +129,13 @@ class CountsCondition
source, expression = source_for(field[:key])
type = field[:type]
# Preserve typed input compatibility; display markers are never real typed values.
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
return scope.none unless entry['operator'] == 'ne'
return @population.condition_scope(scope, field[:key])
end
blank = blank_sql(expression, type)
if entry['blank']
sql = if source && field[:collection]
@@ -119,6 +144,7 @@ class CountsCondition
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank
end
scope = @population.condition_scope(scope, field[:key])
return scope.where(Arel.sql(sql))
end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
@@ -131,6 +157,7 @@ class CountsCondition
validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator']
if type == :interval
scope = @population.condition_scope(scope, field[:key])
return interval(scope, source, expression, parsed)
end
clauses = []
@@ -158,6 +185,7 @@ class CountsCondition
elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })"
end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *binds)
end
@@ -168,6 +196,13 @@ class CountsCondition
values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
values.include?(CountsAxes::MASKED_TEXT) &&
(field[:type] != :string || field[:set_type] == 'tag_reference'))
return scope.none if entry['operator'] == 'all'
values = values - [CountsAxes::MASKED_TEXT]
end
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value|
@@ -191,7 +226,10 @@ class CountsCondition
operator = entry['operator']
if parsed.empty?
return ['in', 'any'].include?(operator) ? scope.none : scope
return scope.none if ['in', 'any'].include?(operator)
return scope if entry['values'].empty?
return @population.condition_scope(scope, field[:key])
end
source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference'
@@ -212,6 +250,7 @@ class CountsCondition
predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *parsed)
end
+8
ファイルの表示
@@ -70,6 +70,13 @@ class CountsPopulation
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end
# Each predicate keeps its own visibility restriction, including OR branches.
def condition_scope(scope, field)
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
end
def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
@@ -209,6 +216,7 @@ class CountsPopulation
def apply_tags
raise ArgumentError unless definition.resource == 'posts'
@scope = condition_scope(@scope, 'tags')
if params['tag'].present?
@scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
+1 -1
ファイルの表示
@@ -253,7 +253,7 @@ class CountsTableExport
end
def summary_value(value, index, page)
return '********' if value == CountsAxes::MASKED_VALUE
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
+2 -10
ファイルの表示
@@ -220,19 +220,11 @@ class CountsTableQuery
private
# Public timeline predicates may include hidden records. Private predicates must
# never become a count oracle, even when the selected axes themselves are public.
# Private predicates require visibility independently of their operator.
# Ordering/measure state must not select a server-only masked coordinate.
def validate_summary_conditions!
return if mode != 'summary' || HistoryAccess.admin?
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key|
# Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match.