Skip to content

Commit

Permalink
Implement user-bound oidc providers (#1372)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeljguarino authored Oct 7, 2024
1 parent 6a735f7 commit a26cd68
Show file tree
Hide file tree
Showing 16 changed files with 445 additions and 29 deletions.
11 changes: 11 additions & 0 deletions apps/core/lib/core/policies/oauth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Core.Policies.OAuth do
use Piazza.Policy
alias Core.Schema.{User, OIDCProvider}

def can?(%User{id: id}, %OIDCProvider{owner_id: id}, _), do: :pass

def can?(user, %Ecto.Changeset{} = cs, action),
do: can?(user, apply_changes(cs), action)

def can?(_, _, _), do: {:error, :forbidden}
end
16 changes: 14 additions & 2 deletions apps/core/lib/core/schema/oidc_provider.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
defmodule Core.Schema.OIDCProvider do
use Piazza.Ecto.Schema
alias Core.Schema.{Installation, OIDCProviderBinding, Invite}
alias Core.Schema.{Installation, OIDCProviderBinding, Invite, User}

defenum AuthMethod, post: 0, basic: 1

schema "oidc_providers" do
field :name, :string
field :description, :string
field :client_id, :string
field :client_secret, :string
field :redirect_uris, {:array, :string}
Expand All @@ -14,6 +16,7 @@ defmodule Core.Schema.OIDCProvider do
field :login, :map, virtual: true

belongs_to :installation, Installation
belongs_to :owner, User

has_many :invites, Invite, foreign_key: :oidc_provider_id
has_many :bindings, OIDCProviderBinding,
Expand All @@ -23,7 +26,15 @@ defmodule Core.Schema.OIDCProvider do
timestamps()
end

@valid ~w(client_id client_secret installation_id redirect_uris auth_method)a
def for_owner(query \\ __MODULE__, owner_id) do
from(p in query, where: p.owner_id == ^owner_id)
end

def ordered(query \\ __MODULE__, order \\ [asc: :name]) do
from(p in query, order_by: ^order)
end

@valid ~w(name description client_id client_secret owner_id installation_id redirect_uris auth_method)a

def changeset(model, attrs \\ %{}) do
model
Expand All @@ -32,5 +43,6 @@ defmodule Core.Schema.OIDCProvider do
|> unique_constraint(:installation_id)
|> unique_constraint(:client_id)
|> foreign_key_constraint(:installation_id)
|> foreign_key_constraint(:owner_id)
end
end
12 changes: 12 additions & 0 deletions apps/core/lib/core/services/base.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
defmodule Core.Services.Base do
alias Core.Schema.User

defmacro __using__(_) do
quote do
import Core.Services.Base
alias Core.Repo

defp conf(key),
do: Application.get_env(:core, __MODULE__)[key]
end
end

def find_bindings(%User{service_account: true} = user) do
case Core.Repo.preload(user, impersonation_policy: :bindings) do
%{impersonation_policy: %{bindings: [_ | _] = bindings}} ->
Enum.map(bindings, &Map.take(&1, [:group_id, :user_id]))
_ -> []
end
end
def find_bindings(_), do: []

def ok(val), do: {:ok, val}

def error(val), do: {:error, val}
Expand Down
93 changes: 93 additions & 0 deletions apps/core/lib/core/services/oauth.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,98 @@
defmodule Core.Services.OAuth do
use Core.Services.Base
import Core.Policies.OAuth
alias Core.PubSub
alias Core.Schema.{User, OIDCProvider, OIDCLogin}
alias Core.Clients.Hydra
alias Core.Services.{Repositories, Audits}
require Logger

@type error :: {:error, term}
@type oauth_resp :: {:ok, %Hydra.Response{}} | error
@type oidc_resp :: {:ok, OidcProvider.t} | error

@oidc_scopes "profile code openid offline_access offline"
@grant_types ~w(authorization_code refresh_token client_credentials)

def get_provider(id), do: Repo.get(OIDCProvider, id)

def get_provider!(id), do: Repo.get!(OIDCProvider, id)

@doc """
Creates a new oidc provider for a given installation, enabling a log-in with plural experience
"""
@spec create_oidc_provider(map, User.t) :: oidc_resp
def create_oidc_provider(attrs, %User{id: id} = user) do
start_transaction()
|> add_operation(:client, fn _ ->
Map.take(attrs, [:redirect_uris])
|> Map.put(:scope, @oidc_scopes)
|> Map.put(:grant_types, @grant_types)
|> Map.put(:token_endpoint_auth_method, oidc_auth_method(attrs.auth_method))
|> Hydra.create_client()
end)
|> add_operation(:oidc_provider, fn
%{client: %{client_id: cid, client_secret: secret}} ->
attrs = Map.merge(attrs, %{client_id: cid, client_secret: secret})
|> add_bindings(find_bindings(user))
%OIDCProvider{owner_id: id}
|> OIDCProvider.changeset(attrs)
|> allow(user, :create)
|> when_ok(:insert)
end)
|> execute(extract: :oidc_provider)
|> notify(:create)
end

defp add_bindings(attrs, bindings) do
bindings = Enum.uniq_by((attrs[:bindings] || []) ++ bindings, & {&1[:group_id], &1[:user_id]})
Map.put(attrs, :bindings, bindings)
end

defp oidc_auth_method(:basic), do: "client_secret_basic"
defp oidc_auth_method(:post), do: "client_secret_post"

@doc """
Updates the spec of an installation's oidc provider
"""
@spec update_oidc_provider(map, binary, User.t) :: oidc_resp
def update_oidc_provider(attrs, id, %User{} = user) do
start_transaction()
|> add_operation(:oidc, fn _ ->
get_provider!(id)
|> Repo.preload([:bindings])
|> OIDCProvider.changeset(attrs)
|> allow(user, :edit)
|> when_ok(:update)
end)
|> add_operation(:client, fn
%{oidc: %{client_id: id, auth_method: auth_method}} ->
attrs = Map.take(attrs, [:redirect_uris])
|> Map.put(:scope, @oidc_scopes)
|> Map.put(:token_endpoint_auth_method, oidc_auth_method(auth_method))
Hydra.update_client(id, attrs)
end)
|> execute(extract: :oidc)
|> notify(:update)
end

@doc """
Deletes an oidc provider and its hydra counterpart
"""
@spec delete_oidc_provider(binary, User.t) :: oidc_resp
def delete_oidc_provider(id, %User{} = user) do
start_transaction()
|> add_operation(:oidc, fn _ ->
get_provider!(id)
|> allow(user, :edit)
|> when_ok(:delete)
end)
|> add_operation(:client, fn %{oidc: %{client_id: id}} ->
with :ok <- Hydra.delete_client(id),
do: {:ok, nil}
end)
|> execute(extract: :oidc)
end

@doc """
Gets the data related to a specific login
Expand Down Expand Up @@ -85,4 +171,11 @@ defmodule Core.Services.OAuth do
{:error, :failure}
end
end

defp notify({:ok, %OIDCProvider{} = oidc}, :create),
do: handle_notify(PubSub.OIDCProviderCreated, oidc)
defp notify({:ok, %OIDCProvider{} = oidc}, :update),
do: handle_notify(PubSub.OIDCProviderUpdated, oidc)

defp notify(pass, _), do: pass
end
9 changes: 0 additions & 9 deletions apps/core/lib/core/services/repositories.ex
Original file line number Diff line number Diff line change
Expand Up @@ -575,15 +575,6 @@ defmodule Core.Services.Repositories do
Map.put(attrs, :bindings, bindings)
end

defp find_bindings(%User{service_account: true} = user) do
case Core.Repo.preload(user, impersonation_policy: :bindings) do
%{impersonation_policy: %{bindings: [_ | _] = bindings}} ->
Enum.map(bindings, &Map.take(&1, [:group_id, :user_id]))
_ -> []
end
end
defp find_bindings(_), do: []

@doc """
Inserts or updates the oidc provider for an installation
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Core.Repo.Migrations.AddOwnerIdOidcProvider do
use Ecto.Migration

def change do
alter table(:oidc_providers) do
add :name, :string
add :description, :string
add :owner_id, references(:users, type: :uuid, on_delete: :delete_all)
end
end
end
75 changes: 75 additions & 0 deletions apps/core/test/services/oauth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,81 @@ defmodule Core.Services.OAuthTest do
alias Core.Schema.OIDCLogin
use Mimic

describe "#create_oidc_provider/2" do
test "a user can create an oidc provider" do
account = insert(:account)
group = insert(:group, account: account)
expect(HTTPoison, :post, fn _, _, _ ->
{:ok, %{status_code: 200, body: Jason.encode!(%{client_id: "123", client_secret: "secret"})}}
end)
user = insert(:user)

{:ok, oidc} = OAuth.create_oidc_provider(%{
redirect_uris: ["https://example.com"],
auth_method: :basic,
bindings: [%{user_id: user.id}, %{group_id: group.id}]
}, user)

assert oidc.client_id == "123"
assert oidc.client_secret == "secret"
assert oidc.redirect_uris == ["https://example.com"]
assert oidc.owner_id == user.id

[first, second] = oidc.bindings

assert first.user_id == user.id
assert second.group_id == group.id
end
end

describe "#update_oidc_provider/2" do
test "you can update your own providers" do
user = insert(:user)
oidc = insert(:oidc_provider, owner: user)
expect(HTTPoison, :put, fn _, _, _ ->
{:ok, %{status_code: 200, body: Jason.encode!(%{client_id: "123", client_secret: "secret"})}}
end)

{:ok, updated} = OAuth.update_oidc_provider(%{
redirect_uris: ["https://example.com"],
auth_method: :basic
}, oidc.id, user)

assert updated.id == oidc.id
assert updated.auth_method == :basic
end

test "others cannot update your provider" do
user = insert(:user)
oidc = insert(:oidc_provider, owner: user)

{:error, :forbidden} = OAuth.update_oidc_provider(%{
redirect_uris: ["https://example.com"],
auth_method: :basic
}, oidc.id, insert(:user))
end
end

describe "#delete_oidc_provider/2" do
test "you can delete your own providers" do
user = insert(:user)
oidc = insert(:oidc_provider, owner: user)
expect(HTTPoison, :delete, fn _, _ -> {:ok, %{status_code: 204, body: ""}} end)

{:ok, deleted} = OAuth.delete_oidc_provider(oidc.id, user)

assert deleted.id == oidc.id
refute refetch(deleted)
end

test "others cannot delete your provider" do
user = insert(:user)
oidc = insert(:oidc_provider, owner: user)

{:error, :forbidden} = OAuth.delete_oidc_provider(oidc.id, insert(:user))
end
end

describe "#get_login/1" do
test "It can get information related to an oauth login" do
provider = insert(:oidc_provider)
Expand Down
8 changes: 7 additions & 1 deletion apps/graphql/lib/graphql/resolvers/oauth.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule GraphQl.Resolvers.OAuth do
use GraphQl.Resolvers.Base, model: Core.Schema.OIDCProvider
alias Core.Schema.OIDCLogin
alias Core.Schema.{OIDCLogin, OIDCProvider}
alias Core.Services.{OAuth, Users}
alias Core.OAuth, as: OAuthHandler
alias GraphQl.Resolvers.User
Expand All @@ -11,6 +11,12 @@ defmodule GraphQl.Resolvers.OAuth do
|> paginate(args)
end

def list_oidc_providers(args, %{context: %{current_user: user}}) do
OIDCProvider.for_owner(user.id)
|> OIDCProvider.ordered()
|> paginate(args)
end

def login_metrics(_, %{context: %{current_user: user}}) do
cutoff = Timex.now() |> Timex.shift(months: -1)
OIDCLogin.for_account(user.account_id)
Expand Down
18 changes: 13 additions & 5 deletions apps/graphql/lib/graphql/resolvers/repository.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule GraphQl.Resolvers.Repository do
use GraphQl.Resolvers.Base, model: Core.Schema.Repository
alias Core.Services.{Repositories, Users}
alias Core.Services.{Repositories, Users, OAuth}
alias Core.Schema.{
Installation,
Integration,
Expand Down Expand Up @@ -205,15 +205,23 @@ defmodule GraphQl.Resolvers.Repository do
def reset_installations(_, %{context: %{current_user: user}}),
do: Repositories.reset_installations(user)

def create_oidc_provider(%{attributes: attrs, installation_id: id}, %{context: %{current_user: user}}),
do: Repositories.create_oidc_provider(attrs, id, user)
def create_oidc_provider(%{attributes: attrs, installation_id: id}, %{context: %{current_user: user}})
when is_binary(id), do: Repositories.create_oidc_provider(attrs, id, user)
def create_oidc_provider(%{attributes: attrs}, %{context: %{current_user: user}}),
do: OAuth.create_oidc_provider(attrs, user)

def update_oidc_provider(%{attributes: attrs, installation_id: id}, %{context: %{current_user: user}}),
do: Repositories.update_oidc_provider(attrs, id, user)
def update_oidc_provider(%{attributes: attrs, installation_id: id}, %{context: %{current_user: user}})
when is_binary(id), do: Repositories.update_oidc_provider(attrs, id, user)
def update_oidc_provider(%{attributes: attrs, id: id}, %{context: %{current_user: user}})
when is_binary(id), do: OAuth.update_oidc_provider(attrs, id, user)
def update_oidc_provider(_, _), do: {:error, "you must provide either id or installation id"}

def upsert_oidc_provider(%{attributes: attrs, installation_id: id}, %{context: %{current_user: user}}),
do: Repositories.upsert_oidc_provider(attrs, id, user)

def delete_oidc_provider(%{id: id}, %{context: %{current_user: user}})
when is_binary(id), do: OAuth.delete_oidc_provider(id, user)

def create_artifact(%{repository_id: repo_id, attributes: attrs}, %{context: %{current_user: user}}),
do: Repositories.create_artifact(attrs, repo_id, user)
def create_artifact(%{repository_name: name, attributes: attrs}, %{context: %{current_user: user}}) do
Expand Down
6 changes: 6 additions & 0 deletions apps/graphql/lib/graphql/schema/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ defmodule GraphQl.Schema.OAuth do

resolve &OAuth.login_metrics/2
end

connection field :oidc_providers, node_type: :oidc_provider do
middleware Authenticated

safe_resolve &OAuth.list_oidc_providers/2
end
end

object :oauth_mutations do
Expand Down
Loading

0 comments on commit a26cd68

Please sign in to comment.