Skip to content

Commit 16f4a21

Browse files
feat: add security & resilience features (Phase 9)
Circuit breaker UI for upstream groups, proxy-level auth policies (JWT, API key, basic, forward auth, mTLS) with reusable policies table, WAF/request security config, and request/response header transforms.
1 parent 436231f commit 16f4a21

23 files changed

Lines changed: 2265 additions & 14 deletions

File tree

lib/sentinel_cp/services.ex

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule SentinelCp.Services do
88

99
import Ecto.Query, warn: false
1010
alias SentinelCp.Repo
11-
alias SentinelCp.Services.{Service, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate}
11+
alias SentinelCp.Services.{Service, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy}
1212

1313
## Services
1414

@@ -204,6 +204,54 @@ defmodule SentinelCp.Services do
204204
Repo.delete(target)
205205
end
206206

207+
## Auth Policies
208+
209+
@doc """
210+
Lists auth policies for a project, ordered by name.
211+
"""
212+
def list_auth_policies(project_id) do
213+
from(a in AuthPolicy,
214+
where: a.project_id == ^project_id,
215+
order_by: [asc: a.name]
216+
)
217+
|> Repo.all()
218+
end
219+
220+
@doc """
221+
Gets a single auth policy by ID.
222+
"""
223+
def get_auth_policy(id), do: Repo.get(AuthPolicy, id)
224+
225+
@doc """
226+
Gets a single auth policy by ID, raises if not found.
227+
"""
228+
def get_auth_policy!(id), do: Repo.get!(AuthPolicy, id)
229+
230+
@doc """
231+
Creates an auth policy.
232+
"""
233+
def create_auth_policy(attrs) do
234+
%AuthPolicy{}
235+
|> AuthPolicy.create_changeset(attrs)
236+
|> Repo.insert()
237+
end
238+
239+
@doc """
240+
Updates an auth policy.
241+
"""
242+
def update_auth_policy(%AuthPolicy{} = policy, attrs) do
243+
policy
244+
|> AuthPolicy.update_changeset(attrs)
245+
|> Repo.update()
246+
end
247+
248+
@doc """
249+
Deletes an auth policy.
250+
"""
251+
def delete_auth_policy(%AuthPolicy{} = policy) do
252+
Repo.delete(policy)
253+
end
254+
207255
## Certificates
208256

209257
@doc """
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
defmodule SentinelCp.Services.AuthPolicy do
2+
@moduledoc """
3+
Auth policy schema for proxy-level authentication configuration.
4+
5+
Auth policies define how the proxy validates incoming requests (JWT, API key,
6+
basic auth, forward auth, mTLS). Policies are reusable across services.
7+
"""
8+
use Ecto.Schema
9+
import Ecto.Changeset
10+
11+
@primary_key {:id, :binary_id, autogenerate: true}
12+
@foreign_key_type :binary_id
13+
14+
@auth_types ~w(jwt api_key basic forward_auth mtls)
15+
16+
schema "auth_policies" do
17+
field :name, :string
18+
field :slug, :string
19+
field :description, :string
20+
field :auth_type, :string
21+
field :config, :map, default: %{}
22+
field :enabled, :boolean, default: true
23+
24+
belongs_to :project, SentinelCp.Projects.Project
25+
has_many :services, SentinelCp.Services.Service
26+
27+
timestamps(type: :utc_datetime)
28+
end
29+
30+
def create_changeset(policy, attrs) do
31+
policy
32+
|> cast(attrs, [:name, :description, :auth_type, :config, :enabled, :project_id])
33+
|> validate_required([:name, :auth_type, :project_id])
34+
|> validate_length(:name, min: 1, max: 100)
35+
|> validate_inclusion(:auth_type, @auth_types)
36+
|> generate_slug()
37+
|> validate_slug()
38+
|> unique_constraint([:project_id, :slug], error_key: :slug)
39+
|> foreign_key_constraint(:project_id)
40+
end
41+
42+
def update_changeset(policy, attrs) do
43+
policy
44+
|> cast(attrs, [:name, :description, :auth_type, :config, :enabled])
45+
|> validate_required([:name, :auth_type])
46+
|> validate_length(:name, min: 1, max: 100)
47+
|> validate_inclusion(:auth_type, @auth_types)
48+
end
49+
50+
defp generate_slug(changeset) do
51+
case get_change(changeset, :name) do
52+
nil ->
53+
changeset
54+
55+
name ->
56+
slug =
57+
name
58+
|> String.downcase()
59+
|> String.replace(~r/[^a-z0-9]+/, "-")
60+
|> String.replace(~r/^-+|-+$/, "")
61+
|> String.slice(0, 50)
62+
63+
put_change(changeset, :slug, slug)
64+
end
65+
end
66+
67+
defp validate_slug(changeset) do
68+
changeset
69+
|> validate_required([:slug])
70+
|> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/,
71+
message: "must contain only lowercase letters, numbers, and hyphens"
72+
)
73+
|> validate_length(:slug, min: 1, max: 50)
74+
end
75+
end

lib/sentinel_cp/services/kdl_generator.ex

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule SentinelCp.Services.KdlGenerator do
66
"""
77

88
alias SentinelCp.Services
9-
alias SentinelCp.Services.{Service, ProjectConfig, UpstreamGroup, Certificate}
9+
alias SentinelCp.Services.{Service, ProjectConfig, UpstreamGroup, Certificate, AuthPolicy}
1010

1111
@doc """
1212
Generates KDL configuration for a project from its services and config.
@@ -22,7 +22,8 @@ defmodule SentinelCp.Services.KdlGenerator do
2222
{:ok, config} = Services.get_or_create_project_config(project_id)
2323
upstream_groups = Services.list_upstream_groups(project_id)
2424
certificates = Services.list_certificates(project_id)
25-
kdl = build_kdl(services, config, upstream_groups, certificates)
25+
auth_policies = Services.list_auth_policies(project_id)
26+
kdl = build_kdl(services, config, upstream_groups, certificates, auth_policies)
2627
{:ok, kdl}
2728
end
2829
end
@@ -31,7 +32,7 @@ defmodule SentinelCp.Services.KdlGenerator do
3132
Generates KDL from provided services, config, and upstream groups (no DB access).
3233
Useful for testing.
3334
"""
34-
def build_kdl(services, %ProjectConfig{} = config, upstream_groups \\ [], certificates \\ []) do
35+
def build_kdl(services, %ProjectConfig{} = config, upstream_groups \\ [], certificates \\ [], auth_policies \\ []) do
3536
# Build lookup maps
3637
group_map =
3738
upstream_groups
@@ -41,6 +42,10 @@ defmodule SentinelCp.Services.KdlGenerator do
4142
certificates
4243
|> Enum.into(%{}, fn c -> {c.id, c} end)
4344

45+
auth_policy_map =
46+
auth_policies
47+
|> Enum.into(%{}, fn a -> {a.id, a} end)
48+
4449
# Determine which certificates are used by services
4550
used_cert_ids =
4651
services
@@ -58,7 +63,7 @@ defmodule SentinelCp.Services.KdlGenerator do
5863
"",
5964
build_tls_certificates(used_certs),
6065
build_upstream_groups(upstream_groups),
61-
build_routes(services, group_map, cert_map),
66+
build_routes(services, group_map, cert_map, auth_policy_map),
6267
build_rate_limits(services)
6368
]
6469

@@ -85,6 +90,7 @@ defmodule SentinelCp.Services.KdlGenerator do
8590

8691
lines = lines ++ build_global_compression(config)
8792
lines = lines ++ build_global_access_control(config)
93+
lines = lines ++ build_global_security(config)
8894

8995
lines ++ ["}"]
9096
end
@@ -97,6 +103,10 @@ defmodule SentinelCp.Services.KdlGenerator do
97103
build_nested_map_block(config.global_access_control, "access_control", " ")
98104
end
99105

106+
defp build_global_security(%ProjectConfig{} = config) do
107+
build_nested_map_block(config.default_security, "security", " ")
108+
end
109+
100110
defp build_upstream_groups([]), do: []
101111

102112
defp build_upstream_groups(groups) do
@@ -136,16 +146,16 @@ defmodule SentinelCp.Services.KdlGenerator do
136146
lines ++ [" }"]
137147
end
138148

139-
defp build_routes(services, group_map, cert_map) do
149+
defp build_routes(services, group_map, cert_map, auth_policy_map) do
140150
route_blocks =
141151
services
142-
|> Enum.map(&build_route(&1, group_map, cert_map))
152+
|> Enum.map(&build_route(&1, group_map, cert_map, auth_policy_map))
143153
|> Enum.intersperse([""])
144154

145155
["routes {"] ++ List.flatten(route_blocks) ++ ["}"]
146156
end
147157

148-
defp build_route(%Service{} = service, group_map, cert_map) do
158+
defp build_route(%Service{} = service, group_map, cert_map, auth_policy_map) do
149159
lines = [" route #{inspect(service.route_path)} {"]
150160

151161
lines =
@@ -182,6 +192,10 @@ defmodule SentinelCp.Services.KdlGenerator do
182192
lines = lines ++ build_compression_block(service.compression)
183193
lines = lines ++ build_path_rewrite_block(service.path_rewrite)
184194
lines = lines ++ build_tls_ref(service.certificate_id, cert_map)
195+
lines = lines ++ build_auth_block(service.auth_policy_id, auth_policy_map)
196+
lines = lines ++ build_security_block(service.security)
197+
lines = lines ++ build_request_transform_block(service.request_transform)
198+
lines = lines ++ build_response_transform_block(service.response_transform)
185199

186200
lines ++ [" }"]
187201
end
@@ -293,6 +307,45 @@ defmodule SentinelCp.Services.KdlGenerator do
293307
end
294308
end
295309

310+
defp build_security_block(sec) when sec == %{} or sec == nil, do: []
311+
312+
defp build_security_block(sec) do
313+
build_nested_map_block(sec, "security", " ")
314+
end
315+
316+
defp build_request_transform_block(rt) when rt == %{} or rt == nil, do: []
317+
318+
defp build_request_transform_block(rt) do
319+
build_nested_map_block(rt, "request_transform", " ")
320+
end
321+
322+
defp build_response_transform_block(rt) when rt == %{} or rt == nil, do: []
323+
324+
defp build_response_transform_block(rt) do
325+
build_nested_map_block(rt, "response_transform", " ")
326+
end
327+
328+
defp build_auth_block(nil, _auth_policy_map), do: []
329+
330+
defp build_auth_block(auth_policy_id, auth_policy_map) do
331+
case Map.get(auth_policy_map, auth_policy_id) do
332+
nil ->
333+
[]
334+
335+
%AuthPolicy{} = policy ->
336+
lines = [" auth {"]
337+
lines = lines ++ [" type #{inspect(policy.auth_type)}"]
338+
339+
config_lines =
340+
(policy.config || %{})
341+
|> Enum.sort_by(fn {k, _} -> k end)
342+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
343+
344+
lines = lines ++ config_lines
345+
lines ++ [" }"]
346+
end
347+
end
348+
296349
defp build_rate_limits(services) do
297350
rate_limited =
298351
services

lib/sentinel_cp/services/project_config.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defmodule SentinelCp.Services.ProjectConfig do
1919
field :default_cors, :map, default: %{}
2020
field :default_compression, :map, default: %{}
2121
field :global_access_control, :map, default: %{}
22+
field :default_security, :map, default: %{}
2223

2324
belongs_to :project, SentinelCp.Projects.Project
2425

@@ -34,6 +35,7 @@ defmodule SentinelCp.Services.ProjectConfig do
3435
:default_cors,
3536
:default_compression,
3637
:global_access_control,
38+
:default_security,
3739
:project_id
3840
])
3941
|> validate_required([:project_id])

lib/sentinel_cp/services/service.ex

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ defmodule SentinelCp.Services.Service do
3030
field :access_control, :map, default: %{}
3131
field :compression, :map, default: %{}
3232
field :path_rewrite, :map, default: %{}
33+
field :security, :map, default: %{}
34+
field :request_transform, :map, default: %{}
35+
field :response_transform, :map, default: %{}
3336
field :redirect_url, :string
3437

3538
belongs_to :project, SentinelCp.Projects.Project
3639
belongs_to :upstream_group, SentinelCp.Services.UpstreamGroup
3740
belongs_to :certificate, SentinelCp.Services.Certificate
41+
belongs_to :auth_policy, SentinelCp.Services.AuthPolicy
3842

3943
timestamps(type: :utc_datetime)
4044
end
@@ -60,9 +64,13 @@ defmodule SentinelCp.Services.Service do
6064
:access_control,
6165
:compression,
6266
:path_rewrite,
67+
:security,
68+
:request_transform,
69+
:response_transform,
6370
:redirect_url,
6471
:upstream_group_id,
6572
:certificate_id,
73+
:auth_policy_id,
6674
:project_id
6775
])
6876
|> validate_required([:name, :route_path, :project_id])
@@ -96,9 +104,13 @@ defmodule SentinelCp.Services.Service do
96104
:access_control,
97105
:compression,
98106
:path_rewrite,
107+
:security,
108+
:request_transform,
109+
:response_transform,
99110
:redirect_url,
100111
:upstream_group_id,
101-
:certificate_id
112+
:certificate_id,
113+
:auth_policy_id
102114
])
103115
|> validate_required([:name, :route_path])
104116
|> validate_length(:name, min: 1, max: 100)

0 commit comments

Comments
 (0)