From 733546f0e0ec6fb494d4d3bfc3360b54f4d58d3e Mon Sep 17 00:00:00 2001 From: Eugene Lorman Date: Thu, 12 Feb 2026 13:49:47 -0600 Subject: [PATCH] fix: support SCIM 2.0 count=0 parameter per RFC 7644 Implement support for count=0 query parameter as specified in RFC 7644 Section 3.4.2.4, which requires returning only metadata (totalResults) without resource data. Changes: - Allow count=0 in Scimitar::Lists::Count (previously raised error) - Skip database query for records when count=0 (performance optimization) - Return empty Resources array with correct totalResults and itemsPerPage=0 - Add comprehensive test coverage for count=0 behavior This fixes issues with SCIM clients (Azure AD, Okta, Google Workspace) that use count=0 to retrieve only total counts without fetching records. Co-Authored-By: Claude Sonnet 4.5 --- ...tive_record_backed_resources_controller.rb | 16 ++-- app/models/scimitar/lists/count.rb | 9 +- spec/models/scimitar/lists/count_spec.rb | 11 ++- ...record_backed_resources_controller_spec.rb | 86 +++++++++++++++++++ 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/app/controllers/scimitar/active_record_backed_resources_controller.rb b/app/controllers/scimitar/active_record_backed_resources_controller.rb index ae52db8..f6927fa 100644 --- a/app/controllers/scimitar/active_record_backed_resources_controller.rb +++ b/app/controllers/scimitar/active_record_backed_resources_controller.rb @@ -36,11 +36,17 @@ def index pagination_info = scim_pagination_info(query.count()) - page_of_results = query - .order(@id_column => :asc) - .offset(pagination_info.offset) - .limit(pagination_info.limit) - .to_a() + # SCIM 2.0 RFC 7644: When count=0, return metadata only (no Resources). + # This avoids an unnecessary database query for record data. + page_of_results = if pagination_info.limit == 0 + [] + else + query + .order(@id_column => :asc) + .offset(pagination_info.offset) + .limit(pagination_info.limit) + .to_a() + end super(pagination_info, page_of_results) do | record | record_to_scim(record) diff --git a/app/models/scimitar/lists/count.rb b/app/models/scimitar/lists/count.rb index 2a79df4..51fabd4 100644 --- a/app/models/scimitar/lists/count.rb +++ b/app/models/scimitar/lists/count.rb @@ -15,9 +15,12 @@ def initialize(*args) # Set a limit (page size) value. # - # +value+:: Integer value held in a String. Must be >= 1. + # +value+:: Integer value held in a String. Must be >= 0. + # + # Per SCIM 2.0 RFC 7644 Section 3.4.2.4: "A value of '0' indicates that + # no resource results are to be returned except for 'totalResults'." # - # Raises exceptions if given non-numeric, zero or negative input. + # Raises exceptions if given non-numeric or negative input. # def limit=(value) value = value&.to_s @@ -25,7 +28,7 @@ def limit=(value) validate_numericality(value) input = value.to_i - raise if input < 1 + raise if input < 0 @limit = input end diff --git a/spec/models/scimitar/lists/count_spec.rb b/spec/models/scimitar/lists/count_spec.rb index f03a7d7..1e846d3 100644 --- a/spec/models/scimitar/lists/count_spec.rb +++ b/spec/models/scimitar/lists/count_spec.rb @@ -34,12 +34,17 @@ expect { @instance.limit = 'A' }.to raise_error(RuntimeError) end - it 'complains about attempts to set zero values' do - expect { @instance.limit = '0' }.to raise_error(RuntimeError) + it 'allows count=0 per SCIM 2.0 specification (RFC 7644)' do + expect { @instance.limit = '0' }.to_not raise_error + expect(@instance.limit).to eql(0) end - it 'complains about attempts to set zero values' do + it 'allows count=0 as integer' do + expect { @instance.limit = 0 }.to_not raise_error + expect(@instance.limit).to eql(0) + end + it 'complains about attempts to set negative values' do expect { @instance.limit = '-10' }.to raise_error(RuntimeError) end end # "context 'on-read error checking' do" diff --git a/spec/requests/active_record_backed_resources_controller_spec.rb b/spec/requests/active_record_backed_resources_controller_spec.rb index 90bed20..8646873 100644 --- a/spec/requests/active_record_backed_resources_controller_spec.rb +++ b/spec/requests/active_record_backed_resources_controller_spec.rb @@ -292,6 +292,92 @@ usernames = result['Resources'].map { |resource| resource['userName'] } expect(usernames).to match_array(['2', '3']) end + + # SCIM 2.0 RFC 7644 Section 3.4.2.4: count=0 support + context 'with count=0' do + it 'returns 200 OK' do + get '/Users', params: { + format: :scim, + count: 0 + } + + expect(response.status).to eql(200) + expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8') + end + + it 'returns totalResults with actual count' do + get '/Users', params: { + format: :scim, + count: 0 + } + + result = JSON.parse(response.body) + expect(result['totalResults']).to eql(3) + end + + it 'returns itemsPerPage as 0' do + get '/Users', params: { + format: :scim, + count: 0 + } + + result = JSON.parse(response.body) + expect(result['itemsPerPage']).to eql(0) + end + + it 'returns empty Resources array' do + get '/Users', params: { + format: :scim, + count: 0 + } + + result = JSON.parse(response.body) + expect(result['Resources']).to eql([]) + end + + it 'respects startIndex parameter' do + get '/Users', params: { + format: :scim, + count: 0, + startIndex: 5 + } + + result = JSON.parse(response.body) + expect(result['startIndex']).to eql(5) + end + + it 'applies filters when calculating totalResults' do + get '/Users', params: { + format: :scim, + count: 0, + filter: 'name.familyName eq "Bar"' + } + + result = JSON.parse(response.body) + expect(result['totalResults']).to eql(1) + expect(result['Resources']).to eql([]) + end + + it 'does not query for records (performance optimization)' do + # We should get the count but not fetch records + query_double = instance_double(ActiveRecord::Relation) + allow(MockUser).to receive(:all).and_return(query_double) + allow(query_double).to receive(:count).and_return(3) + + # Should NOT call order, offset, limit, or to_a when count=0 + expect(query_double).not_to receive(:order) + expect(query_double).not_to receive(:offset) + expect(query_double).not_to receive(:limit) + expect(query_double).not_to receive(:to_a) + + get '/Users', params: { + format: :scim, + count: 0 + } + + expect(response.status).to eql(200) + end + end # "context 'with count=0' do" end # "context 'with items' do" context 'with bad calls' do