Skip to content

Commit b4abdf0

Browse files
feat: implement v3 feature roadmap (Phases 12-19)
Add enterprise authentication (OIDC/SAML SSO, TOTP MFA, API rate limiting, tamper-evident audit chain), structured event bus with reliable delivery, advanced deployment strategies (blue-green, canary analysis, promotion automation, freeze windows), observability stack (tracing, SLO/SLI framework, alerting rules engine, metric rollups), policy-as-code governance with compliance export (CEF/LEEF/JSON), multi-cluster federation with geo-distributed bundle replication, config-as-code export/import, GraphQL schema definitions, and HA/production readiness tooling (leader election, health checks, DR runbooks). 977 tests, 0 failures.
1 parent 99b3f18 commit b4abdf0

89 files changed

Lines changed: 9916 additions & 11 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lib/sentinel_cp/accounts/totp.ex

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
defmodule SentinelCp.Accounts.Totp do
2+
@moduledoc """
3+
TOTP multi-factor authentication context.
4+
Handles enrollment, verification, recovery codes, and org enforcement policies.
5+
"""
6+
7+
import Ecto.Query, warn: false
8+
alias SentinelCp.Repo
9+
alias SentinelCp.Accounts.UserTotp
10+
11+
@doc """
12+
Gets the TOTP configuration for a user.
13+
Returns nil if MFA is not enrolled.
14+
"""
15+
def get_user_totp(user_id) do
16+
Repo.get_by(UserTotp, user_id: user_id)
17+
end
18+
19+
@doc """
20+
Creates a new TOTP enrollment for a user.
21+
Returns the UserTotp with the secret and recovery codes.
22+
The TOTP is not active until verified with a valid code.
23+
"""
24+
def create_user_totp(user_id) do
25+
%UserTotp{}
26+
|> UserTotp.create_changeset(%{user_id: user_id})
27+
|> Repo.insert()
28+
end
29+
30+
@doc """
31+
Verifies a TOTP code and activates MFA for the user.
32+
Used during initial enrollment to confirm the user has the correct secret.
33+
"""
34+
def verify_totp_enrollment(%UserTotp{} = totp, code) do
35+
if valid_totp_code?(totp, code) do
36+
totp
37+
|> UserTotp.verify_changeset()
38+
|> Repo.update()
39+
else
40+
{:error, :invalid_code}
41+
end
42+
end
43+
44+
@doc """
45+
Validates a TOTP code during login.
46+
Returns {:ok, totp} if valid, {:error, :invalid_code} otherwise.
47+
"""
48+
def validate_totp(%UserTotp{} = totp, code) do
49+
cond do
50+
valid_totp_code?(totp, code) ->
51+
{:ok, _} =
52+
totp
53+
|> UserTotp.touch_changeset()
54+
|> Repo.update()
55+
56+
{:ok, totp}
57+
58+
valid_recovery_code?(totp, code) ->
59+
{:ok, _} =
60+
totp
61+
|> UserTotp.use_recovery_code_changeset(code)
62+
|> Repo.update()
63+
64+
{:ok, totp}
65+
66+
true ->
67+
{:error, :invalid_code}
68+
end
69+
end
70+
71+
@doc """
72+
Deletes the TOTP configuration for a user (disables MFA).
73+
"""
74+
def delete_user_totp(%UserTotp{} = totp) do
75+
Repo.delete(totp)
76+
end
77+
78+
@doc """
79+
Regenerates recovery codes for a user's TOTP.
80+
"""
81+
def regenerate_recovery_codes(%UserTotp{} = totp) do
82+
totp
83+
|> UserTotp.regenerate_recovery_codes_changeset()
84+
|> Repo.update()
85+
end
86+
87+
@doc """
88+
Checks if a user has MFA enabled (enrolled and verified).
89+
"""
90+
def mfa_enabled?(user_id) do
91+
case get_user_totp(user_id) do
92+
%UserTotp{} = totp -> UserTotp.verified?(totp)
93+
nil -> false
94+
end
95+
end
96+
97+
@doc """
98+
Checks if a user needs to enroll in MFA based on org policy.
99+
Returns `{:required, deadline}` if MFA is required but not enrolled,
100+
`:ok` if MFA is not required or already enrolled.
101+
"""
102+
def check_mfa_requirement(user, org) do
103+
policy = org_mfa_policy(org)
104+
105+
case policy do
106+
"optional" ->
107+
:ok
108+
109+
"required" ->
110+
if mfa_enabled?(user.id), do: :ok, else: {:required, mfa_deadline(org)}
111+
112+
"required_for_admins" ->
113+
if user.role == "admin" and not mfa_enabled?(user.id) do
114+
{:required, mfa_deadline(org)}
115+
else
116+
:ok
117+
end
118+
119+
_ ->
120+
:ok
121+
end
122+
end
123+
124+
defp valid_totp_code?(%UserTotp{secret: secret}, code) when is_binary(code) do
125+
NimbleTOTP.valid?(secret, code)
126+
end
127+
128+
defp valid_totp_code?(_, _), do: false
129+
130+
defp valid_recovery_code?(%UserTotp{recovery_codes: codes}, code) when is_binary(code) do
131+
code in codes
132+
end
133+
134+
defp valid_recovery_code?(_, _), do: false
135+
136+
defp org_mfa_policy(org) do
137+
Map.get(org, :mfa_policy, "optional")
138+
end
139+
140+
defp mfa_deadline(org) do
141+
grace_days = Map.get(org, :mfa_grace_period_days, 14)
142+
enforced_at = Map.get(org, :mfa_enforced_at)
143+
144+
if enforced_at do
145+
DateTime.add(enforced_at, grace_days * 86_400, :second)
146+
else
147+
nil
148+
end
149+
end
150+
end

lib/sentinel_cp/accounts/user.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ defmodule SentinelCp.Accounts.User do
1717
field :role, :string, default: "reader"
1818
field :confirmed_at, :utc_datetime
1919

20+
# SSO fields
21+
field :sso_provider_type, :string
22+
field :sso_provider_id, :binary_id
23+
field :sso_subject, :string
24+
field :sso_provisioned_at, :utc_datetime
25+
2026
has_many :api_keys, SentinelCp.Accounts.ApiKey
2127
has_many :org_memberships, SentinelCp.Orgs.OrgMembership
2228

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule SentinelCp.Accounts.UserTotp do
2+
@moduledoc """
3+
Schema for TOTP-based multi-factor authentication.
4+
Stores the shared secret and recovery codes for each user.
5+
"""
6+
use Ecto.Schema
7+
import Ecto.Changeset
8+
9+
@primary_key {:id, :binary_id, autogenerate: true}
10+
@foreign_key_type :binary_id
11+
12+
@recovery_code_count 10
13+
14+
schema "user_totps" do
15+
field :secret, :binary
16+
field :recovery_codes, {:array, :string}, default: []
17+
field :verified_at, :utc_datetime
18+
field :last_used_at, :utc_datetime
19+
20+
belongs_to :user, SentinelCp.Accounts.User
21+
22+
timestamps(type: :utc_datetime)
23+
end
24+
25+
def create_changeset(totp, attrs) do
26+
totp
27+
|> cast(attrs, [:user_id])
28+
|> validate_required([:user_id])
29+
|> unique_constraint(:user_id)
30+
|> put_secret()
31+
|> put_recovery_codes()
32+
|> foreign_key_constraint(:user_id)
33+
end
34+
35+
def verify_changeset(totp) do
36+
now = DateTime.utc_now() |> DateTime.truncate(:second)
37+
38+
totp
39+
|> change(%{verified_at: now, last_used_at: now})
40+
end
41+
42+
def touch_changeset(totp) do
43+
now = DateTime.utc_now() |> DateTime.truncate(:second)
44+
change(totp, %{last_used_at: now})
45+
end
46+
47+
def use_recovery_code_changeset(totp, code) do
48+
remaining = List.delete(totp.recovery_codes, code)
49+
change(totp, %{recovery_codes: remaining})
50+
end
51+
52+
def regenerate_recovery_codes_changeset(totp) do
53+
change(totp, %{recovery_codes: generate_recovery_codes()})
54+
end
55+
56+
def verified?(%__MODULE__{verified_at: nil}), do: false
57+
def verified?(%__MODULE__{}), do: true
58+
59+
defp put_secret(changeset) do
60+
if get_field(changeset, :secret) do
61+
changeset
62+
else
63+
put_change(changeset, :secret, NimbleTOTP.secret())
64+
end
65+
end
66+
67+
defp put_recovery_codes(changeset) do
68+
put_change(changeset, :recovery_codes, generate_recovery_codes())
69+
end
70+
71+
defp generate_recovery_codes do
72+
Enum.map(1..@recovery_code_count, fn _ ->
73+
:crypto.strong_rand_bytes(5) |> Base.encode32(padding: false) |> String.downcase()
74+
end)
75+
end
76+
77+
@doc """
78+
Generates an otpauth:// URI for QR code generation.
79+
"""
80+
def otpauth_uri(%__MODULE__{secret: secret}, email) when is_binary(email) do
81+
NimbleTOTP.otpauth_uri("SentinelCP:#{email}", secret, issuer: "SentinelCP")
82+
end
83+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
defmodule SentinelCp.Analytics.MetricRollup do
2+
@moduledoc """
3+
Schema for pre-aggregated metric rollups.
4+
5+
Rollups store hourly and daily aggregations of service metrics,
6+
enabling fast queries over long time ranges without scanning raw data.
7+
8+
## Periods
9+
- `hourly` — one record per service per hour
10+
- `daily` — one record per service per day
11+
- `monthly` — one record per service per month
12+
"""
13+
use Ecto.Schema
14+
import Ecto.Changeset
15+
16+
@primary_key {:id, :binary_id, autogenerate: true}
17+
@foreign_key_type :binary_id
18+
19+
@periods ~w(hourly daily monthly)
20+
21+
schema "metric_rollups" do
22+
field :period, :string
23+
field :period_start, :utc_datetime
24+
field :request_count, :integer, default: 0
25+
field :error_count, :integer, default: 0
26+
field :latency_p50_ms, :integer
27+
field :latency_p95_ms, :integer
28+
field :latency_p99_ms, :integer
29+
field :bandwidth_in_bytes, :integer, default: 0
30+
field :bandwidth_out_bytes, :integer, default: 0
31+
field :status_2xx, :integer, default: 0
32+
field :status_3xx, :integer, default: 0
33+
field :status_4xx, :integer, default: 0
34+
field :status_5xx, :integer, default: 0
35+
36+
belongs_to :service, SentinelCp.Services.Service
37+
belongs_to :project, SentinelCp.Projects.Project
38+
39+
timestamps(type: :utc_datetime)
40+
end
41+
42+
def changeset(rollup, attrs) do
43+
rollup
44+
|> cast(attrs, [
45+
:service_id,
46+
:project_id,
47+
:period,
48+
:period_start,
49+
:request_count,
50+
:error_count,
51+
:latency_p50_ms,
52+
:latency_p95_ms,
53+
:latency_p99_ms,
54+
:bandwidth_in_bytes,
55+
:bandwidth_out_bytes,
56+
:status_2xx,
57+
:status_3xx,
58+
:status_4xx,
59+
:status_5xx
60+
])
61+
|> validate_required([:service_id, :project_id, :period, :period_start])
62+
|> validate_inclusion(:period, @periods)
63+
|> unique_constraint([:service_id, :period, :period_start])
64+
|> foreign_key_constraint(:service_id)
65+
|> foreign_key_constraint(:project_id)
66+
end
67+
68+
def periods, do: @periods
69+
end

0 commit comments

Comments
 (0)