Skip to content

Commit 5308ada

Browse files
feat(rbac): saml jit provisioning (#119)
## 📝 Description I have updated our saml login procedure. Now, if the user does not exist already, and the organization has JIT provisioning enabled within it's SAML integration, the user will be created on the spot. renderedtext/tasks#7566 ## ✅ Checklist - [x] I have tested this change - [x] This change requires documentation update
1 parent dc8fea9 commit 5308ada

File tree

21 files changed

+454
-52
lines changed

21 files changed

+454
-52
lines changed

ee/rbac/lib/internal_api/rbac.pb.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ defmodule InternalApi.RBAC.RoleBindingSource do
2929
field(:ROLE_BINDING_SOURCE_GITLAB, 4)
3030
field(:ROLE_BINDING_SOURCE_SCIM, 5)
3131
field(:ROLE_BINDING_SOURCE_INHERITED_FROM_ORG_ROLE, 6)
32+
field(:ROLE_BINDING_SOURCE_SAML_JIT, 7)
3233
end
3334

3435
defmodule InternalApi.RBAC.ListUserPermissionsRequest do

ee/rbac/lib/rbac/front_repo/user.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ defmodule Rbac.FrontRepo.User do
3737
field(:remember_created_at, :utc_datetime)
3838
field(:visited_at, :utc_datetime)
3939

40-
field(:creation_source, Ecto.Enum, values: [:okta])
40+
field(:creation_source, Ecto.Enum, values: [:okta, :saml_jit])
4141
field(:single_org_user, :boolean)
4242
field(:org_id, :binary_id)
4343
field(:idempotency_token, :string)

ee/rbac/lib/rbac/grpc_servers/rbac_server.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ defmodule Rbac.GrpcServers.RbacServer do
6969
%RBAC.AssignRoleResponse{}
7070

7171
{:error, error_msg} ->
72-
IO.puts("Error assigning role: #{error_msg}")
72+
Logger.error("Error assigning role: #{error_msg}")
7373
grpc_error!(:failed_precondition, error_msg)
7474
end
7575
end)
@@ -408,6 +408,7 @@ defmodule Rbac.GrpcServers.RbacServer do
408408
"github" -> :ROLE_BINDING_SOURCE_GITHUB
409409
"bitbucket" -> :ROLE_BINDING_SOURCE_BITBUCKET
410410
"okta" -> :ROLE_BINDING_SOURCE_SCIM
411+
"saml_jit" -> :ROLE_BINDING_SOURCE_SAML_JIT
411412
"inherited_from_org_role" -> :ROLE_BINDING_SOURCE_INHERITED_FROM_ORG_ROLE
412413
_ -> :ROLE_BINDING_SOURCE_UNSPECIFIED
413414
end

ee/rbac/lib/rbac/okta/saml/api.ex

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ defmodule Rbac.Okta.Saml.Api do
9999
# Okta Authentication
100100
#
101101

102-
post "/okta/auth" do
102+
post("/okta/auth", do: handle_auth(conn))
103+
104+
defp handle_auth(conn) do
103105
alias Rbac.Okta.Integration
104106
alias Rbac.Okta.Saml.PayloadParser
105107

@@ -116,26 +118,42 @@ defmodule Rbac.Okta.Saml.Api do
116118
consume_uri = "https://#{org_username}.#{domain()}/okta/auth"
117119
metadata_uri = "https://#{org_username}.#{domain()}"
118120

119-
with {:ok, integration} <- Integration.find_by_org_id(org_id),
120-
{:ok, email} <- PayloadParser.parse(integration, params, consume_uri, metadata_uri),
121-
{:ok, okta_user} <- find_okta_user(integration, email),
122-
{:ok, user} <- find_user(okta_user),
121+
{:ok, integration} = Integration.find_by_org_id(org_id)
122+
{:ok, email, attributes} = PayloadParser.parse(integration, params, consume_uri, metadata_uri)
123+
124+
with {:ok, scim_saml_user} <- find_scim_or_saml_user(integration, email),
125+
{:ok, user} <- find_user(scim_saml_user),
123126
{:ok, user} <- FrontRepo.User.set_remember_timestamp(user) do
124-
Watchman.increment("okta_login.success")
127+
Watchman.increment("saml_login.success")
125128

126129
Logger.info(
127-
"[Okta] User create a session user_id: #{user.id} integration_id: #{integration.id}"
130+
"[SAML] User create a session user_id: #{user.id} integration_id: #{integration.id}"
128131
)
129132

130133
conn
131134
|> inject_session_cookie(user)
132135
|> redirect(user)
133136
else
137+
{:error, :scim_saml_user, :not_found} = e ->
138+
if integration.jit_provisioning_enabled do
139+
{:ok, saml_jit_user} = Rbac.Repo.SamlJitUser.create(integration, email, attributes)
140+
:ok = Rbac.Okta.Saml.JitProvisioner.AddUser.run(saml_jit_user)
141+
142+
conn
143+
|> put_resp_content_type("text/plain")
144+
|> send_resp(200, "User provisioning, try again in a minute")
145+
else
146+
raise e
147+
end
148+
134149
e ->
135-
Watchman.increment("okta_login.failure")
136-
Logger.error("Okta auth failed #{inspect(e)}")
137-
render_not_found(conn)
150+
raise e
138151
end
152+
rescue
153+
e ->
154+
Watchman.increment("saml_login.failure")
155+
Logger.error("SAML auth failed #{inspect(e)}")
156+
render_not_found(conn)
139157
end
140158

141159
defp inject_session_cookie(conn, user) do
@@ -166,16 +184,18 @@ defmodule Rbac.Okta.Saml.Api do
166184
conn |> put_resp_content_type("text/plain") |> send_resp(404, "Not found")
167185
end
168186

169-
defp find_okta_user(integration, email) do
170-
case Rbac.Repo.OktaUser.find_by_email(integration, email) do
187+
defp find_scim_or_saml_user(integration, email) do
188+
with {:error, :not_found} <- Rbac.Repo.OktaUser.find_by_email(integration, email),
189+
{:error, :not_found} <- Rbac.Repo.SamlJitUser.find_by_email(integration, email) do
190+
{:error, :scim_saml_user, :not_found}
191+
else
171192
{:ok, user} -> {:ok, user}
172-
{:error, :not_found} -> {:error, :okta_user, :not_found}
173193
end
174194
end
175195

176-
defp find_user(okta_user) do
177-
if okta_user.user_id do
178-
case FrontRepo.User.active_user_by_id(okta_user.user_id) do
196+
defp find_user(saml_scim_user) do
197+
if saml_scim_user.user_id do
198+
case FrontRepo.User.active_user_by_id(saml_scim_user.user_id) do
179199
{:ok, user} -> {:ok, user}
180200
{:error, :not_found} -> {:error, :user, :not_found}
181201
end
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# credo:disable-for-this-file
2+
defmodule Rbac.Okta.Saml.JitProvisioner.AddUser do
3+
@moduledoc """
4+
Procedure for creating a Semaphore user based on the received
5+
okta user payload.
6+
7+
The assumed state of the system:
8+
- We have an okta_user record in the database
9+
- The okta_user.state is :pending
10+
- The okta_user.user_id is nil
11+
"""
12+
13+
require Logger
14+
15+
alias Rbac.Repo.{SamlJitUser, RbacRole}
16+
alias Rbac.RoleBindingIdentification
17+
alias Rbac.RoleManagement
18+
19+
@role_name "Member"
20+
21+
def run(saml_jit_user) do
22+
idempotency_token = "okta-user-#{saml_jit_user.id}"
23+
email = saml_jit_user.email
24+
name = SamlJitUser.construct_name(saml_jit_user)
25+
26+
user_params = %{
27+
email: email,
28+
name: name,
29+
idempotency_token: idempotency_token,
30+
creation_source: :saml_jit,
31+
single_org_user: true,
32+
org_id: saml_jit_user.org_id
33+
}
34+
35+
Logger.info("Provisioning #{saml_jit_user.id}")
36+
37+
with(
38+
{:ok, user} <- find_or_create_user(idempotency_token, user_params),
39+
{:ok, saml_jit_user} <- SamlJitUser.connect_user(saml_jit_user, user.id),
40+
:ok <- assign_role(user.id, saml_jit_user.org_id, @role_name),
41+
{:ok, _saml_jit_user} <- SamlJitUser.mark_as_processed(saml_jit_user)
42+
) do
43+
Logger.info("Provisioning #{saml_jit_user.id} done.")
44+
45+
:ok
46+
else
47+
err ->
48+
log_provisioning_error(saml_jit_user, err)
49+
err
50+
end
51+
end
52+
53+
defp find_or_create_user(idempotency_token, user_params) do
54+
case find_user_by_idempotency_token(idempotency_token) do
55+
nil ->
56+
case Rbac.Store.RbacUser.fetch_by_email(user_params.email) do
57+
{:error, :not_found} ->
58+
Logger.info("[Sam lJIT Provisioner] Creating new user #{inspect(user_params)}")
59+
Rbac.User.Actions.create(user_params)
60+
61+
{:ok, user} ->
62+
Logger.info(
63+
"[Saml JIT Provisioner] Adding idempotency token to existing user #{inspect(user_params)}"
64+
)
65+
66+
Rbac.Store.User.Front.add_idempotency_token(user.id, idempotency_token)
67+
{:ok, user}
68+
end
69+
70+
user ->
71+
{:ok, user}
72+
end
73+
end
74+
75+
defp find_user_by_idempotency_token(idempotency_token) do
76+
case Rbac.Store.User.Front.find_by_idempotency_token(idempotency_token) do
77+
{:error, :not_found} ->
78+
nil
79+
80+
{:ok, user} ->
81+
Rbac.Store.RbacUser.fetch(user.id)
82+
end
83+
end
84+
85+
defp assign_role(user_id, org_id, role_name) do
86+
if RoleManagement.user_part_of_org?(user_id, org_id) do
87+
:ok
88+
else
89+
with {:ok, rbi} <- RoleBindingIdentification.new(user_id: user_id, org_id: org_id),
90+
{:ok, role} <- RbacRole.get_role_by_name(role_name, "org_scope", org_id) do
91+
{:ok, nil} = RoleManagement.assign_role(rbi, role.id, :saml_jit)
92+
93+
Rbac.Events.UserJoinedOrganization.publish(user_id, org_id)
94+
:ok
95+
end
96+
end
97+
end
98+
99+
defp log_provisioning_error(okta_user, err) do
100+
inspects = "okta user: #{inspect(okta_user)} error: #{inspect(err)}"
101+
102+
Logger.error("SamlJIT Provisioner: Failed to provision #{inspects}")
103+
end
104+
end

ee/rbac/lib/rbac/okta/saml/payload_parser.ex

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ defmodule Rbac.Okta.Saml.PayloadParser do
1414
{:ok, payload} <- extract_saml_response(params),
1515
{:ok, decoded} <- decode_payload(payload),
1616
{:ok, assertion} <- validate_assertion(decoded, sp) do
17-
subject = esaml_assertion(assertion, :subject)
18-
email = esaml_subject(subject, :name)
17+
email = esaml_assertion(assertion, :subject) |> esaml_subject(:name)
18+
attributes = esaml_assertion(assertion, :attributes) |> construct_attributes_map()
1919

20-
{:ok, to_string(email)}
20+
{:ok, to_string(email), attributes}
2121
end
2222
end
2323

@@ -42,6 +42,13 @@ defmodule Rbac.Okta.Saml.PayloadParser do
4242
{:error, :invalid_xml, e}
4343
end
4444

45+
defp construct_attributes_map(attributes) do
46+
Enum.reduce(attributes, %{}, fn {name, value}, acc ->
47+
value = to_string(value)
48+
Map.update(acc, name, [value], &(&1 ++ [value]))
49+
end)
50+
end
51+
4552
defp validate_assertion(saml, sp) do
4653
:esaml_sp.validate_assertion(saml, sp)
4754
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
defmodule Rbac.Repo.SamlJitUser do
2+
use Rbac.Repo.Schema
3+
alias Rbac.Repo
4+
import Ecto.Query, only: [where: 3]
5+
6+
@timestamps_opts [type: :utc_datetime]
7+
8+
@required_fields [
9+
:org_id,
10+
:integration_id,
11+
:attributes,
12+
:state,
13+
:email
14+
]
15+
16+
@updatable_fields [
17+
:attributes,
18+
:state,
19+
:email,
20+
:updated_at,
21+
:user_id
22+
]
23+
24+
schema "saml_jit_users" do
25+
belongs_to(:integration, Repo.OktaIntegration)
26+
27+
field(:org_id, :binary_id)
28+
field(:attributes, :map)
29+
field(:state, Ecto.Enum, values: [:pending, :processed])
30+
field(:user_id, :binary_id)
31+
field(:email, :string)
32+
33+
timestamps()
34+
end
35+
36+
def create(integration, email, attributes) do
37+
new(integration, email, attributes)
38+
|> changeset()
39+
|> Rbac.Repo.insert()
40+
end
41+
42+
def find_by_email(integration, email) do
43+
__MODULE__
44+
|> where([u], u.integration_id == ^integration.id and u.email == ^email)
45+
|> Repo.one()
46+
|> case do
47+
nil -> {:error, :not_found}
48+
user -> {:ok, user}
49+
end
50+
end
51+
52+
def connect_user(%__MODULE__{} = user, user_id) do
53+
changeset(user, %{user_id: user_id}) |> Repo.update()
54+
end
55+
56+
def construct_name(%__MODULE__{} = user) do
57+
name = extract_attribute(user, "firstName") <> " " <> extract_attribute(user, "lastName")
58+
59+
if String.trim(name) == "" do
60+
user.email |> String.split("@") |> List.first()
61+
else
62+
name
63+
end
64+
end
65+
66+
def mark_as_processed(%__MODULE__{} = user) do
67+
changeset(user, %{state: :processed}) |> Rbac.Repo.update()
68+
end
69+
70+
defp new(integration, email, attributes) do
71+
%__MODULE__{
72+
integration_id: integration.id,
73+
org_id: integration.org_id,
74+
attributes: attributes,
75+
email: email,
76+
state: :pending
77+
}
78+
end
79+
80+
defp changeset(%__MODULE__{} = user, params \\ %{}) do
81+
user
82+
|> cast(params, @updatable_fields)
83+
|> validate_required(@required_fields)
84+
end
85+
86+
defp extract_attribute(%__MODULE__{} = user, name) do
87+
Map.get(user.attributes, name, [""]) |> List.first()
88+
end
89+
end

ee/rbac/lib/rbac/repo/subject_role_binding.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Rbac.Repo.SubjectRoleBinding do
33
alias Rbac.Repo.{RbacRole, Subject}
44
import Ecto.Query, only: [where: 3]
55

6-
@binding_sources ~w(github bitbucket gitlab manually_assigned okta inherited_from_org_role)a
6+
@binding_sources ~w(github bitbucket gitlab manually_assigned okta inherited_from_org_role saml_jit)a
77

88
schema "subject_role_bindings" do
99
belongs_to(:role, RbacRole)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule Rbac.Repo.Migrations.CreateSamlJitUsers do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table("saml_jit_users") do
6+
add(:integration_id, :binary_id, null: false)
7+
add(:org_id, :binary_id, null: false)
8+
add(:attributes, :jsonb, null: false)
9+
add(:state, :string, null: false)
10+
add(:user_id, :uuid)
11+
add(:email, :string)
12+
13+
timestamps()
14+
end
15+
16+
create(unique_index(:saml_jit_users, [:integration_id, :email]))
17+
end
18+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
defmodule Rbac.Repo.Migrations.AddSamlJitToRoleBindingEnum do
2+
use Ecto.Migration
3+
4+
###
5+
### This query efectively adds one more value to enum type
6+
###
7+
### There is a dedicated ALTER TYPE _ ADD VALUE command, but it can not be executed
8+
### Within the transaction, and ecto executes commands inside the transaction, so it can't
9+
### be used here, and we have this long transaction to achive the same thing
10+
###
11+
@sql_commands_for_altering_enum_type [
12+
"ALTER TYPE role_binding_scope RENAME TO deprecated;",
13+
"CREATE TYPE role_binding_scope AS ENUM ('github', 'bitbucket', 'gitlab', 'manually_assigned', 'okta', 'inherited_from_org_role', 'saml_jit');",
14+
"ALTER TABLE subject_role_bindings
15+
ALTER COLUMN binding_source TYPE role_binding_scope USING binding_source::text::role_binding_scope;",
16+
"DROP TYPE deprecated;"
17+
]
18+
19+
def change do
20+
Enum.each(@sql_commands_for_altering_enum_type, &execute/1)
21+
end
22+
end

0 commit comments

Comments
 (0)