Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement user-bound oidc providers #1372

Merged
merged 1 commit into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading