Skip to content

Commit 7cd282e

Browse files
committed
Cache normalized organization membership lookups
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bff1e95b-3949-4ae8-b3a5-737fd278a977
1 parent 26b378a commit 7cd282e

7 files changed

Lines changed: 184 additions & 6 deletions

File tree

lib/entitlements/backend/github_org/service.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def add_user_to_organization(user, role)
6666
return true
6767
elsif new_membership[:state] == "active"
6868
org_members[user] = role
69+
add_org_member_to_normalized_lookup(user)
6970
return true
7071
end
7172
end
@@ -92,6 +93,7 @@ def remove_user_from_organization(user)
9293
# operations in this organization will ignore this user.
9394
if result
9495
org_members.delete(user)
96+
remove_org_member_from_normalized_lookup(user)
9597
pending_members.delete(user)
9698
end
9799

lib/entitlements/backend/github_team/provider.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,10 @@ def commit(entitlement_group)
144144
# Returns a set of strings with usernames meeting the criteria.
145145
Contract Entitlements::Models::Group => C::SetOf[String]
146146
def auto_generate_ignored_users(entitlement_group)
147-
org_members = github.org_members.keys.map(&:downcase)
148-
group_members = entitlement_group.member_strings.map(&:downcase)
149-
Set.new(group_members - org_members)
147+
entitlement_group.member_strings.each_with_object(Set.new) do |username, ignored_users|
148+
normalized_username = username.downcase
149+
ignored_users.add(normalized_username) unless github.org_member?(normalized_username)
150+
end
150151
end
151152

152153
private

lib/entitlements/service/github.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
require "net/http"
66
require "octokit"
7+
require "set"
78
require "uri"
89

910
module Entitlements
@@ -96,6 +97,20 @@ def org_members
9697
Entitlements.cache[:github_org_members][org_signature][:value]
9798
end
9899

100+
# Determine whether a username is an active member of the organization.
101+
#
102+
# username - GitHub username to look up.
103+
#
104+
# Returns true if the username is an active organization member.
105+
Contract String => C::Bool
106+
def org_member?(username)
107+
org_members
108+
entry = Entitlements.cache[:github_org_members].fetch(org_signature)
109+
entry[:normalized_members] ||= Set.new(entry[:value].keys)
110+
normalized_username = /[A-Z]/.match?(username) ? username.downcase : username
111+
entry[:normalized_members].include?(normalized_username)
112+
end
113+
99114
# Returns true if the github instance is an enterprise server instance
100115
Contract C::None => C::Bool
101116
def enterprise?
@@ -160,6 +175,24 @@ def invalidate_org_members_predictive_cache
160175

161176
private
162177

178+
# Keep an already-materialized membership lookup synchronized after an addition or
179+
# role change without forcing it to be built.
180+
Contract String => nil
181+
def add_org_member_to_normalized_lookup(username)
182+
entry = Entitlements.cache[:github_org_members][org_signature]
183+
entry[:normalized_members].add(username.downcase) if entry&.key?(:normalized_members)
184+
nil
185+
end
186+
187+
# Keep an already-materialized membership lookup synchronized after a removal without
188+
# forcing it to be built.
189+
Contract String => nil
190+
def remove_org_member_from_normalized_lookup(username)
191+
entry = Entitlements.cache[:github_org_members][org_signature]
192+
entry[:normalized_members].delete(username.downcase) if entry&.key?(:normalized_members)
193+
nil
194+
end
195+
163196
# The octokit object is initialized the first time it's called.
164197
#
165198
# Takes no arguments.

spec/unit/entitlements/backend/github_org/service_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,39 @@
129129
expect(subject.pending_members).to eq(Set.new)
130130
expect(subject.org_members).to eq("bob" => "admin")
131131
end
132+
133+
it "adds active members and role changes to a materialized normalized lookup" do
134+
allow(subject).to receive(:members_and_roles_from_rest).and_return("bob" => "MEMBER")
135+
expect(subject.org_member?("bob")).to eq(true)
136+
137+
stub_request(:put, "https://github.fake/api/v3/orgs/kittensinc/memberships/alice").to_return(
138+
status: 200,
139+
headers: {
140+
"Content-type" => "application/json"
141+
},
142+
body: JSON.generate(
143+
"url" => "https://github.fake/api/v3/orgs/kittensinc/memberships/alice",
144+
"state" => "active",
145+
"role" => "admin"
146+
)
147+
)
148+
stub_request(:put, "https://github.fake/api/v3/orgs/kittensinc/memberships/bob").to_return(
149+
status: 200,
150+
headers: {
151+
"Content-type" => "application/json"
152+
},
153+
body: JSON.generate(
154+
"url" => "https://github.fake/api/v3/orgs/kittensinc/memberships/bob",
155+
"state" => "active",
156+
"role" => "admin"
157+
)
158+
)
159+
160+
expect(subject.send(:add_user_to_organization, "alice", "admin")).to eq(true)
161+
expect(subject.org_member?("alice")).to eq(true)
162+
expect(subject.send(:add_user_to_organization, "bob", "admin")).to eq(true)
163+
expect(subject.org_member?("bob")).to eq(true)
164+
end
132165
end
133166

134167
context "sad path" do
@@ -244,5 +277,17 @@
244277
expect(subject.pending_members).to eq(Set.new)
245278
expect(subject.org_members).to eq({})
246279
end
280+
281+
it "removes members from a materialized normalized lookup" do
282+
allow(subject).to receive(:members_and_roles_from_rest).and_return("bob" => "ADMIN")
283+
allow(subject).to receive(:enterprise?).and_return(false)
284+
allow(subject).to receive(:pending_members_from_graphql).and_return(Set.new)
285+
expect(subject.org_member?("bob")).to eq(true)
286+
287+
stub_request(:delete, "https://github.fake/api/v3/orgs/kittensinc/memberships/bob").to_return(status: 204)
288+
289+
expect(subject.send(:remove_user_from_organization, "bob")).to eq(true)
290+
expect(subject.org_member?("bob")).to eq(false)
291+
end
247292
end
248293
end

spec/unit/entitlements/backend/github_team/controller_spec.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"RUSSIANBLue" => "member"
2828
}
2929
end
30+
let(:org_member_set) { Set.new(org_member_hash.keys.map(&:downcase)) }
3031

3132
describe "#calculate" do
3233
let(:russian_blue_team) do
@@ -123,7 +124,8 @@
123124
allow(dotcom_obj).to receive(:org).and_return("kittensinc")
124125
allow(dotcom_obj).to receive(:read_team).with(russian_blue_group).and_return(russian_blue_team)
125126
allow(dotcom_obj).to receive(:read_team).with(snowshoe_group).and_return(snowshoe_team)
126-
allow(dotcom_obj).to receive(:org_members).and_return(org_member_hash)
127+
expect(dotcom_obj).not_to receive(:org_members)
128+
allow(dotcom_obj).to receive(:org_member?) { |username| org_member_set.include?(username) }
127129
allow(dotcom_obj).to receive(:from_predictive_cache?).and_return(false)
128130

129131
expect(logger).to receive(:debug).with("Loaded cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=com (id=1001) with 2 member(s)")
@@ -169,7 +171,8 @@
169171
allow(dotcom_obj).to receive(:identifier).and_return("github.com")
170172
allow(dotcom_obj).to receive(:org).and_return("kittensinc")
171173
allow(dotcom_obj).to receive(:read_team).with(russian_blue_group).and_return(russian_blue_team)
172-
allow(dotcom_obj).to receive(:org_members).and_return(org_member_hash)
174+
expect(dotcom_obj).not_to receive(:org_members)
175+
allow(dotcom_obj).to receive(:org_member?) { |username| org_member_set.include?(username) }
173176
allow(dotcom_obj).to receive(:from_predictive_cache?).and_return(false)
174177

175178
expect(logger).to receive(:debug).with("Loaded cn=russian-blues,ou=kittensinc,ou=GitHub,dc=github,dc=com (id=1001) with 2 member(s)")
@@ -218,7 +221,8 @@
218221
allow(dotcom_obj).to receive(:ou).and_return("GitHub")
219222
allow(dotcom_obj).to receive(:read_team).with(russian_blue_group).and_return(nil)
220223
allow(dotcom_obj).to receive(:read_team).with(snowshoe_group).and_return(snowshoe_team)
221-
allow(dotcom_obj).to receive(:org_members).and_return(org_member_hash)
224+
expect(dotcom_obj).not_to receive(:org_members)
225+
allow(dotcom_obj).to receive(:org_member?) { |username| org_member_set.include?(username) }
222226
allow(dotcom_obj).to receive(:from_predictive_cache?).and_return(false)
223227

224228
expect(logger).to receive(:debug).with("Loaded cn=snowshoes,ou=kittensinc,ou=GitHub,dc=github,dc=com (id=1002) with 2 member(s)")

spec/unit/entitlements/backend/github_team/provider_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,30 @@
454454
end
455455
end
456456

457+
describe "#auto_generate_ignored_users" do
458+
it "returns lowercase non-members and does not enumerate organization membership" do
459+
entitlement_group = instance_double(
460+
Entitlements::Models::Group,
461+
member_strings: Set.new(%w[SnowShoe RUSSIAN_BLUE PendingCat])
462+
)
463+
allow(subject).to receive(:github).and_return(github)
464+
expect(github).not_to receive(:org_members)
465+
expect(github).to receive(:org_member?).with("snowshoe").and_return(true)
466+
expect(github).to receive(:org_member?).with("russian_blue").and_return(false)
467+
expect(github).to receive(:org_member?).with("pendingcat").and_return(false)
468+
469+
expect(subject.auto_generate_ignored_users(entitlement_group)).to eq(Set.new(%w[russian_blue pendingcat]))
470+
end
471+
472+
it "returns an empty set for an empty team" do
473+
entitlement_group = instance_double(Entitlements::Models::Group, member_strings: Set.new)
474+
allow(subject).to receive(:github).and_return(github)
475+
expect(github).not_to receive(:org_member?)
476+
477+
expect(subject.auto_generate_ignored_users(entitlement_group)).to eq(Set.new)
478+
end
479+
end
480+
457481
describe "#create_github_team_group" do
458482
it "returns a new empty team" do
459483
entitlement_group = Entitlements::Models::Group.new(

spec/unit/entitlements/service/github_spec.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,71 @@
4747
end
4848
end
4949

50+
describe "#org_member?" do
51+
let(:members_and_roles) do
52+
{
53+
"alice" => "MEMBER",
54+
"bob" => "ADMIN"
55+
}
56+
end
57+
58+
before do
59+
allow(subject).to receive(:members_and_roles_from_rest).and_return(members_and_roles)
60+
end
61+
62+
it "performs case-insensitive lookups" do
63+
expect(subject.org_member?("ALIce")).to eq(true)
64+
expect(subject.org_member?("charles")).to eq(false)
65+
end
66+
67+
it "reuses the normalized membership set" do
68+
expect(subject.org_member?("alice")).to eq(true)
69+
normalized_members = Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]
70+
expect(subject.org_member?("bob")).to eq(true)
71+
expect(Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]).to equal(normalized_members)
72+
end
73+
74+
it "shares the normalized membership set between services for the same organization signature" do
75+
other = described_class.new(
76+
addr: "https://github.fake/api/v3",
77+
org: "kittensinc",
78+
token: "DifferentToken",
79+
ou: "ou=kittensinc,ou=GitHub,dc=github,dc=fake",
80+
ignore_not_found: false
81+
)
82+
83+
expect(subject.org_member?("alice")).to eq(true)
84+
normalized_members = Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]
85+
expect(other.org_member?("bob")).to eq(true)
86+
expect(Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]).to equal(normalized_members)
87+
end
88+
89+
it "isolates normalized membership sets by organization and GitHub instance" do
90+
other_org = described_class.new(
91+
addr: "https://github.fake/api/v3",
92+
org: "puppiesinc",
93+
token: "GoPackGo",
94+
ou: "ou=puppiesinc,ou=GitHub,dc=github,dc=fake",
95+
ignore_not_found: false
96+
)
97+
other_instance = described_class.new(
98+
addr: "https://github.example/api/v3",
99+
org: "kittensinc",
100+
token: "GoPackGo",
101+
ou: "ou=kittensinc,ou=GitHub,dc=github,dc=example",
102+
ignore_not_found: false
103+
)
104+
allow(other_org).to receive(:members_and_roles_from_rest).and_return("charles" => "MEMBER")
105+
allow(other_instance).to receive(:members_and_roles_from_rest).and_return("david" => "MEMBER")
106+
107+
expect(subject.org_member?("alice")).to eq(true)
108+
expect(other_org.org_member?("alice")).to eq(false)
109+
expect(other_org.org_member?("charles")).to eq(true)
110+
expect(other_instance.org_member?("alice")).to eq(false)
111+
expect(other_instance.org_member?("david")).to eq(true)
112+
end
113+
end
114+
50115
describe "#enterprise?" do
51116
it "returns false if an instance is not enterprise" do
52117
stub_request(:get, "https://github.fake/api/v3/meta").
@@ -122,6 +187,8 @@
122187

123188
# First load should read from the cache.
124189
expect(subject.org_members).to eq(answer.map { |k, v| [k, v.downcase] }.to_h)
190+
expect(subject.org_member?("monalisa")).to eq(true)
191+
normalized_members = Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]
125192

126193
# Invalidating cache should force a re-read.
127194
answer_2 = answer.dup
@@ -137,6 +204,8 @@
137204
# Check that the re-read has occurred and the correct result is achieved.
138205
expect(subject).not_to receive(:members_and_roles_from_graphql) # Should already be in object's cache
139206
expect(subject.org_members).to eq(answer_2.map { |k, v| [k, v.downcase] }.to_h)
207+
expect(subject.org_member?("ragamuffin")).to eq(true)
208+
expect(Entitlements.cache[:github_org_members]["https://github.fake/api/v3|kittensinc"][:normalized_members]).not_to equal(normalized_members)
140209
end
141210
end
142211

0 commit comments

Comments
 (0)