Skip to content

Commit

Permalink
SAML runtime operations (#2999)
Browse files Browse the repository at this point in the history
* Add certificates settings in settings sti table

* Add new /api/public_keys route to get uploaded keys

* Add release task to initialize saml

* Add saml runtime options

* Update variable name

* Rename certs settings to add SSO prefix to make it obvious

* Add tests to the new certificates code

* Add saml release task tests
  • Loading branch information
arbulu89 authored Sep 23, 2024
1 parent e05cdf8 commit d4a6a31
Show file tree
Hide file tree
Showing 13 changed files with 384 additions and 8 deletions.
60 changes: 58 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,12 @@ if config_env() in [:prod, :demo] do

enable_oidc = System.get_env("ENABLE_OIDC", "false") == "true"
enable_oauth2 = System.get_env("ENABLE_OAUTH2", "false") == "true"
enable_saml = System.get_env("ENABLE_SAML", "false") == "true"

if enable_oauth2 and enable_oidc do
raise("Cannot start Trento with OIDC and OAUTH2 integrations both enabled.")
if Enum.count([enable_oidc, enable_oauth2, enable_saml], fn enabled -> enabled end) > 1 do
raise(
"Cannot start Trento with multiple SSO options enabled. Use one among: OIDC, OAUTH2 and SAML."
)
end

config :trento, :oidc,
Expand Down Expand Up @@ -247,4 +250,57 @@ if config_env() in [:prod, :demo] do
]
]
end

if enable_saml do
saml_dir = System.get_env("SAML_SP_DIR", "/etc/trento/trento-web/saml")

config :trento, :saml,
enabled: true,
callback_url: "/auth/saml_callback",
idp_id:
System.get_env("SAML_IDP_ID") ||
raise("environment variable SAML_IDP_ID is missing")

config :trento, :pow_assent,
providers: [
saml_local: [
strategy: TrentoWeb.Auth.AssentSamlStrategy
]
]

config :samly, Samly.Provider,
idp_id_from: :path_segment,
service_providers: [
%{
id:
System.get_env("SAML_SP_ID") ||
raise("environment variable SAML_SP_ID is missing"),
entity_id: System.get_env("SAML_SP_ENTITY_ID", ""),
certfile: Path.join([saml_dir, "cert", "saml.pem"]),
keyfile: Path.join([saml_dir, "cert", "saml_key.pem"]),
contact_name: System.get_env("SAML_SP_CONTACT_NAME", "Trento SP Admin"),
contact_email: System.get_env("SAML_SP_CONTACT_EMAIL", "[email protected]"),
org_name: System.get_env("SAML_SP_ORG_NAME", "Trento SP"),
org_displayname: System.get_env("SAML_SP_ORG_DISPLAYNAME", "SAML SP build with Trento"),
org_url: System.get_env("SAML_SP_ORG_URL", "https://www.trento-project.io/")
}
],
identity_providers: [
%{
id: System.get_env("SAML_IDP_ID"),
sp_id: System.get_env("SAML_SP_ID"),
base_url: "https://#{System.get_env("TRENTO_WEB_ORIGIN")}/sso",
metadata_file: Path.join([saml_dir, "metadata.xml"]),
sign_requests: System.get_env("SAML_SIGN_REQUESTS", "true") == "true",
sign_metadata: System.get_env("SAML_SIGN_METADATA", "true") == "true",
signed_assertion_in_resp: System.get_env("SAML_SIGNED_ASSERTION", "true") == "true",
signed_envelopes_in_resp: System.get_env("SAML_SIGNED_ENVELOPES", "true") == "true",
nameid_format:
System.get_env(
"SAML_IDP_NAMEID_FORMAT",
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
)
}
]
end
end
105 changes: 105 additions & 0 deletions lib/trento/release.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ defmodule Trento.Release do
installed.
"""

require Logger

alias Mix.Tasks.Phx.Gen.Cert

alias Trento.ActivityLog.Settings, as: ActivityLogSettings
alias Trento.Settings.ApiKeySettings
alias Trento.Settings.SSOCertificatesSettings

@app :trento

@saml_certificate_name "Trento SAML SP"

def init do
migrate()
init_event_store()
migrate_event_store()
init_admin_user()
init_default_api_key()
init_default_activity_log_retention_time()
maybe_init_saml(System.get_env("ENABLE_SAML", "false") == "true")
end

def migrate do
Expand Down Expand Up @@ -123,11 +131,108 @@ defmodule Trento.Release do
)
end

def maybe_init_saml(false), do: :ok

def maybe_init_saml(true) do
load_app()
Enum.each([:postgrex, :ecto, :httpoison], &Application.ensure_all_started/1)
Trento.Repo.start_link()
Trento.Vault.start_link()

trento_origin =
System.get_env("TRENTO_WEB_ORIGIN") ||
raise """
environment variable TRENTO_WEB_ORIGIN is missing.
For example: yourdomain.example.com
"""

saml_dir = System.get_env("SAML_SP_DIR", "/etc/trento/trento-web/saml")

certificates_settings =
Trento.Repo.one(SSOCertificatesSettings.base_query())

{key_file, cert_file} =
case certificates_settings do
nil ->
{key, cert} =
create_certificates_content(
@saml_certificate_name,
[trento_origin]
)

%SSOCertificatesSettings{}
|> SSOCertificatesSettings.changeset(%{
name: @saml_certificate_name,
key_file: key,
certificate_file: cert
})
|> Trento.Repo.insert!()

{key, cert}

%SSOCertificatesSettings{key_file: key, certificate_file: cert} ->
{key, cert}
end

File.mkdir_p!(Path.join([saml_dir, "cert"]))
File.write!(Path.join([saml_dir, "cert", "saml_key.pem"]), key_file)
File.write!(Path.join([saml_dir, "cert", "saml.pem"]), cert_file)

Logger.info(IO.ANSI.green() <> "Created certificate content:\n\n#{cert_file}")

# Create metadata.xml file
case get_saml_metadata_file(System.get_env()) do
{:ok, content} ->
File.write!(Path.join([saml_dir, "metadata.xml"]), content)

{:error, :request_failure} ->
raise "Error querying the provided SAML_METADATA_URL endpoint"

{:error, :metadata_is_missing} ->
raise "One of SAML_METADATA_URL or SAML_METADATA_CONTENT must be provided"
end

:ok
end

defp repos do
Application.fetch_env!(@app, :ecto_repos)
end

defp load_app do
Application.load(@app)
end

defp create_certificates_content(name, hostnames) do
{certificate, private_key} = Cert.certificate_and_key(2048, name, hostnames)

keyfile_content =
:public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)])

certfile_content = :public_key.pem_encode([{:Certificate, certificate, :not_encrypted}])

{keyfile_content, certfile_content}
end

defp get_saml_metadata_file(%{"SAML_METADATA_URL" => metadata_url}) when metadata_url != "" do
case HTTPoison.get(metadata_url) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, body}

{:ok, _} ->
{:error, :request_failure}

{:error, _} ->
{:error, :request_failure}
end
end

defp get_saml_metadata_file(%{"SAML_METADATA_CONTENT" => metadata_content})
when metadata_content != "" do
{:ok, metadata_content}
end

defp get_saml_metadata_file(_) do
{:error, :metadata_is_missing}
end
end
8 changes: 8 additions & 0 deletions lib/trento/settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule Trento.Settings do
alias Trento.Settings.{
ApiKeySettings,
InstallationSettings,
SSOCertificatesSettings,
SuseManagerSettings
}

Expand Down Expand Up @@ -144,6 +145,13 @@ defmodule Trento.Settings do
:ok
end

# Certificates settings

@spec get_sso_certificates() :: [SSOCertificatesSettings.t()]
def get_sso_certificates do
Repo.one(SSOCertificatesSettings.base_query())
end

defp ensure_no_suse_manager_settings_configured do
case Repo.one(SuseManagerSettings.base_query()) do
nil ->
Expand Down
36 changes: 36 additions & 0 deletions lib/trento/settings/sso_certificates_settings.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule Trento.Settings.SSOCertificatesSettings do
@moduledoc """
SSOCertificatesSettings is the STI projection containing SSL certificates
"""

use Ecto.Schema
use Trento.Support.Ecto.STI, sti_identifier: :sso_certificates_settings

import Ecto.Changeset

alias Trento.Support.Ecto.EncryptedBinary

@type t :: %__MODULE__{}

@derive {Jason.Encoder, except: [:__meta__, :__struct__]}
@primary_key {:id, :binary_id, autogenerate: true}
schema "settings" do
field :name, :string, source: :sso_certificates_settings_name
field :key_file, EncryptedBinary, source: :sso_certificates_settings_key_file
field :certificate_file, EncryptedBinary, source: :sso_certificates_settings_certificate_file

timestamps(type: :utc_datetime_usec)
sti_fields()
end

@spec changeset(t() | Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
def changeset(certificates_settings, attrs) do
certificates_settings
|> cast(attrs, __MODULE__.__schema__(:fields))
|> validate_required([:name, :key_file, :certificate_file])
# TODO: move suse_manager_settings.ex certificates function to some support module
# |> validate_cert_and_key
|> sti_changes()
|> unique_constraint(:type)
end
end
14 changes: 14 additions & 0 deletions lib/trento_web/controllers/v1/settings_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,20 @@ defmodule TrentoWeb.V1.SettingsController do
end
end

operation :get_public_keys,
summary: "Get uploaded public keys",
tags: ["Platform"],
description: "Get uploaded public keys",
responses: [
ok: {"Uploaded public keys", "application/json", Schema.Platform.PublicKeys}
]

@spec get_public_keys(Plug.Conn.t(), any) :: Plug.Conn.t()
def get_public_keys(conn, _) do
certificates = Settings.get_sso_certificates()
render(conn, "public_keys.json", %{public_keys: [certificates]})
end

def get_policy_resource(conn) do
case Phoenix.Controller.action_name(conn) do
:update_api_key_settings -> Trento.Settings.ApiKeySettings
Expand Down
21 changes: 21 additions & 0 deletions lib/trento_web/openapi/v1/schema/platform.ex
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,25 @@ defmodule TrentoWeb.OpenApi.V1.Schema.Platform do
struct?: false
)
end

defmodule PublicKeys do
@moduledoc false

OpenApiSpex.schema(
%{
title: "PublicKeys",
description: "Uploaded public keys",
type: :array,
items: %Schema{
title: "PublicKey",
type: :object,
properties: %{
name: %Schema{type: :string, description: "Name"},
content: %Schema{type: :string, description: "Public key content"}
}
}
},
struct?: false
)
end
end
5 changes: 5 additions & 0 deletions lib/trento_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ defmodule TrentoWeb.Router do
get "/readyz", HealthController, :ready
end

scope "/api", TrentoWeb.V1 do
pipe_through [:api, :api_v1]
get "/public_keys", SettingsController, :get_public_keys
end

scope "/api" do
pipe_through [:api, :protected_api]

Expand Down
8 changes: 8 additions & 0 deletions lib/trento_web/views/v1/settings_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,12 @@ defmodule TrentoWeb.V1.SettingsView do
ca_uploaded_at: ca_uploaded_at
}
end

def render("public_keys.json", %{public_keys: public_keys}) do
render_many(public_keys, __MODULE__, "public_key.json", as: :public_key)
end

def render("public_key.json", %{public_key: %{name: name, certificate_file: cert_file}}) do
%{name: name, content: cert_file}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Trento.Repo.Migrations.AddSSOCertificatesSettingsSti do
use Ecto.Migration

def change do
alter table(:settings) do
add :sso_certificates_settings_name, :string
add :sso_certificates_settings_key_file, :binary
add :sso_certificates_settings_certificate_file, :binary
end

create unique_index(:settings, [:sso_certificates_settings_name])
end
end
15 changes: 12 additions & 3 deletions test/support/factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ defmodule Trento.Factory do
DiscoveryEvent
}

alias Trento.Settings.SuseManagerSettings

alias Trento.Settings.{
ApiKeySettings,
InstallationSettings
InstallationSettings,
SSOCertificatesSettings,
SuseManagerSettings
}

alias Trento.ActivityLog.ActivityLog, as: ActivityLogEntry
Expand Down Expand Up @@ -967,6 +967,15 @@ defmodule Trento.Factory do
}
end

def sso_certificates_settings_factory do
%SSOCertificatesSettings{
type: :sso_certificates_settings,
name: Faker.StarWars.planet(),
certificate_file: Faker.Lorem.paragraph(),
key_file: Faker.Lorem.paragraph()
}
end

def insert_software_updates_settings(attrs \\ []) do
insert(
:software_updates_settings,
Expand Down
Loading

0 comments on commit d4a6a31

Please sign in to comment.