From b9d7c4fef5de287ef4950285bb2f48aaac129627 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 8 Nov 2026 01:56:45 +0900 Subject: [PATCH] #444 --- backend/app/models/history_access.rb | 2 +- backend/app/services/counts_axes.rb | 66 ++++-- backend/app/services/counts_condition.rb | 43 +++- backend/app/services/counts_population.rb | 109 +--------- backend/app/services/counts_table_export.rb | 2 +- backend/app/services/counts_table_query.rb | 5 +- .../requests/counts_masked_search_spec.rb | 196 ++++++++++++++++++ .../services/counts_pivot_security_spec.rb | 189 +++++++++++++++-- 8 files changed, 472 insertions(+), 140 deletions(-) create mode 100644 backend/spec/requests/counts_masked_search_spec.rb diff --git a/backend/app/models/history_access.rb b/backend/app/models/history_access.rb index cb3de89..96203da 100644 --- a/backend/app/models/history_access.rb +++ b/backend/app/models/history_access.rb @@ -4,7 +4,7 @@ require 'digest' class HistoryAccess < ActiveSupport::CurrentAttributes attribute :user class Forbidden < StandardError; end - ARTIFACT_POLICY = 'counts-masked-summary-2'.freeze + ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'], 'nico_tag_versions' => ['tag_id'], diff --git a/backend/app/services/counts_axes.rb b/backend/app/services/counts_axes.rb index 4267e31..29026aa 100644 --- a/backend/app/services/counts_axes.rb +++ b/backend/app/services/counts_axes.rb @@ -1,5 +1,6 @@ class CountsAxes MAX_AXES = 8 + MASKED_TEXT = '********'.freeze MASKED_VALUE = '__counts_private__'.freeze PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user', 'created_at'].freeze @@ -8,6 +9,33 @@ class CountsAxes 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) @@ -61,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 @@ -78,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| @@ -99,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 @@ -113,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 diff --git a/backend/app/services/counts_condition.rb b/backend/app/services/counts_condition.rb index 1104c0f..530c9e4 100644 --- a/backend/app/services/counts_condition.rb +++ b/backend/app/services/counts_condition.rb @@ -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 diff --git a/backend/app/services/counts_population.rb b/backend/app/services/counts_population.rb index e5dfda2..73260e9 100644 --- a/backend/app/services/counts_population.rb +++ b/backend/app/services/counts_population.rb @@ -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 @@ -192,88 +199,7 @@ class CountsPopulation def apply_condition(entry) @condition ||= CountsCondition.new(self) - unless summary_private_condition?(entry) - @scope = @condition.apply(@scope, entry) - return - end - - return if masked_noop?(entry) - - # Evaluate private predicates only on authorised versions. On other versions, - # the entire field is the same opaque marker, regardless of its stored value. - original = @scope - visible = private_visibility_sql(entry['field']) - authorised = authorised_private_condition(original.where(Arel.sql(visible)), entry) - @scope = if masked_match?(entry) - authorised.or(original.where(Arel.sql("NOT (#{ visible })"))) - else - authorised - end - end - - def authorised_private_condition(scope, entry) - marker = '********' - if entry['operator'] == 'axis_eq' - return scope.none if entry['value'] == marker && - definition.fields[entry['field']] != :string - - return @condition.apply(scope, entry) - end - - field = definition.filter_metadata.find { _1[:key] == entry['field'] } - return @condition.apply(scope, entry) unless field - return @condition.apply(scope, entry) if field[:type] == :string && - field[:set_type] != 'tag_reference' - - if entry['value'] == marker - return scope.none if ['eq', 'gt', 'ge', 'lt', 'le', 'range'].include?(entry['operator']) - return scope if entry['operator'] == 'ne' - end - if entry['values'].is_a?(Array) && entry['values'].include?(marker) - return scope.none if entry['operator'] == 'all' - - return @condition.apply(scope, entry.merge('values' => entry['values'] - [marker])) - end - @condition.apply(scope, entry) - end - - def summary_private_condition?(entry) - params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg && - !HistoryAccess.admin? && !CountsAxes::PUBLIC_FIELDS.include?(entry['field']) && - entry['field'] != 'user.id' - end - - def private_visibility_sql(field) - visible = HistoryAccess.sql(definition.model, 'v') - return visible unless field == 'changed_field' - - "(#{ visible }) AND (#{ HistoryAccess.sql(definition.model, 'previous') })" - end - - def masked_noop?(entry) - return false if entry['operator'] == 'axis_eq' || entry['blank'] - set = ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator']) - return !entry.key?('values') if set - - [entry['value'], entry['end']].all? { _1.nil? || _1 == '' } - end - - def masked_match?(entry) - marker = '********' - return false if entry['blank'] - - value = entry['value'] - case entry['operator'] - when 'axis_eq', 'eq' then value == marker - when 'ne' then value != marker - when 'like' - pattern = Regexp.escape(value.to_s).gsub('%', '.*').gsub('_', '.') - /\A#{ pattern }\z/i.match?(marker) - when 'in', 'any' then entry.fetch('values', []).include?(marker) - when 'not_in', 'none' then !entry.fetch('values', []).include?(marker) - when 'all' then entry.fetch('values', []).all? { _1 == marker } - else false - end + @scope = @condition.apply(@scope, entry) end def snapshot_membership(value) @@ -290,10 +216,7 @@ class CountsPopulation def apply_tags raise ArgumentError unless definition.resource == 'posts' - private = params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg && - !HistoryAccess.admin? - original = @scope if private - @scope = HistoryAccess.visible(@scope, 'v') if private + @scope = condition_scope(@scope, 'tags') if params['tag'].present? @scope = @scope.where(snapshot_membership( "#{ params.fetch('kind', 'internal') }:#{ params['tag'] }")) @@ -317,21 +240,9 @@ class CountsPopulation expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })" literal[:negative] ? "NOT #{ expression }" : expression end - if predicates.empty? - @scope = original if private && params['tag'].blank? - return - end + return if predicates.empty? any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' } @scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })")) - return unless private && params['tag'].blank? - - # A masked tag collection cannot match a real tag. Negative terms match - # the opaque marker independently of any hidden snapshot membership. - hidden = any ? literals.any? { _1[:negative] } : literals.all? { _1[:negative] } - if hidden - visible = HistoryAccess.sql(definition.model, 'v') - @scope = @scope.or(original.where(Arel.sql("NOT (#{ visible })"))) - end end end diff --git a/backend/app/services/counts_table_export.rb b/backend/app/services/counts_table_export.rb index eab1d50..fadd00a 100644 --- a/backend/app/services/counts_table_export.rb +++ b/backend/app/services/counts_table_export.rb @@ -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? diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index 9f96901..1fcc66a 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -220,9 +220,8 @@ class CountsTableQuery private - # Summary predicates are evaluated against public coordinates or the masked - # value by CountsPopulation. Never accept a private server-only coordinate in - # user-supplied ordering/measure state. + # 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? diff --git a/backend/spec/requests/counts_masked_search_spec.rb b/backend/spec/requests/counts_masked_search_spec.rb new file mode 100644 index 0000000..e3414be --- /dev/null +++ b/backend/spec/requests/counts_masked_search_spec.rb @@ -0,0 +1,196 @@ +require 'rails_helper' + +RSpec.describe 'Counts masked search', type: :request do + let!(:tag) { create(:tag, primary_name: '秘密のタグ') } + let(:actor) { create(:user, :member) } + let(:admin) { create(:user, :admin) } + let(:base) do + { 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary', + 'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z', + 'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' } + end + + before do + [1, 2].each do |number| + TagVersion.create!(tag:, version_no: number, + event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ', + category: 'general', aliases: '秘密の別名', parent_tag_ids: '', + names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false, + aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } }, + created_by_user: actor, created_at: Time.utc(2023, number)) + end + end + + def query(extra = {}) + get '/counts/query', params: base.merge(extra) + expect(response).to have_http_status(:ok), extra.inspect + response.parsed_body + end + + def predicate(field, operator, value, index = 0) + prefix = "conditions.#{ index }" + result = { 'conditions' => '', "#{ prefix }.field" => field, + "#{ prefix }.operator" => operator } + if ['in', 'any', 'all', 'none', 'not_in'].include?(operator) + result.merge!("#{ prefix }.values" => '1', 'condition_values' => '', + "condition_values.#{ index }.condition" => index.to_s, + "condition_values.#{ index }.value_type" => 'string', + "condition_values.#{ index }.value" => value) + else + result["#{ prefix }.value"] = value + end + result + end + + it 'rejects every name and alias predicate on private history' do + ['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field| + secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ' + multiple = !['name', 'name_ja'].include?(field) + operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in'] + ['eq', 'ne', 'like', *operators].each do |operator| + [secret, '********', '%', ''].each do |value| + expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0 + expect(query(predicate(field, operator, value))['count']).to eq(expected) + end + end + blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1') + expect(query(blank)['count']).to eq(0) + end + end + + it 'ignores missing and empty scalar values unless blank is checked' do + ['name', 'name_ja', 'names.name', 'aliases.name'].each do |field| + ['eq', 'ne', 'like'].each do |operator| + condition = predicate(field, operator, '') + [condition, condition.except('conditions.0.value')].each do |empty| + expect(query(empty)['count']).to eq(2) + expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2) + expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0) + end + end + end + end + + it 'keeps details totals unchanged for inactive private predicates' do + settings = predicate('name', 'ne', '').merge('mode' => 'details') + result = query(settings) + expect(result['count']).to eq(0) + expect(result['versions']).to be_empty + expect(result['output_count']).to eq(2) + active = query(settings.merge('conditions.0.blank' => '1')) + expect(active['output_count']).to eq(0) + end + + it 'ignores missing sets but preserves explicit empty set semantics' do + { 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do + |field, operators| + operators.each do |operator| + condition = { 'conditions' => '', 'conditions.0.field' => field, + 'conditions.0.operator' => operator } + expect(query(condition)['count']).to eq(2) + empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '') + expected = ['in', 'any'].include?(operator) ? 0 : 2 + expect(query(empty)['count']).to eq(expected) + expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0) + # An explicit empty string element is a real value, not an unspecified set. + expect(query(predicate(field, operator, ''))['count']).to eq(0) + end + end + end + + it 'ignores empty private predicates regardless of their position' do + first = predicate('name', 'ne', '') + .merge(predicate('resource_id', 'eq', tag.id.to_s, 1)) + last = predicate('resource_id', 'eq', tag.id.to_s) + .merge(predicate('aliases.name', 'like', '', 1)) + expect(query(first)['count']).to eq(2) + expect(query(last)['count']).to eq(2) + active = predicate('name', 'ne', 'unrelated') + .merge(predicate('aliases.name', 'like', '', 1)) + expect(query(active)['count']).to eq(0) + end + + it 'searches public literal markers and treats axis predicates as explicit' do + public_tag = create(:tag, primary_name: '********') + TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create', + name: '********', category: 'general', aliases: '', parent_tag_ids: '', + names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false, + aliases: [] } }, created_at: Time.utc(2023)) + expect(query['count']).to eq(3) + ['eq', 'ne', 'like', 'in', 'not_in'].each do |operator| + expected = ['ne', 'not_in'].include?(operator) ? 0 : 1 + expect(query(predicate('name', operator, '********'))['count']).to eq(expected) + end + blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1') + expect(query(blank)['count']).to eq(1) + ['********', '', nil].each do |value| + condition = { 'conditions' => '', 'conditions.0.field' => 'name', + 'conditions.0.operator' => 'axis_eq', + 'conditions.0.value_type' => value.nil? ? 'null' : 'string' } + condition['conditions.0.value'] = value unless value.nil? + expected = value == '********' ? 1 : 0 + expect(query(condition)['count']).to eq(expected) + drill = { 'drill' => '', 'drill.0.field' => 'name', + 'drill.0.value_type' => value.nil? ? 'null' : 'string' } + drill['drill.0.value'] = value unless value.nil? + expect(query(drill)['count']).to eq(expected) + end + end + + it 'keeps public identity and timeline predicates searchable' do + expect(query['count']).to eq(2) + values = { 'resource_id' => tag.id.to_s, 'version_no' => '2', + 'event_type' => 'discard', 'user' => actor.id.to_s, + 'created_at' => '2023-02-01T00:00:00Z' } + values.each do |field, value| + expected = ['resource_id', 'user'].include?(field) ? 2 : 1 + expect(query(predicate(field, 'eq', value))['count']).to eq(expected) + end + condition = predicate('name', 'ne', '秘密のタグ') + .merge(predicate('event_type', 'eq', 'discard', 1)) + expect(query(condition)['count']).to eq(0) + expect(query(predicate('category', 'eq', '********'))['count']).to eq(0) + expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0) + end + + it 'keeps public predicates available after combining multiple conditions' do + condition = predicate('resource_id', 'eq', tag.id.to_s) + .merge(predicate('event_type', 'eq', 'discard', 1)) + expect(query(condition)['count']).to eq(1) + condition.merge!(predicate('name', 'ne', 'unrelated', 2)) + expect(query(condition)['count']).to eq(0) + end + + it 'keeps real name searches for administrators and excludes private details' do + condition = predicate('name', 'eq', '秘密のタグ') + details = query(condition.merge('mode' => 'details')) + expect(details['versions']).to be_empty + expect(details['output_count']).to eq(0) + masked = query(predicate('name', 'eq', '********').merge('mode' => 'details')) + expect(masked['versions']).to be_empty + expect(masked['output_count']).to eq(0) + sign_in_as(admin) + expect(query(condition)['count']).to eq(2) + expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2) + end + + it 'rejects private names reached through a post tag snapshot' do + post = create(:post) + PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post', + url: post.url, tags: '', parent_post_ids: '', + tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ', + category: 'general', sections: [] }], + created_at: Time.utc(2024)) + ['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field| + ['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator| + ['秘密のタグ', '********', '%'].each do |value| + settings = predicate(field, operator, value).merge('resource' => 'posts') + expect(query(settings)['count']).to eq(0) + end + end + blank = predicate(field, 'eq', '').merge('resource' => 'posts', + 'conditions.0.blank' => '1') + expect(query(blank)['count']).to eq(0) + end + end +end diff --git a/backend/spec/services/counts_pivot_security_spec.rb b/backend/spec/services/counts_pivot_security_spec.rb index 8427e54..4e45f51 100644 --- a/backend/spec/services/counts_pivot_security_spec.rb +++ b/backend/spec/services/counts_pivot_security_spec.rb @@ -69,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do end end - it 'preserves five tag occurrences after merging private tags and across pages' do + it 'counts one opaque collection per private version across pages' do [nil, guest, member, admin].each do |user| page = result(user) - expect(grand(page)).to eq(5) - expect(page[:cells].sum { _1[:count] }).to eq(5) + expected = user == admin ? 5 : 6 + expect(grand(page)).to eq(expected) + expect(page[:cells].sum { _1[:count] }).to eq(expected) expect(page).not_to have_key(:excluded_count) paged = result(user, 'limit' => '1') - expect(grand(paged)).to eq(5) + expect(grand(paged)).to eq(expected) expect(result(user, 'limit' => '1', 'page' => '2')[:group_count]) .to eq(page[:group_count]) end public_page = result(nil) hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE } - expect(hidden.sum { _1[:count] }).to eq(3) + expect(hidden.sum { _1[:count] }).to eq(4) expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }") expect(public_page.to_json).not_to include('hidden-title', 'private-discard') end it 'retains distinct membership when tags are not a row axis' do [nil, admin].each do |user| - expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3) + expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))) + .to eq(user == admin ? 3 : 5) expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5) details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '') expect(details[:count]).to eq(user == admin ? 5 : 1) @@ -121,14 +123,13 @@ RSpec.describe 'Counts masked pivot contracts' do it 'counts distinct changed records when tags are not additive row tuples' do [nil, admin].each do |user| page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type') - expect(grand(page)).to eq(2) + expect(grand(page)).to eq(user == admin ? 2 : 4) if user.nil? expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE]) end - # Changed discard snapshots have no tags: no full cell contributes here. - empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags') - expect(empty[:cells]).to be_empty - expect(grand(empty)).to eq(0) + # Private empty collections contribute the same opaque tuple as populated ones. + combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags') + expect(grand(combined)).to eq(user == admin ? 0 : 4) end end @@ -154,7 +155,7 @@ RSpec.describe 'Counts masked pivot contracts' do expect(values.map(&:to_i)).to eq([1, 2, 10]) end - it 'uses public identity fields and masked values for unauthorised filters' do + it 'keeps public identity predicates and excludes private attributes' do hidden_id = PostVersion.first.post_id.to_s base = { 'rows' => '', 'cols' => '' } condition = lambda do |field, operator, value| @@ -170,25 +171,121 @@ RSpec.describe 'Counts masked pivot contracts' do expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells] .sole[:count]).to eq(0) expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells] - .sole[:count]).to eq(5) + .sole[:count]).to eq(1) expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells] - .sole[:count]).to eq(4) + .sole[:count]).to eq(0) expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells] - .sole[:count]).to eq(4) + .sole[:count]).to eq(0) expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells] .sole[:count]).to eq(1) expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells] - .sole[:count]).to eq(5) + .sole[:count]).to eq(1) expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1) - expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(4) + expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0) end - it 'rejects server-only coordinates in column order and drilldown' do + it 'retains public OR branches while rejecting private predicates on hidden rows' do + HistoryAccess.set(user: nil) do + population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''), + authorisation_flg: false) + condition = CountsCondition.new(population) + public_scope = condition.apply(population.scope, + { 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' }) + private_scope = condition.apply(population.scope, + { 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' }) + masked_scope = condition.apply(population.scope, + { 'field' => 'title', 'operator' => 'eq', 'value' => '********' }) + expect(public_scope.except(:select).count).to eq(2) + expect(public_scope.or(private_scope).except(:select).count).to eq(3) + expect(private_scope.or(public_scope).except(:select).count).to eq(3) + expect(public_scope.or(masked_scope).except(:select).count).to eq(2) + expect(public_scope.merge(private_scope).except(:select).count).to eq(0) + end + end + + it 'excludes private collections from positive, negative, AND and OR tag searches' do + settings = { 'rows' => '', 'cols' => '' } + { 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0, + 'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count| + expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count) + end + end + + it 'ignores empty numeric and interval bounds without restricting private history' do + settings = { 'rows' => '', 'cols' => '', 'conditions' => '' } + { 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'], + 'tags.sections' => ['range'] }.each do |field, operators| + operators.each do |operator| + condition = settings.merge('conditions.0.field' => field, + 'conditions.0.operator' => operator) + empty = condition.merge('conditions.0.value' => '') + expect(result(nil, condition)[:cells].sole[:count]).to eq(5) + expect(result(nil, empty)[:cells].sole[:count]).to eq(5) + expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells] + .sole[:count]).to eq(5) + if operator == 'range' + empty['conditions.0.end'] = '' + expect(result(nil, empty)[:cells].sole[:count]).to eq(5) + ['conditions.0.value', 'conditions.0.end'].each do |key| + active = empty.merge(key => '100') + expect(result(nil, active)[:cells].sole[:count]).to eq(0) + end + end + end + end + end + + it 'keeps typed marker sets active after incompatible marker values are removed' do + settings = { 'rows' => '', 'cols' => '', 'conditions' => '', + 'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1', + 'condition_values' => '', 'condition_values.0.condition' => '0', + 'condition_values.0.value_type' => 'string', + 'condition_values.0.value' => '********' } + ['in', 'not_in'].each do |operator| + expected = operator == 'in' ? 0 : 1 + condition = settings.merge('conditions.0.operator' => operator) + expect(result(nil, condition)[:cells].sole[:count]).to eq(expected) + end + end + + it 'restricts active numeric comparisons and preserves empty selection semantics' do + settings = { 'rows' => '', 'cols' => '', 'conditions' => '', + 'conditions.0.field' => 'video_ms' } + ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator| + condition = settings.merge('conditions.0.operator' => operator, + 'conditions.0.value' => '0') + condition['conditions.0.end'] = '100' if operator == 'range' + # The one public row is NULL; private NULLs and real values never match. + expected = operator == 'ne' ? 1 : 0 + expect(result(nil, condition)[:cells].sole[:count]).to eq(expected) + end + ['in', 'not_in'].each do |operator| + condition = settings.merge('conditions.0.operator' => operator, + 'conditions.0.values' => '1', 'condition_values' => '') + expected = operator == 'not_in' ? 5 : 0 + expect(result(nil, condition)[:cells].sole[:count]).to eq(expected) + end + blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1') + expect(result(nil, blank)[:cells].sole[:count]).to eq(1) + end + + it 'preserves empty tag sets and restricts active blank collection predicates' do + settings = { 'rows' => '', 'cols' => '', 'conditions' => '', + 'conditions.0.field' => 'tags' } + ['any', 'all', 'none'].each do |operator| + condition = settings.merge('conditions.0.operator' => operator, + 'conditions.0.values' => '1', 'condition_values' => '') + expected = operator == 'any' ? 0 : 5 + expect(result(nil, condition)[:cells].sole[:count]).to eq(expected) + end + blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1') + expect(result(nil, blank)[:cells].sole[:count]).to eq(0) + end + + it 'rejects server-only coordinates in column order' do probes = [ { 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string', - 'column_order.0.value' => CountsAxes::MASKED_VALUE }, - { 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string', - 'drill.0.value' => CountsAxes::MASKED_VALUE }] + 'column_order.0.value' => CountsAxes::MASKED_VALUE }] HistoryAccess.set(user: nil) do probes.each do |probe| expect { CountsTableQuery.new(params.merge(probe)).call } @@ -197,6 +294,54 @@ RSpec.describe 'Counts masked pivot contracts' do end end + + it 'keeps private collection and change counts independent of stored elements' do + settings = [ + { 'rows' => 'tags', 'cols' => 'changed_field' }, + { 'rows' => 'changed_field tags', 'cols' => '' }] + original = settings.map { result(nil, _1) } + snapshot = tags.map do |tag| + { tag_id: tag.id, version_no: 1, sections: [] } + end + hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id) + PostVersion.where(post_id: hidden_posts).update_all( + title: 'same hidden value', tags_json: snapshot) + expect(settings.map { result(nil, _1) }).to eq(original) + end + + it 'excludes private tag and change coordinates from drill conditions' do + ['tags', 'changed_field'].each do |field| + settings = { 'rows' => field, 'cols' => '' } + page = result(nil, settings) + cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] } + expect(cell[:count]).to eq(4) + [CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value| + drill = { 'drill' => '', 'drill.0.field' => field, + 'drill.0.value_type' => 'string', 'drill.0.value' => value } + expect(result(nil, settings.merge(drill))[:cells]).to be_empty + details = result(nil, settings.merge(drill).merge('mode' => 'details')) + expect(details[:versions]).to be_empty + condition = { 'conditions' => '', 'conditions.0.field' => field, + 'conditions.0.operator' => 'axis_eq', + 'conditions.0.value_type' => 'string', 'conditions.0.value' => value } + expect(result(nil, settings.merge(condition))[:cells]).to be_empty + end + end + end + + it 'rejects real private tag and change coordinates for non-admin users' do + [nil, guest, member].each do |user| + { 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do + |field, value| + settings = { 'rows' => '', 'cols' => '', 'drill' => '', + 'drill.0.field' => field, 'drill.0.value_type' => 'string', + 'drill.0.value' => value } + expect(result(user, settings)[:cells].sole[:count]).to eq(0) + expect(result(admin, settings)[:cells].sole[:count]).to be > 0 + end + end + end + it 'keeps public column sorting usable while guessed private coordinates match nothing' do first = result(nil, 'rows' => 'event_type', 'cols' => 'tags') probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags', @@ -212,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do HistoryAccess.set(user: nil) do payload = params.merge('format' => 'json') json = JSON.parse(CountsTableExport.new(payload).body.to_a.join) - expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5) + expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6) expect(json.to_json).not_to include('hidden-title', 'private-discard') csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join expect(csv).to include('********')