Skip to content

Commit 3d12f5b

Browse files
authored
fix(secrethub): race condition in jwt configurator (#190)
## 📝 Description Fixing the race condition in JWT configurator. For more details, see [the task](renderedtext/tasks#7524). ## ✅ Checklist - [x] I have tested this change - [x] ~This change requires documentation update~ - N/A
1 parent d614dd6 commit 3d12f5b

File tree

5 files changed

+184
-63
lines changed

5 files changed

+184
-63
lines changed

secrethub/lib/secrethub/open_id_connect/jwt_configuration.ex

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,14 @@ defmodule Secrethub.OpenIDConnect.JWTConfiguration do
4848
is_active: true
4949
}
5050

51-
# First try to find existing config
52-
query =
53-
from c in __MODULE__,
54-
where: c.org_id == ^org_id and is_nil(c.project_id)
55-
56-
case Repo.one(query) do
57-
nil ->
58-
%__MODULE__{}
59-
|> changeset(attrs)
60-
|> Repo.insert()
61-
62-
config ->
63-
config
64-
|> changeset(attrs)
65-
|> Repo.update()
66-
end
51+
# Use upsert with conditional conflict target
52+
%__MODULE__{}
53+
|> changeset(attrs)
54+
|> Repo.insert(
55+
on_conflict: {:replace, [:claims, :is_active, :updated_at]},
56+
conflict_target: {:unsafe_fragment, ~s<("org_id") WHERE project_id IS NULL>},
57+
returning: true
58+
)
6759
else
6860
{:error, :invalid_claims}
6961
end
@@ -93,24 +85,15 @@ defmodule Secrethub.OpenIDConnect.JWTConfiguration do
9385
is_active: true
9486
}
9587

96-
# First try to find existing config
97-
query =
98-
from c in __MODULE__,
99-
where: c.org_id == ^org_id and c.project_id == ^project_id
100-
101-
case Repo.one(query) do
102-
nil ->
103-
# No existing config, create new one
104-
%__MODULE__{}
105-
|> changeset(attrs)
106-
|> Repo.insert()
107-
108-
existing ->
109-
# Update existing config
110-
existing
111-
|> changeset(%{claims: claims})
112-
|> Repo.update()
113-
end
88+
# Use upsert with conditional conflict target for project-level config
89+
%__MODULE__{}
90+
|> changeset(attrs)
91+
|> Repo.insert(
92+
on_conflict: {:replace, [:claims, :is_active, :updated_at]},
93+
conflict_target:
94+
{:unsafe_fragment, ~s<("org_id", "project_id") WHERE project_id IS NOT NULL>},
95+
returning: true
96+
)
11497
else
11598
{:error, :invalid_claims}
11699
end
@@ -190,13 +173,9 @@ defmodule Secrethub.OpenIDConnect.JWTConfiguration do
190173
def delete_org_config(org_id) do
191174
query = from(c in __MODULE__, where: c.org_id == ^org_id)
192175

193-
case Repo.all(query) do
194-
[] ->
195-
{:error, :not_found}
196-
197-
configs ->
198-
Enum.each(configs, &Repo.delete/1)
199-
{:ok, :deleted}
176+
case Repo.delete_all(query) do
177+
{0, _} -> {:error, :not_found}
178+
{_count, _} -> {:ok, :deleted}
200179
end
201180
end
202181

secrethub/lib/secrethub/open_id_connect/jwt_filter.ex

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ defmodule Secrethub.OpenIDConnect.JWTFilter do
1010
def filter_enabled?(org_id),
1111
do: FeatureProvider.feature_enabled?(:open_id_connect_filter, param: org_id)
1212

13+
def project_filter_enabled?(org_id),
14+
do: FeatureProvider.feature_enabled?(:open_id_connect_project_filter, param: org_id)
15+
1316
@doc """
1417
Filters JWT claims based on organization or project settings.
1518
Uses JWT configuration if available, otherwise falls back to essential claims.
@@ -67,18 +70,32 @@ defmodule Secrethub.OpenIDConnect.JWTFilter do
6770
Returns a list of active claim names or error tuple.
6871
"""
6972
def get_allowed_claims(org_id, project_id) do
70-
case JWTConfiguration.get_project_config(org_id, project_id) do
71-
{:ok, config} ->
72-
active_claims =
73-
config.claims
74-
|> Enum.filter(fn claim -> claim["is_active"] == true end)
75-
|> Enum.map(fn claim -> claim["name"] end)
76-
|> Enum.sort()
73+
if project_filter_enabled?(org_id) do
74+
get_project_allowed_claims(org_id, project_id)
75+
else
76+
get_org_allowed_claims(org_id)
77+
end
78+
end
7779

78-
{:ok, active_claims}
80+
defp get_project_allowed_claims(org_id, project_id) do
81+
JWTConfiguration.get_project_config(org_id, project_id)
82+
|> handle_get_config_result()
83+
end
7984

80-
err ->
81-
err
82-
end
85+
defp get_org_allowed_claims(org_id) do
86+
JWTConfiguration.get_org_config(org_id)
87+
|> handle_get_config_result()
88+
end
89+
90+
defp handle_get_config_result({:ok, config}) do
91+
active_claims =
92+
config.claims
93+
|> Enum.filter(fn claim -> claim["is_active"] == true end)
94+
|> Enum.map(fn claim -> claim["name"] end)
95+
|> Enum.sort()
96+
97+
{:ok, active_claims}
8398
end
99+
100+
defp handle_get_config_result(err), do: err
84101
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule Secrethub.Repo.Migrations.AddUniqueIndexesToJwtConfigurations do
2+
use Ecto.Migration
3+
4+
def up do
5+
# Drop any existing duplicate records before adding unique constraints
6+
execute """
7+
DELETE FROM jwt_configurations
8+
WHERE id::text NOT IN (
9+
SELECT MIN(id::text)
10+
FROM jwt_configurations
11+
GROUP BY org_id, project_id
12+
);
13+
"""
14+
15+
create_if_not_exists unique_index(:jwt_configurations, [:org_id],
16+
name: :jwt_configurations_org_unique_index,
17+
where: "project_id IS NULL"
18+
)
19+
20+
create_if_not_exists unique_index(:jwt_configurations, [:org_id, :project_id],
21+
name: :jwt_configurations_org_project_unique_index,
22+
where: "project_id IS NOT NULL"
23+
)
24+
25+
drop_if_exists index(:jwt_configurations, [:org_id, :project_id])
26+
end
27+
28+
def down do
29+
drop_if_exists index(:jwt_configurations, [:org_id],
30+
name: :jwt_configurations_org_unique_index
31+
)
32+
33+
drop_if_exists index(:jwt_configurations, [:org_id, :project_id],
34+
name: :jwt_configurations_org_project_unique_index
35+
)
36+
end
37+
end

secrethub/test/secrethub/open_id_connect/jwt_configuration_test.exs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,95 @@ defmodule Secrethub.OpenIDConnect.JWTConfigurationTest do
668668
}
669669
]
670670

671-
{:ok, %{org_id: org_id, project_id: project_id, claims: claims}}
671+
# Different claims for concurrent tests
672+
concurrent_claims = [
673+
%{
674+
"name" => "sub",
675+
"is_active" => true,
676+
"description" => "Concurrent update"
677+
}
678+
]
679+
680+
{:ok,
681+
%{
682+
org_id: org_id,
683+
project_id: project_id,
684+
claims: claims,
685+
concurrent_claims: concurrent_claims
686+
}}
687+
end
688+
689+
test "handles concurrent org config creations", %{
690+
org_id: org_id,
691+
claims: claims,
692+
concurrent_claims: concurrent_claims
693+
} do
694+
# Start multiple concurrent operations
695+
tasks =
696+
for _ <- 1..20 do
697+
Task.async(fn ->
698+
JWTConfiguration.create_or_update_org_config(org_id, claims)
699+
end)
700+
end
701+
702+
# Add a concurrent update with different claims
703+
update_task =
704+
Task.async(fn ->
705+
JWTConfiguration.create_or_update_org_config(org_id, concurrent_claims)
706+
end)
707+
708+
# Wait for all tasks to complete
709+
results = Task.await_many([update_task | tasks], 5000)
710+
711+
# All operations should succeed
712+
assert Enum.all?(results, fn {:ok, _} -> true end)
713+
714+
# Verify only one configuration exists
715+
configs = Repo.all(JWTConfiguration)
716+
org_configs = Enum.filter(configs, &is_nil(&1.project_id))
717+
assert length(org_configs) == 1
718+
719+
# The final state should be consistent
720+
{:ok, final_config} = JWTConfiguration.get_org_config(org_id)
721+
assert final_config.org_id == org_id
722+
assert is_nil(final_config.project_id)
723+
end
724+
725+
test "handles concurrent project config creations", %{
726+
org_id: org_id,
727+
project_id: project_id,
728+
claims: claims,
729+
concurrent_claims: concurrent_claims
730+
} do
731+
# Start multiple concurrent operations
732+
tasks =
733+
for _ <- 1..10 do
734+
Task.async(fn ->
735+
JWTConfiguration.create_or_update_project_config(org_id, project_id, claims)
736+
end)
737+
end
738+
739+
# Add a concurrent update with different claims
740+
update_task =
741+
Task.async(fn ->
742+
JWTConfiguration.create_or_update_project_config(org_id, project_id, concurrent_claims)
743+
end)
744+
745+
# Wait for all tasks to complete
746+
results = Task.await_many([update_task | tasks], 5000)
747+
748+
# All operations should succeed
749+
assert Enum.all?(results, fn {:ok, _} -> true end)
750+
751+
# Verify only one configuration exists for this project
752+
configs = Repo.all(JWTConfiguration)
753+
project_configs = Enum.filter(configs, &(&1.project_id == project_id))
754+
assert length(project_configs) == 1
755+
756+
# The final state should be consistent
757+
{:ok, final_config} = JWTConfiguration.get_project_config(org_id, project_id)
758+
assert final_config.org_id == org_id
759+
assert final_config.project_id == project_id
672760
end
673761

674762
test "allows updating existing org config", %{org_id: org_id, claims: claims} do

secrethub/test/secrethub/open_id_connect/jwt_filter_test.exs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
4343
}
4444

4545
with_mock(JWTConfiguration, [],
46-
get_project_config: fn "org_id", "project_id" ->
46+
get_org_config: fn "org_id" ->
4747
{:ok,
4848
%{
4949
claims: [
@@ -85,7 +85,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
8585
}
8686

8787
with_mock(JWTConfiguration, [],
88-
get_project_config: fn "org_id", "project_id" ->
88+
get_org_config: fn "org_id" ->
8989
{:ok,
9090
%{
9191
claims: [
@@ -116,7 +116,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
116116
}
117117

118118
with_mock(JWTConfiguration, [],
119-
get_project_config: fn "org_id", "project_id" ->
119+
get_org_config: fn "org_id" ->
120120
{:ok,
121121
%{
122122
claims: [
@@ -131,15 +131,15 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
131131
end
132132

133133
test "returns error for invalid claims" do
134-
FakeServices.enable_features(["open_id_connect_filter"])
134+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
135135
assert JWTFilter.filter_claims(nil, "org_id", "project_id") == {:error, :invalid_claims}
136136
end
137137

138138
test "propagates JWT configuration errors" do
139139
FakeServices.enable_features(["open_id_connect_filter"])
140140

141141
with_mock(JWTConfiguration, [],
142-
get_project_config: fn "org_id", "project_id" ->
142+
get_org_config: fn "org_id" ->
143143
{:error, :not_found}
144144
end
145145
) do
@@ -151,7 +151,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
151151

152152
describe "get_allowed_claims/2" do
153153
test "returns active claims from JWT configuration" do
154-
FakeServices.enable_features(["open_id_connect_filter"])
154+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
155155

156156
with_mock(JWTConfiguration, [],
157157
get_project_config: fn "org_id", "project_id" ->
@@ -170,7 +170,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
170170
end
171171

172172
test "propagates JWT configuration errors" do
173-
FakeServices.enable_features(["open_id_connect_filter"])
173+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
174174

175175
with_mock(JWTConfiguration, [],
176176
get_project_config: fn "org_id", "project_id" ->
@@ -259,7 +259,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
259259
end
260260

261261
test "project config overrides org config", %{org_id: org_id, project_id: project_id} do
262-
FakeServices.enable_features(["open_id_connect_filter"])
262+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
263263
# Create org configuration with certain claims active
264264
{:ok, org_config} = JWTConfiguration.get_org_config(org_id)
265265

@@ -293,7 +293,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
293293
end
294294

295295
test "handles non-existent project configuration", %{org_id: org_id, project_id: project_id} do
296-
FakeServices.enable_features(["open_id_connect_filter"])
296+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
297297
# Create org configuration with active claims
298298
{:ok, org_config} = JWTConfiguration.get_org_config(org_id)
299299

@@ -355,7 +355,7 @@ defmodule Secrethub.OpenIDConnect.JWTFilterTest do
355355
org_id: org_id,
356356
project_id: project_id
357357
} do
358-
FakeServices.enable_features(["open_id_connect_filter"])
358+
FakeServices.enable_features(["open_id_connect_filter", "open_id_connect_project_filter"])
359359

360360
project_claims = [
361361
%{"name" => "sub", "is_active" => true},

0 commit comments

Comments
 (0)