From 58973ea609cac8380f3e2a2f65b7bd61ae1d0311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 10:30:55 +0000 Subject: [PATCH 01/16] feat(front): add service accounts service layer --- front/config/config.exs | 1 + front/config/prod.exs | 1 + front/lib/front/application.ex | 31 +- front/lib/front/clients/service_account.ex | 181 +++++++++++ front/lib/front/models/service_account.ex | 50 +++ front/lib/front/service_account.ex | 52 +++ .../controllers/service_account_controller.ex | 140 +++++++++ front/lib/front_web/router.ex | 9 + .../front_web/views/service_account_view.ex | 38 +++ front/lib/internal_api/audit.pb.ex | 1 + front/lib/internal_api/billing.pb.ex | 32 ++ .../internal_api/plumber_w_f.workflow.pb.ex | 10 +- front/lib/internal_api/projecthub.pb.ex | 1 + front/lib/internal_api/rbac.pb.ex | 1 + front/lib/internal_api/service_account.pb.ex | 297 ++++++++++++++++++ front/lib/internal_api/user.pb.ex | 1 + front/mix.exs | 1 + front/mix.lock | 2 + front/scripts/internal_protos.sh | 4 +- .../support/fake_clients/service_account.ex | 207 ++++++++++++ front/test/test_helper.exs | 3 + 21 files changed, 1053 insertions(+), 10 deletions(-) create mode 100644 front/lib/front/clients/service_account.ex create mode 100644 front/lib/front/models/service_account.ex create mode 100644 front/lib/front/service_account.ex create mode 100644 front/lib/front_web/controllers/service_account_controller.ex create mode 100644 front/lib/front_web/views/service_account_view.ex create mode 100644 front/lib/internal_api/service_account.pb.ex create mode 100644 front/test/support/fake_clients/service_account.ex diff --git a/front/config/config.exs b/front/config/config.exs index f7a942db9..bebb86b5a 100644 --- a/front/config/config.exs +++ b/front/config/config.exs @@ -32,6 +32,7 @@ config :front, default_user_name: "Semaphore User" config :feature_provider, provider: {Front.FeatureHubProvider, []} config :front, :superjerry_client, {Support.FakeClients.Superjerry, []} config :front, :scouter_client, {Front.Clients.Scouter, []} +config :front, :service_account_client, {Support.FakeClients.ServiceAccount, []} config :money, default_currency: :USD, diff --git a/front/config/prod.exs b/front/config/prod.exs index 64596a63e..a84ce86c4 100644 --- a/front/config/prod.exs +++ b/front/config/prod.exs @@ -8,6 +8,7 @@ config :front, FrontWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" config :front, :superjerry_client, {Front.Clients.Superjerry, []} +config :front, :service_account_client, {Front.Clients.ServiceAccount, []} config :front, guard_grpc_timeout: 15_000 config :front, permission_patrol_timeout: 15_000 config :front, me_host: "me.", me_path: "/" diff --git a/front/lib/front/application.ex b/front/lib/front/application.ex index c4f9f5fbb..fed23a012 100644 --- a/front/lib/front/application.ex +++ b/front/lib/front/application.ex @@ -16,14 +16,21 @@ defmodule Front.Application do {&:logger_filters.domain/2, {:stop, :equal, [:progress]}} ) + base_children = [ + {Phoenix.PubSub, [name: Front.PubSub, adapter: Phoenix.PubSub.PG2]}, + FrontWeb.Endpoint, + {Task.Supervisor, [name: Front.TaskSupervisor]}, + Front.Tracing.Store, + Front.FeatureProviderInvalidatorWorker + ] + children = - [ - {Phoenix.PubSub, [name: Front.PubSub, adapter: Phoenix.PubSub.PG2]}, - FrontWeb.Endpoint, - {Task.Supervisor, [name: Front.TaskSupervisor]}, - Front.Tracing.Store, - Front.FeatureProviderInvalidatorWorker - ] ++ reactor() ++ cache() ++ telemetry() ++ feature_provider(provider) + base_children ++ + reactor() ++ + cache() ++ + telemetry() ++ + feature_provider(provider) ++ + clients() opts = [strategy: :one_for_one, name: Front.Supervisor] @@ -93,6 +100,16 @@ defmodule Front.Application do end end + def clients do + if Application.get_env(:front, :environment) in [:dev, :test] do + [ + Application.get_env(:front, :service_account_client) + ] + else + [] + end + end + def config_change(changed, _new, removed) do FrontWeb.Endpoint.config_change(changed, removed) :ok diff --git a/front/lib/front/clients/service_account.ex b/front/lib/front/clients/service_account.ex new file mode 100644 index 000000000..1a5c17d75 --- /dev/null +++ b/front/lib/front/clients/service_account.ex @@ -0,0 +1,181 @@ +defmodule Front.Clients.ServiceAccount do + require Logger + + alias InternalApi.ServiceAccount.{ + CreateRequest, + DeleteRequest, + DescribeRequest, + ListRequest, + RegenerateTokenRequest, + UpdateRequest + } + + alias Front.Models.ServiceAccount + + @behaviour Front.ServiceAccount.Behaviour + + @impl Front.ServiceAccount.Behaviour + def create(org_id, name, description, creator_id) do + %CreateRequest{ + org_id: org_id, + name: name, + description: description, + creator_id: creator_id + } + |> grpc_call(:create) + |> case do + {:ok, result} -> + service_account = ServiceAccount.from_proto(result.service_account) + {:ok, {service_account, result.api_token}} + + err -> + Logger.error("Error creating service account for org #{org_id}: #{inspect(err)}") + handle_error(err) + end + end + + @impl Front.ServiceAccount.Behaviour + def list(org_id, page_size, page_token) do + %ListRequest{ + org_id: org_id, + page_size: page_size, + page_token: page_token || "" + } + |> grpc_call(:list) + |> case do + {:ok, result} -> + service_accounts = Enum.map(result.service_accounts, &ServiceAccount.from_proto/1) + next_page_token = if result.next_page_token == "", do: nil, else: result.next_page_token + {:ok, {service_accounts, next_page_token}} + + err -> + Logger.error("Error listing service accounts for org #{org_id}: #{inspect(err)}") + handle_error(err) + end + end + + @impl Front.ServiceAccount.Behaviour + def describe(service_account_id) do + %DescribeRequest{ + service_account_id: service_account_id + } + |> grpc_call(:describe) + |> case do + {:ok, result} -> + service_account = ServiceAccount.from_proto(result.service_account) + {:ok, service_account} + + err -> + Logger.error("Error describing service account #{service_account_id}: #{inspect(err)}") + handle_error(err) + end + end + + @impl Front.ServiceAccount.Behaviour + def update(service_account_id, name, description) do + %UpdateRequest{ + service_account_id: service_account_id, + name: name, + description: description + } + |> grpc_call(:update) + |> case do + {:ok, result} -> + service_account = ServiceAccount.from_proto(result.service_account) + {:ok, service_account} + + err -> + Logger.error("Error updating service account #{service_account_id}: #{inspect(err)}") + handle_error(err) + end + end + + @impl Front.ServiceAccount.Behaviour + def delete(service_account_id) do + %DeleteRequest{ + service_account_id: service_account_id + } + |> grpc_call(:delete) + |> case do + {:ok, _result} -> + :ok + + err -> + Logger.error("Error deleting service account #{service_account_id}: #{inspect(err)}") + handle_error(err) + end + end + + @impl Front.ServiceAccount.Behaviour + def regenerate_token(service_account_id) do + %RegenerateTokenRequest{ + service_account_id: service_account_id + } + |> grpc_call(:regenerate_token) + |> case do + {:ok, result} -> + {:ok, result.api_token} + + err -> + Logger.error( + "Error regenerating token for service account #{service_account_id}: #{inspect(err)}" + ) + + handle_error(err) + end + end + + defp grpc_call(request, action) do + Watchman.benchmark("service_account.#{action}.duration", fn -> + channel() + |> call_grpc( + InternalApi.ServiceAccount.ServiceAccountService.Stub, + action, + request, + metadata(), + timeout() + ) + |> tap(fn + {:ok, _} -> Watchman.increment("service_account.#{action}.success") + {:error, _} -> Watchman.increment("service_account.#{action}.failure") + end) + end) + end + + defp call_grpc(error = {:error, err}, _, _, _, _, _) do + Logger.error(""" + Unexpected error when connecting to ServiceAccount: #{inspect(err)} + """) + + error + end + + defp call_grpc({:ok, channel}, module, function_name, request, metadata, timeout) do + apply(module, function_name, [channel, request, [metadata: metadata, timeout: timeout]]) + end + + defp channel do + Application.fetch_env!(:front, :service_account_grpc_endpoint) + |> GRPC.Stub.connect() + end + + defp timeout do + 15_000 + end + + defp metadata do + nil + end + + defp handle_error({:error, %GRPC.RPCError{status: status, message: message}}) do + if status == GRPC.Status.internal() do + {:error, "Unknown error, if this persists, please contact support."} + else + {:error, message} + end + end + + defp handle_error({:error, _}) do + {:error, "Unknown error, if this persists, please contact support."} + end +end diff --git a/front/lib/front/models/service_account.ex b/front/lib/front/models/service_account.ex new file mode 100644 index 000000000..036536a67 --- /dev/null +++ b/front/lib/front/models/service_account.ex @@ -0,0 +1,50 @@ +defmodule Front.Models.ServiceAccount do + @moduledoc """ + Model representing a Service Account + """ + + defstruct [ + :id, + :name, + :description, + :org_id, + :creator_id, + :created_at, + :updated_at, + :deactivated + ] + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + description: String.t(), + org_id: String.t(), + creator_id: String.t(), + created_at: DateTime.t(), + updated_at: DateTime.t(), + deactivated: boolean() + } + + @doc """ + Creates a new ServiceAccount struct from protobuf data + """ + @spec from_proto(InternalApi.ServiceAccount.t()) :: t + def from_proto(proto) do + %__MODULE__{ + id: proto.id, + name: proto.name, + description: proto.description, + org_id: proto.org_id, + creator_id: proto.creator_id, + created_at: timestamp_to_datetime(proto.created_at), + updated_at: timestamp_to_datetime(proto.updated_at), + deactivated: proto.deactivated + } + end + + defp timestamp_to_datetime(%Google.Protobuf.Timestamp{seconds: seconds}) do + DateTime.from_unix!(seconds) + end + + defp timestamp_to_datetime(_), do: DateTime.utc_now() +end diff --git a/front/lib/front/service_account.ex b/front/lib/front/service_account.ex new file mode 100644 index 000000000..44c2f8308 --- /dev/null +++ b/front/lib/front/service_account.ex @@ -0,0 +1,52 @@ +defmodule Front.ServiceAccount do + defmodule Behaviour do + @callback create( + org_id :: String.t(), + name :: String.t(), + description :: String.t(), + creator_id :: String.t() + ) :: {:ok, {Front.Models.ServiceAccount.t(), String.t()}} | {:error, any} + + @callback list( + org_id :: String.t(), + page_size :: integer(), + page_token :: String.t() | nil + ) :: {:ok, {[Front.Models.ServiceAccount.t()], String.t() | nil}} | {:error, any} + + @callback describe(service_account_id :: String.t()) :: + {:ok, Front.Models.ServiceAccount.t()} | {:error, any} + + @callback update( + service_account_id :: String.t(), + name :: String.t(), + description :: String.t() + ) :: {:ok, Front.Models.ServiceAccount.t()} | {:error, any} + + @callback delete(service_account_id :: String.t()) :: :ok | {:error, any} + + @callback regenerate_token(service_account_id :: String.t()) :: + {:ok, String.t()} | {:error, any} + end + + def create(org_id, name, description, creator_id), + do: service_account_impl().create(org_id, name, description, creator_id) + + def list(org_id, page_size, page_token \\ nil), + do: service_account_impl().list(org_id, page_size, page_token) + + def describe(service_account_id), do: service_account_impl().describe(service_account_id) + + def update(service_account_id, name, description), + do: service_account_impl().update(service_account_id, name, description) + + def delete(service_account_id), do: service_account_impl().delete(service_account_id) + + def regenerate_token(service_account_id), + do: service_account_impl().regenerate_token(service_account_id) + + defp service_account_impl do + {client, _client_opts} = Application.fetch_env!(:front, :service_account_client) + + client + end +end diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex new file mode 100644 index 000000000..0cc4d6231 --- /dev/null +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -0,0 +1,140 @@ +defmodule FrontWeb.ServiceAccountController do + use FrontWeb, :controller + require Logger + + alias Front.{Audit, ServiceAccount} + alias FrontWeb.Plugs + + plug(Plugs.FetchPermissions) + plug(Plugs.PageAccess, permission: "service_accounts.view") + + plug( + Plugs.PageAccess, + [permission: "service_accounts.manage"] + when action in [:create, :update, :delete, :regenerate_token] + ) + + def index(conn, params) do + org_id = conn.assigns.organization_id + page_size = String.to_integer(params["page_size"] || "20") + page_token = params["page_token"] + + case ServiceAccount.list(org_id, page_size, page_token) do + {:ok, {service_accounts, next_page_token}} -> + conn + |> put_resp_header("x-next-page-token", next_page_token || "") + |> render("index.json", service_accounts: service_accounts) + + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{error: message}) + end + end + + def create(conn, params) do + org_id = conn.assigns.organization_id + user_id = conn.assigns.user_id + name = params["name"] || "" + description = params["description"] || "" + + case ServiceAccount.create(org_id, name, description, user_id) do + {:ok, {service_account, api_token}} -> + conn + |> Audit.new(:ServiceAccount, :Added) + |> Audit.add(resource_id: service_account.id) + |> Audit.add(resource_name: service_account.name) + |> Audit.add(description: "Service account created") + |> Audit.metadata(organization_id: org_id) + |> Audit.metadata(user_id: user_id) + |> Audit.log() + + conn + |> put_status(:created) + |> render("show.json", service_account: service_account, api_token: api_token) + + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{error: message}) + end + end + + def show(conn, %{"id" => id}) do + case ServiceAccount.describe(id) do + {:ok, service_account} -> + render(conn, "show.json", service_account: service_account) + + {:error, _message} -> + conn + |> put_status(:not_found) + |> json(%{error: "Service account not found"}) + end + end + + def update(conn, %{"id" => id} = params) do + name = params["name"] || "" + description = params["description"] || "" + + case ServiceAccount.update(id, name, description) do + {:ok, service_account} -> + conn + |> Audit.new(:ServiceAccount, :Modified) + |> Audit.add(resource_id: service_account.id) + |> Audit.add(resource_name: service_account.name) + |> Audit.add(description: "Service account updated") + |> Audit.metadata(organization_id: conn.assigns.organization_id) + |> Audit.metadata(user_id: conn.assigns.user_id) + |> Audit.log() + + render(conn, "show.json", service_account: service_account) + + {:error, _message} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to update service account"}) + end + end + + def delete(conn, %{"id" => id}) do + with {:ok, existing} <- ServiceAccount.describe(id), + :ok <- ServiceAccount.delete(id) do + conn + |> Audit.new(:ServiceAccount, :Removed) + |> Audit.add(resource_id: id) + |> Audit.add(resource_name: existing.name) + |> Audit.add(description: "Service account deleted") + |> Audit.metadata(organization_id: conn.assigns.organization_id) + |> Audit.metadata(user_id: conn.assigns.user_id) + |> Audit.log() + + send_resp(conn, :no_content, "") + else + {:error, _message} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to delete service account"}) + end + end + + def regenerate_token(conn, %{"id" => id}) do + with {:ok, existing} <- ServiceAccount.describe(id), + {:ok, api_token} <- ServiceAccount.regenerate_token(id) do + conn + |> Audit.new(:ServiceAccount, :Rebuild) + |> Audit.add(resource_id: id) + |> Audit.add(resource_name: existing.name) + |> Audit.add(description: "Service account token regenerated") + |> Audit.metadata(organization_id: conn.assigns.organization_id) + |> Audit.metadata(user_id: conn.assigns.user_id) + |> Audit.log() + + json(conn, %{api_token: api_token}) + else + {:error, _message} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to regenerate token"}) + end + end +end diff --git a/front/lib/front_web/router.ex b/front/lib/front_web/router.ex index 29d1bcae8..557624466 100644 --- a/front/lib/front_web/router.ex +++ b/front/lib/front_web/router.ex @@ -170,6 +170,15 @@ defmodule FrontWeb.Router do get("/offboarding/:user_id", OffboardingController, :show) end + scope "/service_accounts" do + get("/", ServiceAccountController, :index) + post("/", ServiceAccountController, :create) + get("/:id", ServiceAccountController, :show) + put("/:id", ServiceAccountController, :update) + delete("/:id", ServiceAccountController, :delete) + post("/:id/regenerate_token", ServiceAccountController, :regenerate_token) + end + post("/project/:name_or_id/offboarding", OffboardingController, :transfer) delete("/project/:name_or_id/offboarding", OffboardingController, :remove) diff --git a/front/lib/front_web/views/service_account_view.ex b/front/lib/front_web/views/service_account_view.ex new file mode 100644 index 000000000..66950247e --- /dev/null +++ b/front/lib/front_web/views/service_account_view.ex @@ -0,0 +1,38 @@ +defmodule FrontWeb.ServiceAccountView do + use FrontWeb, :view + + alias Front.Models.ServiceAccount + + def render("index.json", %{service_accounts: service_accounts}) do + %{ + service_accounts: Enum.map(service_accounts, &service_account_json/1) + } + end + + def render("show.json", %{service_account: %ServiceAccount{} = service_account} = assigns) do + data = service_account_json(service_account) + + # Only include api_token if it's present (on create/regenerate) + case Map.get(assigns, :api_token) do + nil -> data + token -> Map.put(data, :api_token, token) + end + end + + defp service_account_json(%ServiceAccount{} = service_account) do + %{ + id: service_account.id, + name: service_account.name, + description: service_account.description, + created_at: format_datetime(service_account.created_at), + updated_at: format_datetime(service_account.updated_at), + deactivated: service_account.deactivated + } + end + + defp format_datetime(nil), do: nil + + defp format_datetime(%DateTime{} = datetime) do + DateTime.to_iso8601(datetime) + end +end diff --git a/front/lib/internal_api/audit.pb.ex b/front/lib/internal_api/audit.pb.ex index 20f03239a..5ea53d435 100644 --- a/front/lib/internal_api/audit.pb.ex +++ b/front/lib/internal_api/audit.pb.ex @@ -415,6 +415,7 @@ defmodule InternalApi.Audit.Event.Resource do field(:Okta, 17) field(:FlakyTests, 18) field(:RBACRole, 19) + field(:ServiceAccount, 20) end defmodule InternalApi.Audit.Event.Operation do diff --git a/front/lib/internal_api/billing.pb.ex b/front/lib/internal_api/billing.pb.ex index af0625fc7..61e8e3e55 100644 --- a/front/lib/internal_api/billing.pb.ex +++ b/front/lib/internal_api/billing.pb.ex @@ -908,6 +908,38 @@ defmodule InternalApi.Billing.NoteChanged do field(:timestamp, 3, type: Google.Protobuf.Timestamp) end +defmodule InternalApi.Billing.BudgetAlert do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + org_id: String.t(), + spending_amount: String.t(), + spending_limit: String.t(), + email: String.t(), + percentage_threshold: integer, + from_date: Google.Protobuf.Timestamp.t(), + to_date: Google.Protobuf.Timestamp.t() + } + defstruct [ + :org_id, + :spending_amount, + :spending_limit, + :email, + :percentage_threshold, + :from_date, + :to_date + ] + + field(:org_id, 1, type: :string) + field(:spending_amount, 2, type: :string) + field(:spending_limit, 3, type: :string) + field(:email, 4, type: :string) + field(:percentage_threshold, 5, type: :int32) + field(:from_date, 6, type: Google.Protobuf.Timestamp) + field(:to_date, 7, type: Google.Protobuf.Timestamp) +end + defmodule InternalApi.Billing.TrialOwnerOnboarded do @moduledoc false use Protobuf, syntax: :proto3 diff --git a/front/lib/internal_api/plumber_w_f.workflow.pb.ex b/front/lib/internal_api/plumber_w_f.workflow.pb.ex index 97d0606a8..10e53ae68 100644 --- a/front/lib/internal_api/plumber_w_f.workflow.pb.ex +++ b/front/lib/internal_api/plumber_w_f.workflow.pb.ex @@ -16,7 +16,9 @@ defmodule InternalApi.PlumberWF.ScheduleRequest do label: String.t(), triggered_by: integer, scheduler_task_id: String.t(), - env_vars: [InternalApi.PlumberWF.ScheduleRequest.EnvVar.t()] + env_vars: [InternalApi.PlumberWF.ScheduleRequest.EnvVar.t()], + start_in_conceived_state: boolean, + git_reference: String.t() } defstruct [ :service, @@ -32,7 +34,9 @@ defmodule InternalApi.PlumberWF.ScheduleRequest do :label, :triggered_by, :scheduler_task_id, - :env_vars + :env_vars, + :start_in_conceived_state, + :git_reference ] field(:service, 2, type: InternalApi.PlumberWF.ScheduleRequest.ServiceType, enum: true) @@ -49,6 +53,8 @@ defmodule InternalApi.PlumberWF.ScheduleRequest do field(:triggered_by, 15, type: InternalApi.PlumberWF.TriggeredBy, enum: true) field(:scheduler_task_id, 16, type: :string) field(:env_vars, 17, repeated: true, type: InternalApi.PlumberWF.ScheduleRequest.EnvVar) + field(:start_in_conceived_state, 18, type: :bool) + field(:git_reference, 19, type: :string) end defmodule InternalApi.PlumberWF.ScheduleRequest.Repo do diff --git a/front/lib/internal_api/projecthub.pb.ex b/front/lib/internal_api/projecthub.pb.ex index 097c4b7d7..8cb1ba837 100644 --- a/front/lib/internal_api/projecthub.pb.ex +++ b/front/lib/internal_api/projecthub.pb.ex @@ -332,6 +332,7 @@ defmodule InternalApi.Projecthub.Project.Spec.Repository.RunType do field(:TAGS, 1) field(:PULL_REQUESTS, 2) field(:FORKED_PULL_REQUESTS, 3) + field(:DRAFT_PULL_REQUESTS, 4) end defmodule InternalApi.Projecthub.Project.Spec.Scheduler do diff --git a/front/lib/internal_api/rbac.pb.ex b/front/lib/internal_api/rbac.pb.ex index 29fb21c76..5e1c7a6bf 100644 --- a/front/lib/internal_api/rbac.pb.ex +++ b/front/lib/internal_api/rbac.pb.ex @@ -514,6 +514,7 @@ defmodule InternalApi.RBAC.SubjectType do field(:USER, 0) field(:GROUP, 1) + field(:SERVICE_ACCOUNT, 2) end defmodule InternalApi.RBAC.Scope do diff --git a/front/lib/internal_api/service_account.pb.ex b/front/lib/internal_api/service_account.pb.ex new file mode 100644 index 000000000..71f7a6872 --- /dev/null +++ b/front/lib/internal_api/service_account.pb.ex @@ -0,0 +1,297 @@ +defmodule InternalApi.ServiceAccount.CreateRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + org_id: String.t(), + name: String.t(), + description: String.t(), + creator_id: String.t() + } + defstruct [:org_id, :name, :description, :creator_id] + + field(:org_id, 1, type: :string) + field(:name, 2, type: :string) + field(:description, 3, type: :string) + field(:creator_id, 4, type: :string) +end + +defmodule InternalApi.ServiceAccount.CreateResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account: InternalApi.ServiceAccount.ServiceAccount.t(), + api_token: String.t() + } + defstruct [:service_account, :api_token] + + field(:service_account, 1, type: InternalApi.ServiceAccount.ServiceAccount) + field(:api_token, 2, type: :string) +end + +defmodule InternalApi.ServiceAccount.ListRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + org_id: String.t(), + page_size: integer, + page_token: String.t() + } + defstruct [:org_id, :page_size, :page_token] + + field(:org_id, 1, type: :string) + field(:page_size, 2, type: :int32) + field(:page_token, 3, type: :string) +end + +defmodule InternalApi.ServiceAccount.ListResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_accounts: [InternalApi.ServiceAccount.ServiceAccount.t()], + next_page_token: String.t() + } + defstruct [:service_accounts, :next_page_token] + + field(:service_accounts, 1, repeated: true, type: InternalApi.ServiceAccount.ServiceAccount) + field(:next_page_token, 2, type: :string) +end + +defmodule InternalApi.ServiceAccount.DescribeRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t() + } + defstruct [:service_account_id] + + field(:service_account_id, 1, type: :string) +end + +defmodule InternalApi.ServiceAccount.DescribeResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account: InternalApi.ServiceAccount.ServiceAccount.t() + } + defstruct [:service_account] + + field(:service_account, 1, type: InternalApi.ServiceAccount.ServiceAccount) +end + +defmodule InternalApi.ServiceAccount.UpdateRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t(), + name: String.t(), + description: String.t() + } + defstruct [:service_account_id, :name, :description] + + field(:service_account_id, 1, type: :string) + field(:name, 2, type: :string) + field(:description, 3, type: :string) +end + +defmodule InternalApi.ServiceAccount.UpdateResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account: InternalApi.ServiceAccount.ServiceAccount.t() + } + defstruct [:service_account] + + field(:service_account, 1, type: InternalApi.ServiceAccount.ServiceAccount) +end + +defmodule InternalApi.ServiceAccount.DeleteRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t() + } + defstruct [:service_account_id] + + field(:service_account_id, 1, type: :string) +end + +defmodule InternalApi.ServiceAccount.DeleteResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + defstruct [] +end + +defmodule InternalApi.ServiceAccount.RegenerateTokenRequest do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t() + } + defstruct [:service_account_id] + + field(:service_account_id, 1, type: :string) +end + +defmodule InternalApi.ServiceAccount.RegenerateTokenResponse do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + api_token: String.t() + } + defstruct [:api_token] + + field(:api_token, 1, type: :string) +end + +defmodule InternalApi.ServiceAccount.ServiceAccount do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + description: String.t(), + org_id: String.t(), + creator_id: String.t(), + created_at: Google.Protobuf.Timestamp.t(), + updated_at: Google.Protobuf.Timestamp.t(), + deactivated: boolean + } + defstruct [ + :id, + :name, + :description, + :org_id, + :creator_id, + :created_at, + :updated_at, + :deactivated + ] + + field(:id, 1, type: :string) + field(:name, 2, type: :string) + field(:description, 3, type: :string) + field(:org_id, 4, type: :string) + field(:creator_id, 5, type: :string) + field(:created_at, 6, type: Google.Protobuf.Timestamp) + field(:updated_at, 7, type: Google.Protobuf.Timestamp) + field(:deactivated, 8, type: :bool) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountCreated do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t(), + org_id: String.t(), + timestamp: Google.Protobuf.Timestamp.t() + } + defstruct [:service_account_id, :org_id, :timestamp] + + field(:service_account_id, 1, type: :string) + field(:org_id, 2, type: :string) + field(:timestamp, 3, type: Google.Protobuf.Timestamp) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountUpdated do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t(), + org_id: String.t(), + timestamp: Google.Protobuf.Timestamp.t() + } + defstruct [:service_account_id, :org_id, :timestamp] + + field(:service_account_id, 1, type: :string) + field(:org_id, 2, type: :string) + field(:timestamp, 3, type: Google.Protobuf.Timestamp) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountDeleted do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t(), + org_id: String.t(), + timestamp: Google.Protobuf.Timestamp.t() + } + defstruct [:service_account_id, :org_id, :timestamp] + + field(:service_account_id, 1, type: :string) + field(:org_id, 2, type: :string) + field(:timestamp, 3, type: Google.Protobuf.Timestamp) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountTokenRegenerated do + @moduledoc false + use Protobuf, syntax: :proto3 + + @type t :: %__MODULE__{ + service_account_id: String.t(), + org_id: String.t(), + timestamp: Google.Protobuf.Timestamp.t() + } + defstruct [:service_account_id, :org_id, :timestamp] + + field(:service_account_id, 1, type: :string) + field(:org_id, 2, type: :string) + field(:timestamp, 3, type: Google.Protobuf.Timestamp) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountService.Service do + @moduledoc false + use GRPC.Service, name: "InternalApi.ServiceAccount.ServiceAccountService" + + rpc( + :Create, + InternalApi.ServiceAccount.CreateRequest, + InternalApi.ServiceAccount.CreateResponse + ) + + rpc(:List, InternalApi.ServiceAccount.ListRequest, InternalApi.ServiceAccount.ListResponse) + + rpc( + :Describe, + InternalApi.ServiceAccount.DescribeRequest, + InternalApi.ServiceAccount.DescribeResponse + ) + + rpc( + :Update, + InternalApi.ServiceAccount.UpdateRequest, + InternalApi.ServiceAccount.UpdateResponse + ) + + rpc( + :Delete, + InternalApi.ServiceAccount.DeleteRequest, + InternalApi.ServiceAccount.DeleteResponse + ) + + rpc( + :RegenerateToken, + InternalApi.ServiceAccount.RegenerateTokenRequest, + InternalApi.ServiceAccount.RegenerateTokenResponse + ) +end + +defmodule InternalApi.ServiceAccount.ServiceAccountService.Stub do + @moduledoc false + use GRPC.Stub, service: InternalApi.ServiceAccount.ServiceAccountService.Service +end diff --git a/front/lib/internal_api/user.pb.ex b/front/lib/internal_api/user.pb.ex index 5fc2ba9a3..0389664df 100644 --- a/front/lib/internal_api/user.pb.ex +++ b/front/lib/internal_api/user.pb.ex @@ -534,6 +534,7 @@ defmodule InternalApi.User.User.CreationSource do field(:NOT_SET, 0) field(:OKTA, 1) + field(:SERVICE_ACCOUNT, 2) end defmodule InternalApi.User.UserCreated do diff --git a/front/mix.exs b/front/mix.exs index 15c88b37d..9474d2f4e 100644 --- a/front/mix.exs +++ b/front/mix.exs @@ -57,6 +57,7 @@ defmodule Front.Mixfile do {:yaml_elixir, "~> 2.4"}, {:junit_formatter, "~> 3.3", only: [:test]}, {:mock, "~> 0.3.8", only: :test}, + {:mox, "~> 1.0", only: :test}, {:util, github: "renderedtext/elixir-util"}, {:typed_struct, "~> 0.1.4"}, {:amqp_client, "~> 3.9.27"}, diff --git a/front/mix.lock b/front/mix.lock index d89b45c44..8fa920c77 100644 --- a/front/mix.lock +++ b/front/mix.lock @@ -45,7 +45,9 @@ "mix_audit": {:hex, :mix_audit, "0.1.4", "35c424173a574436a80ad7f63cf014a7d9ce727de8cd4e7b4138d90b11aec043", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.4.0", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "7a43fee661bcadbad31aa04a86d33a890421c174723814b8a3a7f0e7076936a1"}, "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, "money": {:hex, :money, "1.12.4", "9d9817aa79d1317871f6b006721c264bf1910fb28ba2af50746514f0d7e8ddbe", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "87e4bb907df1da184cb4640569d8df99ee6d88c84ce4f5da03cb2fab8d433eb9"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, diff --git a/front/scripts/internal_protos.sh b/front/scripts/internal_protos.sh index 9bc250b21..7d5981914 100755 --- a/front/scripts/internal_protos.sh +++ b/front/scripts/internal_protos.sh @@ -39,7 +39,9 @@ feature superjerry instance_config usage -scouter' +scouter +license +service_account' for element in $list;do echo "$element" diff --git a/front/test/support/fake_clients/service_account.ex b/front/test/support/fake_clients/service_account.ex new file mode 100644 index 000000000..b54121b7c --- /dev/null +++ b/front/test/support/fake_clients/service_account.ex @@ -0,0 +1,207 @@ +defmodule Support.FakeClients.ServiceAccount do + @moduledoc """ + Fake implementation of the ServiceAccount client for testing purposes. + This module uses an Agent to store service accounts in memory. + It simulates the behaviour of the actual ServiceAccount client. + It provides methods to create, list, describe, update, delete, and regenerate tokens for service accounts. + This is useful for testing and development without needing a real backend. + """ + use Agent + @behaviour Front.ServiceAccount.Behaviour + + alias Front.Models.ServiceAccount + + @doc """ + Starts the fake service account agent with empty state + """ + def start_link(opts \\ []) do + Agent.start_link( + fn -> %{service_accounts: %{}, tokens: %{}} end, + Keyword.put_new(opts, :name, __MODULE__) + ) + end + + @doc """ + Resets the agent state - useful for tests + """ + def reset do + Agent.update(__MODULE__, fn _ -> %{service_accounts: %{}, tokens: %{}} end) + end + + @doc """ + Seeds the agent with some test data - useful for development + """ + def seed(org_id, creator_id) do + Enum.each(1..3, fn i -> + create( + org_id, + "Test Service Account #{i}", + "Description for test service account #{i}", + creator_id + ) + end) + end + + @impl Front.ServiceAccount.Behaviour + def create(org_id, name, description, creator_id) do + cond do + name == "" -> + {:error, "Service account name cannot be empty"} + + String.length(name) > 100 -> + {:error, "Service account name is too long (maximum 100 characters)"} + + true -> + service_account = %ServiceAccount{ + id: Ecto.UUID.generate(), + name: name, + description: description, + org_id: org_id, + creator_id: creator_id, + created_at: DateTime.utc_now(), + updated_at: DateTime.utc_now(), + deactivated: false + } + + api_token = generate_token() + + Agent.update(__MODULE__, fn state -> + state + |> put_in([:service_accounts, service_account.id], service_account) + |> put_in([:tokens, service_account.id], api_token) + end) + + {:ok, {service_account, api_token}} + end + end + + @impl Front.ServiceAccount.Behaviour + def list(org_id, page_size, page_token) do + cond do + page_size < 1 -> + {:error, "Page size must be greater than 0"} + + page_size > 1000 -> + {:error, "Page size too large (maximum 1000)"} + + true -> + service_accounts = + Agent.get(__MODULE__, fn state -> + state.service_accounts + |> Map.values() + |> Enum.filter(&(&1.org_id == org_id)) + |> Enum.sort_by(& &1.created_at, {:desc, DateTime}) + end) + + # Simple pagination implementation + {page_accounts, has_more} = paginate_results(service_accounts, page_size, page_token) + next_page_token = if has_more, do: generate_page_token(page_accounts), else: nil + + {:ok, {page_accounts, next_page_token}} + end + end + + @impl Front.ServiceAccount.Behaviour + def describe(service_account_id) do + case Agent.get(__MODULE__, &get_in(&1, [:service_accounts, service_account_id])) do + nil -> {:error, "Service account not found"} + service_account -> {:ok, service_account} + end + end + + @impl Front.ServiceAccount.Behaviour + def update(service_account_id, name, description) do + cond do + name == "" -> + {:error, "Service account name cannot be empty"} + + String.length(name) > 100 -> + {:error, "Service account name is too long (maximum 100 characters)"} + + !valid_uuid?(service_account_id) -> + {:error, "Invalid service account ID format"} + + true -> + Agent.get_and_update(__MODULE__, fn state -> + case get_in(state, [:service_accounts, service_account_id]) do + nil -> + {{:error, "Service account not found"}, state} + + service_account -> + updated_account = %{ + service_account + | name: name, + description: description, + updated_at: DateTime.utc_now() + } + + new_state = put_in(state, [:service_accounts, service_account_id], updated_account) + + {{:ok, updated_account}, new_state} + end + end) + end + end + + @impl Front.ServiceAccount.Behaviour + def delete(service_account_id) do + Agent.get_and_update(__MODULE__, fn state -> + case get_in(state, [:service_accounts, service_account_id]) do + nil -> + {{:error, "Service account not found"}, state} + + _service_account -> + new_state = + state + |> update_in([:service_accounts], &Map.delete(&1, service_account_id)) + |> update_in([:tokens], &Map.delete(&1, service_account_id)) + + {:ok, new_state} + end + end) + end + + @impl Front.ServiceAccount.Behaviour + def regenerate_token(service_account_id) do + Agent.get_and_update(__MODULE__, fn state -> + case get_in(state, [:service_accounts, service_account_id]) do + nil -> + {{:error, "Service account not found"}, state} + + _service_account -> + new_token = generate_token() + new_state = put_in(state, [:tokens, service_account_id], new_token) + {{:ok, new_token}, new_state} + end + end) + end + + defp generate_token do + "sa_" <> Base.encode64(:crypto.strong_rand_bytes(32), padding: false) + end + + defp valid_uuid?(string) do + case Ecto.UUID.cast(string) do + {:ok, _} -> true + :error -> false + end + end + + defp paginate_results(items, page_size, nil) do + {Enum.take(items, page_size), length(items) > page_size} + end + + defp paginate_results(items, page_size, page_token) do + start_index = + case Enum.find_index(items, &(&1.id == page_token)) do + nil -> 0 + index -> index + 1 + end + + remaining = Enum.drop(items, start_index) + {Enum.take(remaining, page_size), length(remaining) > page_size} + end + + defp generate_page_token([]), do: nil + defp generate_page_token(accounts), do: List.last(accounts).id +end diff --git a/front/test/test_helper.exs b/front/test/test_helper.exs index 486526879..51129bb28 100644 --- a/front/test/test_helper.exs +++ b/front/test/test_helper.exs @@ -15,6 +15,9 @@ Application.put_env(:wallaby, :js_logger, file) Support.Stubs.init() +Mox.defmock(ServiceAccountMock, for: Front.ServiceAccount.Behaviour) +Application.put_env(:front, :service_account_client, ServiceAccountMock) + ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) ExUnit.start(trace: false, capture_log: true) From 5150c9c14454f0c5f1adb43452794c8f64c4db6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 13:07:51 +0200 Subject: [PATCH 02/16] feat(front): add feature flag and permissions to service accounts --- front/lib/front/auth.ex | 2 ++ .../front_web/controllers/service_account_controller.ex | 8 +++++--- front/lib/front_web/plugs/organization_authorization.ex | 7 +++++++ front/lib/front_web/views/service_account_view.ex | 6 +++--- front/test/support/stubs/feature.ex | 3 ++- front/test/support/stubs/permission_patrol.ex | 2 +- front/test/support/stubs/rbac.ex | 4 +++- 7 files changed, 23 insertions(+), 9 deletions(-) diff --git a/front/lib/front/auth.ex b/front/lib/front/auth.ex index f91a258c3..90c56430f 100644 --- a/front/lib/front/auth.ex +++ b/front/lib/front/auth.ex @@ -313,6 +313,8 @@ defmodule Front.Auth do "project.secrets.manage" -> :ManageProjectSecrets "project.deployment_targets.view" -> :ViewDeploymentTargets "project.deployment_targets.manage" -> :ManageDeploymentTargets + "service_accounts.view" -> :ViewServiceAccounts + "service_accounts.manage" -> :ManageServiceAccounts _ -> :unknown end end diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex index 0cc4d6231..c264c5035 100644 --- a/front/lib/front_web/controllers/service_account_controller.ex +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -5,15 +5,17 @@ defmodule FrontWeb.ServiceAccountController do alias Front.{Audit, ServiceAccount} alias FrontWeb.Plugs - plug(Plugs.FetchPermissions) - plug(Plugs.PageAccess, permission: "service_accounts.view") + plug(Plugs.FetchPermissions, scope: "org") + plug(Plugs.PageAccess, permissions: "service_accounts.view") plug( Plugs.PageAccess, - [permission: "service_accounts.manage"] + [permissions: "service_accounts.manage"] when action in [:create, :update, :delete, :regenerate_token] ) + plug(Plugs.FeatureEnabled, [:service_accounts]) + def index(conn, params) do org_id = conn.assigns.organization_id page_size = String.to_integer(params["page_size"] || "20") diff --git a/front/lib/front_web/plugs/organization_authorization.ex b/front/lib/front_web/plugs/organization_authorization.ex index 16197b762..77dcb491c 100644 --- a/front/lib/front_web/plugs/organization_authorization.ex +++ b/front/lib/front_web/plugs/organization_authorization.ex @@ -19,6 +19,7 @@ defmodule FrontWeb.Plugs.OrganizationAuthorization do alias FrontWeb.SelfHostedAgentController, as: SelfHostedAgent alias FrontWeb.SettingsController, as: Settings alias FrontWeb.SupportController, as: Support + alias FrontWeb.ServiceAccountController, as: ServiceAccount alias Front.Auth @@ -119,6 +120,12 @@ defmodule FrontWeb.Plugs.OrganizationAuthorization do defp authorize(Billing, :invoices, conn), do: Auth.private(conn, :ManageBilling) defp authorize(Billing, _, conn), do: Auth.private(conn, :ViewBilling) + defp authorize(ServiceAccount, action, conn) + when action in [:create, :update, :delete, :regenerate_token], + do: Auth.private(conn, :ManageServiceAccounts) + + defp authorize(ServiceAccount, _, conn), do: Auth.private(conn, :ViewServiceAccounts) + defp can?(conn, permission) do user_id = conn.assigns.user_id org_id = conn.assigns.organization_id diff --git a/front/lib/front_web/views/service_account_view.ex b/front/lib/front_web/views/service_account_view.ex index 66950247e..069fdaa7d 100644 --- a/front/lib/front_web/views/service_account_view.ex +++ b/front/lib/front_web/views/service_account_view.ex @@ -9,7 +9,7 @@ defmodule FrontWeb.ServiceAccountView do } end - def render("show.json", %{service_account: %ServiceAccount{} = service_account} = assigns) do + def render("show.json", assigns = %{service_account: service_account = %ServiceAccount{}}) do data = service_account_json(service_account) # Only include api_token if it's present (on create/regenerate) @@ -19,7 +19,7 @@ defmodule FrontWeb.ServiceAccountView do end end - defp service_account_json(%ServiceAccount{} = service_account) do + defp service_account_json(service_account = %ServiceAccount{}) do %{ id: service_account.id, name: service_account.name, @@ -32,7 +32,7 @@ defmodule FrontWeb.ServiceAccountView do defp format_datetime(nil), do: nil - defp format_datetime(%DateTime{} = datetime) do + defp format_datetime(datetime = %DateTime{}) do DateTime.to_iso8601(datetime) end end diff --git a/front/test/support/stubs/feature.ex b/front/test/support/stubs/feature.ex index 60fb23987..475ecf1ed 100644 --- a/front/test/support/stubs/feature.ex +++ b/front/test/support/stubs/feature.ex @@ -138,7 +138,8 @@ defmodule Support.Stubs.Feature do {"open_id_connect_filter", state: :ENABLED, quantity: 1}, {"wf_editor_via_jobs", state: :HIDDEN, quantity: 0}, {"ui_reports", state: :ENABLED, quantity: 1}, - {"ui_partial_ppl_rebuild", state: :ENABLED, quantity: 1} + {"ui_partial_ppl_rebuild", state: :ENABLED, quantity: 1}, + {"service_accounts", state: :ENABLED, quantity: 1} ] end diff --git a/front/test/support/stubs/permission_patrol.ex b/front/test/support/stubs/permission_patrol.ex index b71dbf0d7..198a74a12 100644 --- a/front/test/support/stubs/permission_patrol.ex +++ b/front/test/support/stubs/permission_patrol.ex @@ -1,7 +1,7 @@ defmodule Support.Stubs.PermissionPatrol do alias Support.Stubs.DB - @all_organization_permissions "organization.custom_roles.view,organization.custom_roles.manage,organization.okta.view,organization.okta.manage,organization.contact_support,organization.delete,organization.view,organization.secrets_policy_settings.manage,organization.secrets_policy_settings.view,organization.activity_monitor.view,organization.projects.create,organization.audit_logs.view,organization.audit_logs.manage,organization.people.view,organization.people.invite,organization.people.manage,organization.groups.view,organization.groups.manage,organization.custom_roles.manage,organization.self_hosted_agents.view,organization.self_hosted_agents.manage,organization.general_settings.view,organization.general_settings.manage,organization.secrets.view,organization.secrets.manage,organization.ip_allow_list.view,organization.ip_allow_list.manage,organization.notifications.view,organization.notifications.manage,organization.pre_flight_checks.view,organization.pre_flight_checks.manage,organization.plans_and_billing.view,organization.plans_and_billing.manage,organization.repo_to_role_mappers.manage,organization.dashboards.view,organization.dashboards.manage,organization.instance_git_integration.manage" + @all_organization_permissions "organization.custom_roles.view,organization.custom_roles.manage,organization.okta.view,organization.okta.manage,organization.contact_support,organization.delete,organization.view,organization.secrets_policy_settings.manage,organization.secrets_policy_settings.view,organization.activity_monitor.view,organization.projects.create,organization.audit_logs.view,organization.audit_logs.manage,organization.people.view,organization.people.invite,organization.people.manage,organization.groups.view,organization.groups.manage,organization.custom_roles.manage,organization.self_hosted_agents.view,organization.self_hosted_agents.manage,organization.general_settings.view,organization.general_settings.manage,organization.secrets.view,organization.secrets.manage,organization.ip_allow_list.view,organization.ip_allow_list.manage,organization.notifications.view,organization.notifications.manage,organization.pre_flight_checks.view,organization.pre_flight_checks.manage,organization.plans_and_billing.view,organization.plans_and_billing.manage,organization.repo_to_role_mappers.manage,organization.dashboards.view,organization.dashboards.manage,organization.instance_git_integration.manage,service_accounts.view,service_accounts.manage" @all_project_permissions "project.view,project.delete,project.access.view,project.access.manage,project.debug,project.secrets.view,project.secrets.manage,project.notifications.view,project.notifications.manage,project.insights.view,project.insights.manage,project.artifacts.view,project.artifacts.delete,project.artifacts.view_settings,project.artifacts.modify_settings,project.scheduler.view,project.scheduler.manage,project.scheduler.run_manually,project.general_settings.view,project.general_settings.manage,project.repository_info.view,project.repository_info.manage,project.deployment_targets.view,project.deployment_targets.manage,project.pre_flight_checks.view,project.pre_flight_checks.manage,project.workflow.view,project.workflow.manage,project.job.view,project.job.rerun,project.job.stop,project.job.port_forwarding,project.job.attach" def init do diff --git a/front/test/support/stubs/rbac.ex b/front/test/support/stubs/rbac.ex index e2a366f95..dab95022d 100644 --- a/front/test/support/stubs/rbac.ex +++ b/front/test/support/stubs/rbac.ex @@ -46,7 +46,9 @@ defmodule Support.Stubs.RBAC do "organization.plans_and_billing.manage", "organization.repo_to_role_mappers.manage", "organization.dashboards.view", - "organization.dashboards.manage" + "organization.dashboards.manage", + "service_accounts.view", + "service_accounts.manage" ] @project_permissions [ From 21d5b64673bbd041af3bd74b965a3287b9c36c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 13:12:12 +0200 Subject: [PATCH 03/16] fix(front): pass service account client errors to ui --- .../controllers/service_account_controller.ex | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex index c264c5035..580a1586f 100644 --- a/front/lib/front_web/controllers/service_account_controller.ex +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -29,7 +29,7 @@ defmodule FrontWeb.ServiceAccountController do {:error, message} -> conn - |> put_status(:bad_request) + |> put_status(422) |> json(%{error: message}) end end @@ -57,7 +57,7 @@ defmodule FrontWeb.ServiceAccountController do {:error, message} -> conn - |> put_status(:bad_request) + |> put_status(422) |> json(%{error: message}) end end @@ -67,10 +67,10 @@ defmodule FrontWeb.ServiceAccountController do {:ok, service_account} -> render(conn, "show.json", service_account: service_account) - {:error, _message} -> + {:error, message} -> conn - |> put_status(:not_found) - |> json(%{error: "Service account not found"}) + |> put_status(422) + |> json(%{error: message}) end end @@ -91,10 +91,10 @@ defmodule FrontWeb.ServiceAccountController do render(conn, "show.json", service_account: service_account) - {:error, _message} -> + {:error, message} -> conn - |> put_status(:bad_request) - |> json(%{error: "Failed to update service account"}) + |> put_status(422) + |> json(%{error: message}) end end @@ -112,10 +112,10 @@ defmodule FrontWeb.ServiceAccountController do send_resp(conn, :no_content, "") else - {:error, _message} -> + {:error, message} -> conn - |> put_status(:bad_request) - |> json(%{error: "Failed to delete service account"}) + |> put_status(422) + |> json(%{error: message}) end end @@ -133,10 +133,10 @@ defmodule FrontWeb.ServiceAccountController do json(conn, %{api_token: api_token}) else - {:error, _message} -> + {:error, message} -> conn - |> put_status(:bad_request) - |> json(%{error: "Failed to regenerate token"}) + |> put_status(422) + |> json(%{error: message}) end end end From 9b7e90b9d118bfbca892dc82933e60cbb5ce04d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 13:19:09 +0200 Subject: [PATCH 04/16] fix(front): use proper permission names --- front/lib/front/auth.ex | 10 ++++++++-- .../controllers/service_account_controller.ex | 4 ++-- front/test/support/stubs/permission_patrol.ex | 2 +- front/test/support/stubs/rbac.ex | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/front/lib/front/auth.ex b/front/lib/front/auth.ex index 90c56430f..6a861b64e 100644 --- a/front/lib/front/auth.ex +++ b/front/lib/front/auth.ex @@ -278,6 +278,12 @@ defmodule Front.Auth do :ManageDeploymentTargets -> "project.deployment_targets.manage" + :ViewServiceAccounts -> + "organization.service_accounts.view" + + :ManageServiceAccounts -> + "organization.service_accounts.manage" + _ -> Logger.error( "operation with name id #{inspect(operation)}, which is not supported in mapper" @@ -313,8 +319,8 @@ defmodule Front.Auth do "project.secrets.manage" -> :ManageProjectSecrets "project.deployment_targets.view" -> :ViewDeploymentTargets "project.deployment_targets.manage" -> :ManageDeploymentTargets - "service_accounts.view" -> :ViewServiceAccounts - "service_accounts.manage" -> :ManageServiceAccounts + "organization.service_accounts.view" -> :ViewServiceAccounts + "organization.service_accounts.manage" -> :ManageServiceAccounts _ -> :unknown end end diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex index 580a1586f..b7e87c084 100644 --- a/front/lib/front_web/controllers/service_account_controller.ex +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -6,11 +6,11 @@ defmodule FrontWeb.ServiceAccountController do alias FrontWeb.Plugs plug(Plugs.FetchPermissions, scope: "org") - plug(Plugs.PageAccess, permissions: "service_accounts.view") + plug(Plugs.PageAccess, permissions: "organization.service_accounts.view") plug( Plugs.PageAccess, - [permissions: "service_accounts.manage"] + [permissions: "organization.service_accounts.manage"] when action in [:create, :update, :delete, :regenerate_token] ) diff --git a/front/test/support/stubs/permission_patrol.ex b/front/test/support/stubs/permission_patrol.ex index 198a74a12..619d166fa 100644 --- a/front/test/support/stubs/permission_patrol.ex +++ b/front/test/support/stubs/permission_patrol.ex @@ -1,7 +1,7 @@ defmodule Support.Stubs.PermissionPatrol do alias Support.Stubs.DB - @all_organization_permissions "organization.custom_roles.view,organization.custom_roles.manage,organization.okta.view,organization.okta.manage,organization.contact_support,organization.delete,organization.view,organization.secrets_policy_settings.manage,organization.secrets_policy_settings.view,organization.activity_monitor.view,organization.projects.create,organization.audit_logs.view,organization.audit_logs.manage,organization.people.view,organization.people.invite,organization.people.manage,organization.groups.view,organization.groups.manage,organization.custom_roles.manage,organization.self_hosted_agents.view,organization.self_hosted_agents.manage,organization.general_settings.view,organization.general_settings.manage,organization.secrets.view,organization.secrets.manage,organization.ip_allow_list.view,organization.ip_allow_list.manage,organization.notifications.view,organization.notifications.manage,organization.pre_flight_checks.view,organization.pre_flight_checks.manage,organization.plans_and_billing.view,organization.plans_and_billing.manage,organization.repo_to_role_mappers.manage,organization.dashboards.view,organization.dashboards.manage,organization.instance_git_integration.manage,service_accounts.view,service_accounts.manage" + @all_organization_permissions "organization.custom_roles.view,organization.custom_roles.manage,organization.okta.view,organization.okta.manage,organization.contact_support,organization.delete,organization.view,organization.secrets_policy_settings.manage,organization.secrets_policy_settings.view,organization.activity_monitor.view,organization.projects.create,organization.audit_logs.view,organization.audit_logs.manage,organization.people.view,organization.people.invite,organization.people.manage,organization.groups.view,organization.groups.manage,organization.custom_roles.manage,organization.self_hosted_agents.view,organization.self_hosted_agents.manage,organization.general_settings.view,organization.general_settings.manage,organization.secrets.view,organization.secrets.manage,organization.ip_allow_list.view,organization.ip_allow_list.manage,organization.notifications.view,organization.notifications.manage,organization.pre_flight_checks.view,organization.pre_flight_checks.manage,organization.plans_and_billing.view,organization.plans_and_billing.manage,organization.repo_to_role_mappers.manage,organization.dashboards.view,organization.dashboards.manage,organization.instance_git_integration.manage,organization.service_accounts.view,organization.service_accounts.manage" @all_project_permissions "project.view,project.delete,project.access.view,project.access.manage,project.debug,project.secrets.view,project.secrets.manage,project.notifications.view,project.notifications.manage,project.insights.view,project.insights.manage,project.artifacts.view,project.artifacts.delete,project.artifacts.view_settings,project.artifacts.modify_settings,project.scheduler.view,project.scheduler.manage,project.scheduler.run_manually,project.general_settings.view,project.general_settings.manage,project.repository_info.view,project.repository_info.manage,project.deployment_targets.view,project.deployment_targets.manage,project.pre_flight_checks.view,project.pre_flight_checks.manage,project.workflow.view,project.workflow.manage,project.job.view,project.job.rerun,project.job.stop,project.job.port_forwarding,project.job.attach" def init do diff --git a/front/test/support/stubs/rbac.ex b/front/test/support/stubs/rbac.ex index dab95022d..9ef4d1158 100644 --- a/front/test/support/stubs/rbac.ex +++ b/front/test/support/stubs/rbac.ex @@ -47,8 +47,8 @@ defmodule Support.Stubs.RBAC do "organization.repo_to_role_mappers.manage", "organization.dashboards.view", "organization.dashboards.manage", - "service_accounts.view", - "service_accounts.manage" + "organization.service_accounts.view", + "organization.service_accounts.manage" ] @project_permissions [ From f72d35ce965ef9b78237d31c61e7c030a9a09bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 11:45:28 +0000 Subject: [PATCH 05/16] toil(front): add service account controller tests --- .../controllers/service_account_controller.ex | 2 +- .../service_account_controller_test.exs | 360 ++++++++++++++++++ front/test/test_helper.exs | 2 +- 3 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 front/test/front_web/controllers/service_account_controller_test.exs diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex index b7e87c084..d3e89d255 100644 --- a/front/lib/front_web/controllers/service_account_controller.ex +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -74,7 +74,7 @@ defmodule FrontWeb.ServiceAccountController do end end - def update(conn, %{"id" => id} = params) do + def update(conn, params = %{"id" => id}) do name = params["name"] || "" description = params["description"] || "" diff --git a/front/test/front_web/controllers/service_account_controller_test.exs b/front/test/front_web/controllers/service_account_controller_test.exs new file mode 100644 index 000000000..38afea54d --- /dev/null +++ b/front/test/front_web/controllers/service_account_controller_test.exs @@ -0,0 +1,360 @@ +defmodule FrontWeb.ServiceAccountControllerTest do + use FrontWeb.ConnCase + import Mox + alias Front.Models.ServiceAccount + alias Support.Stubs.DB + + setup :verify_on_exit! + + setup %{conn: conn} do + Cacheman.clear(:front) + Support.Stubs.init() + Support.Stubs.build_shared_factories() + + user_id = DB.first(:users) |> Map.get(:id) + org_id = DB.first(:organizations) |> Map.get(:id) + + Support.Stubs.PermissionPatrol.remove_all_permissions() + + # Set up base permissions + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.view", + "organization.service_accounts.view" + ]) + + conn = + conn + |> put_req_header("x-semaphore-org-id", org_id) + |> put_req_header("x-semaphore-user-id", user_id) + + {:ok, conn: conn, org_id: org_id, user_id: user_id} + end + + describe "GET /service_accounts" do + test "lists service accounts successfully", %{conn: conn, org_id: org_id} do + service_account = %ServiceAccount{ + id: "sa_123", + name: "Test Service Account", + description: "Test description", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-01 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :list, fn ^org_id, 20, nil -> + {:ok, {[service_account], "next_page_token"}} + end) + + conn = get(conn, "/service_accounts") + + assert json_response(conn, 200) == %{ + "service_accounts" => [ + %{ + "id" => "sa_123", + "name" => "Test Service Account", + "description" => "Test description", + "created_at" => "2024-01-01T10:00:00Z", + "updated_at" => "2024-01-01T10:00:00Z", + "deactivated" => false + } + ] + } + + assert get_resp_header(conn, "x-next-page-token") == ["next_page_token"] + end + + test "handles pagination parameters", %{conn: conn, org_id: org_id} do + expect(ServiceAccountMock, :list, fn ^org_id, 10, "page_token_123" -> + {:ok, {[], nil}} + end) + + conn = + get(conn, "/service_accounts", %{"page_size" => "10", "page_token" => "page_token_123"}) + + assert json_response(conn, 200) == %{"service_accounts" => []} + assert get_resp_header(conn, "x-next-page-token") == [""] + end + + test "handles backend errors", %{conn: conn, org_id: org_id} do + expect(ServiceAccountMock, :list, fn ^org_id, 20, nil -> + {:error, "Failed to list service accounts"} + end) + + conn = get(conn, "/service_accounts") + + assert json_response(conn, 422) == %{"error" => "Failed to list service accounts"} + end + + test "requires service_accounts.view permission", %{ + conn: conn, + org_id: org_id, + user_id: user_id + } do + Support.Stubs.PermissionPatrol.remove_all_permissions() + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, ["organization.view"]) + + conn = get(conn, "/service_accounts") + + assert html_response(conn, 404) =~ "Page not found" + end + end + + describe "POST /service_accounts" do + setup %{org_id: org_id, user_id: user_id} do + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.service_accounts.manage" + ]) + + :ok + end + + test "creates service account successfully", %{conn: conn, org_id: org_id, user_id: user_id} do + service_account = %ServiceAccount{ + id: "sa_new", + name: "New Service Account", + description: "New description", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-01 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :create, fn ^org_id, + "New Service Account", + "New description", + ^user_id -> + {:ok, {service_account, "api_token_123"}} + end) + + conn = + post(conn, "/service_accounts", %{ + "name" => "New Service Account", + "description" => "New description" + }) + + assert json_response(conn, 201) == %{ + "id" => "sa_new", + "name" => "New Service Account", + "description" => "New description", + "created_at" => "2024-01-01T10:00:00Z", + "updated_at" => "2024-01-01T10:00:00Z", + "deactivated" => false, + "api_token" => "api_token_123" + } + end + + test "handles empty parameters", %{conn: conn, org_id: org_id, user_id: user_id} do + expect(ServiceAccountMock, :create, fn ^org_id, "", "", ^user_id -> + {:error, "Name is required"} + end) + + conn = post(conn, "/service_accounts", %{}) + + assert json_response(conn, 422) == %{"error" => "Name is required"} + end + + test "handles backend errors", %{conn: conn, org_id: org_id, user_id: user_id} do + expect(ServiceAccountMock, :create, fn ^org_id, "Test", "Desc", ^user_id -> + {:error, "Failed to create service account"} + end) + + conn = + post(conn, "/service_accounts", %{ + "name" => "Test", + "description" => "Desc" + }) + + assert json_response(conn, 422) == %{"error" => "Failed to create service account"} + end + + test "requires service_accounts.manage permission", %{ + conn: conn, + org_id: org_id, + user_id: user_id + } do + Support.Stubs.PermissionPatrol.remove_all_permissions() + + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.view", + "organization.service_accounts.view" + ]) + + conn = post(conn, "/service_accounts", %{"name" => "Test"}) + + assert html_response(conn, 404) =~ "Page not found" + end + end + + describe "GET /service_accounts/:id" do + test "retrieves service account successfully", %{conn: conn} do + service_account = %ServiceAccount{ + id: "sa_123", + name: "Test Service Account", + description: "Test description", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-01 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :describe, fn "sa_123" -> + {:ok, service_account} + end) + + conn = get(conn, "/service_accounts/sa_123") + + assert json_response(conn, 200) == %{ + "id" => "sa_123", + "name" => "Test Service Account", + "description" => "Test description", + "created_at" => "2024-01-01T10:00:00Z", + "updated_at" => "2024-01-01T10:00:00Z", + "deactivated" => false + } + end + + test "handles not found", %{conn: conn} do + expect(ServiceAccountMock, :describe, fn "sa_nonexistent" -> + {:error, "Service account not found"} + end) + + conn = get(conn, "/service_accounts/sa_nonexistent") + + assert json_response(conn, 422) == %{"error" => "Service account not found"} + end + end + + describe "PUT /service_accounts/:id" do + setup %{org_id: org_id, user_id: user_id} do + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.service_accounts.manage" + ]) + + :ok + end + + test "updates service account successfully", %{conn: conn} do + updated_account = %ServiceAccount{ + id: "sa_123", + name: "Updated Name", + description: "Updated description", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-02 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :update, fn "sa_123", "Updated Name", "Updated description" -> + {:ok, updated_account} + end) + + conn = + put(conn, "/service_accounts/sa_123", %{ + "name" => "Updated Name", + "description" => "Updated description" + }) + + assert json_response(conn, 200) == %{ + "id" => "sa_123", + "name" => "Updated Name", + "description" => "Updated description", + "created_at" => "2024-01-01T10:00:00Z", + "updated_at" => "2024-01-02T10:00:00Z", + "deactivated" => false + } + end + + test "handles update errors", %{conn: conn} do + expect(ServiceAccountMock, :update, fn "sa_123", "", "" -> + {:error, "Failed to update"} + end) + + conn = put(conn, "/service_accounts/sa_123", %{}) + + assert json_response(conn, 422) == %{"error" => "Failed to update"} + end + end + + describe "DELETE /service_accounts/:id" do + setup %{org_id: org_id, user_id: user_id} do + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.service_accounts.manage" + ]) + + :ok + end + + test "deletes service account successfully", %{conn: conn} do + service_account = %ServiceAccount{ + id: "sa_123", + name: "To Delete", + description: "Will be deleted", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-01 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :describe, fn "sa_123" -> + {:ok, service_account} + end) + + expect(ServiceAccountMock, :delete, fn "sa_123" -> + :ok + end) + + conn = delete(conn, "/service_accounts/sa_123") + + assert response(conn, 204) == "" + end + + test "handles delete errors", %{conn: conn} do + expect(ServiceAccountMock, :describe, fn "sa_123" -> + {:error, "Not found"} + end) + + conn = delete(conn, "/service_accounts/sa_123") + + assert json_response(conn, 422) == %{"error" => "Not found"} + end + end + + describe "POST /service_accounts/:id/regenerate_token" do + setup %{org_id: org_id, user_id: user_id} do + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ + "organization.service_accounts.manage" + ]) + + :ok + end + + test "regenerates token successfully", %{conn: conn} do + service_account = %ServiceAccount{ + id: "sa_123", + name: "Test Account", + description: "Test", + created_at: ~U[2024-01-01 10:00:00Z], + updated_at: ~U[2024-01-01 10:00:00Z], + deactivated: false + } + + expect(ServiceAccountMock, :describe, fn "sa_123" -> + {:ok, service_account} + end) + + expect(ServiceAccountMock, :regenerate_token, fn "sa_123" -> + {:ok, "new_api_token_456"} + end) + + conn = post(conn, "/service_accounts/sa_123/regenerate_token") + + assert json_response(conn, 200) == %{"api_token" => "new_api_token_456"} + end + + test "handles regenerate errors", %{conn: conn} do + expect(ServiceAccountMock, :describe, fn "sa_123" -> + {:error, "Not found"} + end) + + conn = post(conn, "/service_accounts/sa_123/regenerate_token") + + assert json_response(conn, 422) == %{"error" => "Not found"} + end + end +end diff --git a/front/test/test_helper.exs b/front/test/test_helper.exs index 51129bb28..3603eb3b1 100644 --- a/front/test/test_helper.exs +++ b/front/test/test_helper.exs @@ -16,7 +16,7 @@ Application.put_env(:wallaby, :js_logger, file) Support.Stubs.init() Mox.defmock(ServiceAccountMock, for: Front.ServiceAccount.Behaviour) -Application.put_env(:front, :service_account_client, ServiceAccountMock) +Application.put_env(:front, :service_account_client, {ServiceAccountMock, []}) ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) ExUnit.start(trace: false, capture_log: true) From a79c302ff8da1d30295a4c6d5b66e9a8beefa2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Wed, 16 Jul 2025 12:37:23 +0000 Subject: [PATCH 06/16] fix(front): correct typespecs for feature enabled plug --- front/lib/front_web/plugs/feature_enabled.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/front/lib/front_web/plugs/feature_enabled.ex b/front/lib/front_web/plugs/feature_enabled.ex index 442730d3a..2b6a30e10 100644 --- a/front/lib/front_web/plugs/feature_enabled.ex +++ b/front/lib/front_web/plugs/feature_enabled.ex @@ -1,12 +1,15 @@ defmodule FrontWeb.Plugs.FeatureEnabled do + @behaviour Plug alias Front.Auth - @type feature_name :: [String.t() | :atom] + @type feature_name :: atom() @nil_uuid "00000000-0000-0000-0000-000000000000" + @impl true def init(features), do: features + @impl true def call(conn, features) do enabled = feature_enabled?(conn, features) or is_insider?(conn) From 03c90f34af7f3be9d3380e7ff91c7ed35c0eb50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Thu, 17 Jul 2025 11:12:17 +0000 Subject: [PATCH 07/16] feat(front): ui for managing service accounts --- front/assets/js/app.js | 25 +- front/assets/js/people/add_people/index.tsx | 29 +-- front/assets/js/people/edit_person/index.tsx | 77 +++--- .../components/CreateServiceAccount.tsx | 120 +++++++++ .../components/EditServiceAccount.tsx | 132 ++++++++++ .../components/ServiceAccountsList.tsx | 169 +++++++++++++ .../components/TokenDisplay.tsx | 61 +++++ front/assets/js/service_accounts/config.ts | 29 +++ front/assets/js/service_accounts/index.tsx | 239 ++++++++++++++++++ front/assets/js/service_accounts/types.ts | 68 +++++ front/assets/js/service_accounts/utils/api.ts | 71 ++++++ front/assets/js/toolbox/api_request.ts | 22 +- front/assets/js/toolbox/modal.tsx | 19 +- front/config/prod.exs | 2 +- front/lib/front/application.ex | 13 +- front/lib/front/auth.ex | 8 - .../controllers/service_account_controller.ex | 6 +- .../plugs/organization_authorization.ex | 7 - .../templates/people/organization.html.eex | 4 + .../templates/people/project.html.eex | 3 + front/lib/front_web/views/people_view.ex | 14 + .../front_web/views/service_account_view.ex | 10 +- front/test/support/mix/tasks/dev_server.ex | 2 + front/test/support/stubs/permission_patrol.ex | 2 +- 24 files changed, 1033 insertions(+), 99 deletions(-) create mode 100644 front/assets/js/service_accounts/components/CreateServiceAccount.tsx create mode 100644 front/assets/js/service_accounts/components/EditServiceAccount.tsx create mode 100644 front/assets/js/service_accounts/components/ServiceAccountsList.tsx create mode 100644 front/assets/js/service_accounts/components/TokenDisplay.tsx create mode 100644 front/assets/js/service_accounts/config.ts create mode 100644 front/assets/js/service_accounts/index.tsx create mode 100644 front/assets/js/service_accounts/types.ts create mode 100644 front/assets/js/service_accounts/utils/api.ts diff --git a/front/assets/js/app.js b/front/assets/js/app.js index 7d8762e69..d2fd32f45 100644 --- a/front/assets/js/app.js +++ b/front/assets/js/app.js @@ -66,6 +66,7 @@ import { default as Agents} from "./agents"; import { default as AddPeople } from "./people/add_people"; import { default as EditPerson } from "./people/edit_person"; import { default as SyncPeople } from "./people/sync_people"; +import { default as ServiceAccounts } from "./service_accounts"; import { default as Report } from "./report"; import { InitializingScreen } from "./project_onboarding/initializing"; @@ -294,12 +295,23 @@ export var App = { GroupManagement.init(); new Star(); - const addPeopleAppRoot = document.getElementById("add-people"); - if (addPeopleAppRoot) { - AddPeople({ - dom: addPeopleAppRoot, - config: addPeopleAppRoot.dataset, - }); + + // Initialize Preact apps + const serviceAccountsEl = document.getElementById("service-accounts"); + if (serviceAccountsEl) { + const config = JSON.parse(serviceAccountsEl.dataset.config); + ServiceAccounts({ dom: serviceAccountsEl, config }); + } + + const addPeopleEl = document.getElementById("add-people"); + if (addPeopleEl) { + AddPeople({ dom: addPeopleEl, config: addPeopleEl.dataset }); + } + + const syncPeopleEl = document.querySelector(".app-sync-people"); + if (syncPeopleEl) { + const config = JSON.parse(syncPeopleEl.dataset.config); + SyncPeople({ dom: syncPeopleEl, config }); } document.querySelectorAll(".app-edit-person").forEach((editPersonAppRoot) => { @@ -516,6 +528,7 @@ export var App = { window.Notice.init(); + $(document).on("click", ".x-select-on-click", function (event) { event.currentTarget.setSelectionRange(0, event.currentTarget.value.length); }); diff --git a/front/assets/js/people/add_people/index.tsx b/front/assets/js/people/add_people/index.tsx index 7b28c5a1c..58d1123b8 100644 --- a/front/assets/js/people/add_people/index.tsx +++ b/front/assets/js/people/add_people/index.tsx @@ -44,7 +44,7 @@ export const App = () => { person_add {`Add people`} - close(false)} title="Add people"> + close(false)} title="Add new people" width="w-70-m"> @@ -134,16 +134,9 @@ const AddNewUsers = (props: { close: (reload: boolean) => void, }) => { }; return ( -
-
-

Add new people

-
- +
{userProviders.length > 1 && ( -
+
{userProviders.map(userProviderBox)}
)} @@ -158,7 +151,7 @@ const AddNewUsers = (props: { close: (reload: boolean) => void, }) => { return ( {loading && ( -
+
)} @@ -280,7 +273,7 @@ const ProvideVia = (props: ProvideViaProps) => { return (
{message}
-
+
)} {!arePeopleInvited && ( -
+
- -
-
-
-

Edit user

-
-
- -
- -
- + +
+
+ +
+
+ +
-
- - -
+
+ + +
-
- - - -
-
- + - -
+
diff --git a/front/assets/js/service_accounts/components/CreateServiceAccount.tsx b/front/assets/js/service_accounts/components/CreateServiceAccount.tsx new file mode 100644 index 000000000..88561d4a4 --- /dev/null +++ b/front/assets/js/service_accounts/components/CreateServiceAccount.tsx @@ -0,0 +1,120 @@ +import { useState, useContext } from "preact/hooks"; +import { Modal } from "js/toolbox"; +import { ConfigContext } from "../config"; +import { ServiceAccountsAPI } from "../utils/api"; +import { TokenDisplay } from "./TokenDisplay"; +import * as toolbox from "js/toolbox"; + +interface CreateServiceAccountProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; +} + +export const CreateServiceAccount = ({ isOpen, onClose, onCreated }: CreateServiceAccountProps) => { + const config = useContext(ConfigContext); + const api = new ServiceAccountsAPI(config); + + const [name, setName] = useState(``); + const [description, setDescription] = useState(``); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [token, setToken] = useState(null); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + setError(null); + setLoading(true); + + const response = await api.create(name, description); + + if (response.error) { + setError(response.error); + setLoading(false); + } else if (response.data) { + setToken(response.data.api_token); + setLoading(false); + } + }; + + const handleClose = () => { + if (token) { + onCreated(); + } + setName(``); + setDescription(``); + setError(null); + setToken(null); + onClose(); + }; + + const canSubmit = name.trim().length > 0 && !loading; + + return ( + + {!token ? ( +
void handleSubmit(e)}> +
+
+ + setName(e.currentTarget.value)} + placeholder="e.g., CI/CD Pipeline" + disabled={loading} + autoFocus + /> +
+ +
+ +