- <%= render "members/members_list.html", org_id: @org_id, groups: @groups, members: @members, roles: @roles, org_scope?: @org_scope?, conn: @conn, permissions: @permissions %>
+ <%= render "members/members_list.html", org_id: @org_id, groups: @groups, members: @members, service_accounts: @service_accounts, roles: @roles, org_scope?: @org_scope?, conn: @conn, permissions: @permissions %>
<%= render "_pagination.html", pagination: @pagination%>
<% else %>
diff --git a/front/lib/front_web/views/people_view.ex b/front/lib/front_web/views/people_view.ex
index f03552d30..53ef14fe0 100644
--- a/front/lib/front_web/views/people_view.ex
+++ b/front/lib/front_web/views/people_view.ex
@@ -45,6 +45,38 @@ defmodule FrontWeb.PeopleView do
}
end
+ def service_accounts_config(conn) do
+ org_id = conn.assigns.organization_id
+ permissions = conn.assigns.permissions
+
+ {:ok, all_roles} = Front.RBAC.RoleManagement.list_possible_roles(org_id, "org_scope")
+
+ # Filter roles - exclude Owner unless user has change_owner permission
+ filtered_roles =
+ all_roles
+ |> Enum.filter(fn
+ %{name: "Owner"} -> permissions["organization.change_owner"] || false
+ _role -> true
+ end)
+ |> Enum.map(fn role ->
+ %{
+ id: role.id,
+ name: role.name,
+ description: role.description
+ }
+ end)
+
+ %{
+ organization_id: org_id,
+ project_id: conn.assigns[:project_id],
+ permissions: %{
+ view: permissions["organization.service_accounts.view"] || false,
+ manage: permissions["organization.service_accounts.manage"] || false
+ },
+ roles: filtered_roles
+ }
+ end
+
@spec available_user_providers(org_id :: String.t()) :: [String.t()]
defp available_user_providers(org_id) do
[
@@ -194,28 +226,40 @@ defmodule FrontWeb.PeopleView do
|> raw()
end
- defp map_role_to_colour("Admin"), do: "blue"
- defp map_role_to_colour("Contributor"), do: "green"
- defp map_role_to_colour("Reader"), do: "yellow"
- defp map_role_to_colour("Owner"), do: "red"
- defp map_role_to_colour("Member"), do: "green"
- defp map_role_to_colour("Viewer"), do: "orange"
- defp map_role_to_colour("Billing Admin"), do: "purple"
- defp map_role_to_colour(_), do: "cyan"
+ def map_role_to_colour("Admin"), do: "blue"
+ def map_role_to_colour("Contributor"), do: "green"
+ def map_role_to_colour("Reader"), do: "yellow"
+ def map_role_to_colour("Owner"), do: "red"
+ def map_role_to_colour("Member"), do: "green"
+ def map_role_to_colour("Viewer"), do: "orange"
+ def map_role_to_colour("Billing Admin"), do: "purple"
+ def map_role_to_colour(_), do: "cyan"
def construct_member_avatar(member) do
- avatar_url =
- if member.has_avatar do
- member.avatar
- else
- first_letter = member.name |> String.first() |> String.downcase()
- "#{assets_path()}/images/org-#{first_letter}.svg"
- end
-
- """
-

- """
- |> raw()
+ # Check if this is a service account
+ is_service_account = member.subject_type == "service_account"
+
+ if is_service_account do
+ """
+
+ smart_toy
+
+ """
+ |> raw()
+ else
+ avatar_url =
+ if member.has_avatar do
+ member.avatar
+ else
+ first_letter = member.name |> String.first() |> String.downcase()
+ "#{assets_path()}/images/org-#{first_letter}.svg"
+ end
+
+ """
+

+ """
+ |> raw()
+ end
end
def build_roles(member, roles) do
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..0f995a2d1
--- /dev/null
+++ b/front/lib/front_web/views/service_account_view.ex
@@ -0,0 +1,49 @@
+defmodule FrontWeb.ServiceAccountView do
+ use FrontWeb, :view
+
+ alias Front.Models.ServiceAccount
+
+ def render("index.json", %{service_accounts: service_accounts, total_pages: total_pages}) do
+ %{
+ service_accounts: Enum.map(service_accounts, &service_account_json/1),
+ total_pages: total_pages
+ }
+ end
+
+ 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)
+ case Map.get(assigns, :api_token) do
+ nil -> data
+ token -> Map.put(data, :api_token, token)
+ end
+ end
+
+ defp service_account_json(service_account = %ServiceAccount{}) 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,
+ roles: Enum.map(service_account.roles, &role_json/1)
+ }
+ end
+
+ defp role_json(role) do
+ %{
+ id: role.id,
+ name: role.name,
+ source: role.source,
+ color: FrontWeb.PeopleView.map_role_to_colour(role.name)
+ }
+ 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..50069e25f
--- /dev/null
+++ b/front/lib/internal_api/service_account.pb.ex
@@ -0,0 +1,313 @@
+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.DescribeManyRequest do
+ @moduledoc false
+ use Protobuf, syntax: :proto3
+
+ @type t :: %__MODULE__{
+ sa_ids: [String.t()]
+ }
+ defstruct [:sa_ids]
+
+ field(:sa_ids, 1, repeated: true, type: :string)
+end
+
+defmodule InternalApi.ServiceAccount.DescribeManyResponse do
+ @moduledoc false
+ use Protobuf, syntax: :proto3
+
+ @type t :: %__MODULE__{
+ service_accounts: [InternalApi.ServiceAccount.ServiceAccount.t()]
+ }
+ defstruct [:service_accounts]
+
+ field(:service_accounts, 1, repeated: true, 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.DeactivateRequest 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.DeactivateResponse do
+ @moduledoc false
+ use Protobuf, syntax: :proto3
+
+ defstruct []
+end
+
+defmodule InternalApi.ServiceAccount.ReactivateRequest 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.ReactivateResponse do
+ @moduledoc false
+ use Protobuf, syntax: :proto3
+
+ defstruct []
+end
+
+defmodule InternalApi.ServiceAccount.DestroyRequest 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.DestroyResponse 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.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(
+ :DescribeMany,
+ InternalApi.ServiceAccount.DescribeManyRequest,
+ InternalApi.ServiceAccount.DescribeManyResponse
+ )
+
+ rpc(
+ :Update,
+ InternalApi.ServiceAccount.UpdateRequest,
+ InternalApi.ServiceAccount.UpdateResponse
+ )
+
+ rpc(
+ :Deactivate,
+ InternalApi.ServiceAccount.DeactivateRequest,
+ InternalApi.ServiceAccount.DeactivateResponse
+ )
+
+ rpc(
+ :Reactivate,
+ InternalApi.ServiceAccount.ReactivateRequest,
+ InternalApi.ServiceAccount.ReactivateResponse
+ )
+
+ rpc(
+ :Destroy,
+ InternalApi.ServiceAccount.DestroyRequest,
+ InternalApi.ServiceAccount.DestroyResponse
+ )
+
+ 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/lib/public_api/semaphore/notifications.v1alpha.pb.ex b/front/lib/public_api/semaphore/notifications.v1alpha.pb.ex
index cbffbd89c..ffc705a72 100644
--- a/front/lib/public_api/semaphore/notifications.v1alpha.pb.ex
+++ b/front/lib/public_api/semaphore/notifications.v1alpha.pb.ex
@@ -22,14 +22,16 @@ defmodule Semaphore.Notifications.V1alpha.Notification.Metadata do
name: String.t(),
id: String.t(),
create_time: integer,
- update_time: integer
+ update_time: integer,
+ creator_id: String.t()
}
- defstruct [:name, :id, :create_time, :update_time]
+ defstruct [:name, :id, :create_time, :update_time, :creator_id]
field(:name, 1, type: :string)
field(:id, 2, type: :string)
field(:create_time, 3, type: :int64)
field(:update_time, 4, type: :int64)
+ field(:creator_id, 5, type: :string)
end
defmodule Semaphore.Notifications.V1alpha.Notification.Spec 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/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..5b32c1c4a
--- /dev/null
+++ b/front/test/front_web/controllers/service_account_controller_test.exs
@@ -0,0 +1,427 @@
+defmodule FrontWeb.ServiceAccountControllerTest do
+ use FrontWeb.ConnCase
+ import Mox
+ alias Support.Stubs.DB
+
+ setup :verify_on_exit!
+
+ setup %{conn: conn} do
+ Cacheman.clear(:front)
+ Support.Stubs.init()
+ Support.Stubs.build_shared_factories()
+
+ original_client = Application.get_env(:front, :service_account_client)
+ Application.put_env(:front, :service_account_client, {ServiceAccountMock, []})
+
+ on_exit(fn ->
+ Application.put_env(:front, :service_account_client, original_client)
+ end)
+
+ user_id = DB.first(:users) |> Map.get(:id)
+ org_id = DB.first(:organizations) |> Map.get(:id)
+
+ Support.Stubs.PermissionPatrol.remove_all_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
+ GrpcMock.expect(RBACMock, :list_members, fn request, _stream ->
+ member = %InternalApi.RBAC.ListMembersResponse.Member{
+ subject: %InternalApi.RBAC.Subject{
+ subject_id: "sa_123",
+ subject_type: InternalApi.RBAC.SubjectType.value(:SERVICE_ACCOUNT),
+ display_name: ""
+ },
+ subject_role_bindings: [
+ %InternalApi.RBAC.SubjectRoleBinding{
+ role: %InternalApi.RBAC.Role{
+ id: "role_123",
+ name: "Admin",
+ org_id: org_id,
+ scope: InternalApi.RBAC.Scope.value(:SCOPE_ORG),
+ description: "",
+ permissions: [],
+ rbac_permissions: [],
+ readonly: false
+ },
+ source: InternalApi.RBAC.RoleBindingSource.value(:ROLE_BINDING_SOURCE_MANUALLY)
+ }
+ ]
+ }
+
+ assert request.org_id == org_id
+ assert request.page.page_no == 0
+ assert request.page.page_size == 20
+ assert request.member_type == InternalApi.RBAC.SubjectType.value(:SERVICE_ACCOUNT)
+
+ response = %InternalApi.RBAC.ListMembersResponse{
+ members: [member],
+ total_pages: 1
+ }
+
+ response
+ end)
+
+ service_account_proto = %InternalApi.ServiceAccount.ServiceAccount{
+ id: "sa_123",
+ name: "Test Service Account",
+ description: "Test description",
+ org_id: org_id,
+ creator_id: "",
+ created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ deactivated: false
+ }
+
+ expect(ServiceAccountMock, :describe_many, fn ["sa_123"] ->
+ {:ok, [service_account_proto]}
+ 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,
+ "roles" => [
+ %{
+ "id" => "role_123",
+ "name" => "Admin",
+ "source" => "manual",
+ "color" => "blue"
+ }
+ ]
+ }
+ ],
+ "total_pages" => 1
+ }
+ end
+
+ test "handles pagination parameters", %{conn: conn, org_id: org_id} do
+ GrpcMock.expect(RBACMock, :list_members, fn request, _stream ->
+ assert request.org_id == org_id
+ assert request.page.page_no == 1
+ assert request.page.page_size == 20
+ assert request.member_type == InternalApi.RBAC.SubjectType.value(:SERVICE_ACCOUNT)
+
+ response = %InternalApi.RBAC.ListMembersResponse{
+ members: [],
+ total_pages: 2
+ }
+
+ response
+ end)
+
+ expect(ServiceAccountMock, :describe_many, fn [] ->
+ {:ok, []}
+ end)
+
+ conn = get(conn, "/service_accounts", %{"page" => "2"})
+
+ assert json_response(conn, 200) == %{
+ "service_accounts" => [],
+ "total_pages" => 2
+ }
+ end
+
+ test "handles backend errors", %{conn: conn} do
+ GrpcMock.expect(RBACMock, :list_members, fn _request, _stream ->
+ raise GRPC.RPCError, status: 2, message: "Internal Server Error"
+ 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_proto = %InternalApi.ServiceAccount.ServiceAccount{
+ id: "sa_new",
+ name: "New Service Account",
+ description: "New description",
+ org_id: org_id,
+ creator_id: user_id,
+ created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ deactivated: false
+ }
+
+ expect(ServiceAccountMock, :create, fn ^org_id,
+ "New Service Account",
+ "New description",
+ ^user_id ->
+ {:ok, {service_account_proto, "api_token_123"}}
+ end)
+
+ GrpcMock.expect(RBACMock, :assign_role, fn request, _stream ->
+ assert request.role_assignment.subject.subject_id == "sa_new"
+ assert request.role_assignment.role_id == "role_123"
+ assert request.role_assignment.org_id == org_id
+ assert request.requester_id == user_id
+
+ %InternalApi.RBAC.AssignRoleResponse{}
+ end)
+
+ conn =
+ post(conn, "/service_accounts", %{
+ "name" => "New Service Account",
+ "description" => "New description",
+ "role_id" => "role_123"
+ })
+
+ 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",
+ "roles" => []
+ }
+ 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, "Service account name cannot be empty"}
+ end)
+
+ conn = post(conn, "/service_accounts", %{})
+
+ assert json_response(conn, 422) == %{
+ "error" => "Failed to create service account or assign role"
+ }
+ 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, "Backend error"}
+ end)
+
+ conn =
+ post(conn, "/service_accounts", %{
+ "name" => "Test",
+ "description" => "Desc"
+ })
+
+ assert json_response(conn, 422) == %{
+ "error" => "Failed to create service account or assign role"
+ }
+ 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 "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, user_id: user_id, org_id: org_id} do
+ updated_account_proto = %InternalApi.ServiceAccount.ServiceAccount{
+ id: "sa_123",
+ name: "Updated Name",
+ description: "Updated description",
+ org_id: org_id,
+ creator_id: "some_creator",
+ created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_189_600},
+ deactivated: false
+ }
+
+ expect(ServiceAccountMock, :update, fn "sa_123", "Updated Name", "Updated description" ->
+ {:ok, updated_account_proto}
+ end)
+
+ GrpcMock.expect(RBACMock, :assign_role, fn request, _stream ->
+ assert request.role_assignment.subject.subject_id == "sa_123"
+ assert request.role_assignment.role_id == "role_456"
+ assert request.role_assignment.org_id == org_id
+ assert request.requester_id == user_id
+
+ %InternalApi.RBAC.AssignRoleResponse{}
+ end)
+
+ conn =
+ put(conn, "/service_accounts/sa_123", %{
+ "name" => "Updated Name",
+ "description" => "Updated description",
+ "role_id" => "role_456"
+ })
+
+ 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,
+ "roles" => []
+ }
+ end
+
+ test "handles update errors", %{conn: conn} do
+ expect(ServiceAccountMock, :update, fn "sa_123", "", "" ->
+ {:error, "Service account name cannot be empty"}
+ end)
+
+ conn = put(conn, "/service_accounts/sa_123", %{})
+
+ assert json_response(conn, 422) == %{
+ "error" => "Failed to update service account or assign role"
+ }
+ 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_proto = %InternalApi.ServiceAccount.ServiceAccount{
+ id: "sa_123",
+ name: "To Delete",
+ description: "Will be deleted",
+ org_id: "some_org_id",
+ creator_id: "some_creator",
+ created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ deactivated: false
+ }
+
+ expect(ServiceAccountMock, :describe, fn "sa_123" ->
+ {:ok, service_account_proto}
+ 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, "Service account not found"}
+ end)
+
+ conn = delete(conn, "/service_accounts/sa_123")
+
+ assert json_response(conn, 422) == %{"error" => "Failed to delete service account"}
+ 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_proto = %InternalApi.ServiceAccount.ServiceAccount{
+ id: "sa_123",
+ name: "Test Account",
+ description: "Test",
+ org_id: "some_org_id",
+ creator_id: "some_creator",
+ created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
+ deactivated: false
+ }
+
+ expect(ServiceAccountMock, :describe, fn "sa_123" ->
+ {:ok, service_account_proto}
+ 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, "Service account not found"}
+ end)
+
+ conn = post(conn, "/service_accounts/sa_123/regenerate_token")
+
+ assert json_response(conn, 422) == %{
+ "error" => "Failed to regenerate service account token"
+ }
+ end
+ end
+end
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..342bc57c2
--- /dev/null
+++ b/front/test/support/fake_clients/service_account.ex
@@ -0,0 +1,217 @@
+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 InternalApi.ServiceAccount.ServiceAccount
+ alias Support.Stubs.RBAC
+
+ @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
+
+ @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: now_proto_timestamp(),
+ updated_at: now_proto_timestamp(),
+ deactivated: false
+ }
+
+ api_token = generate_token()
+
+ Agent.update(__MODULE__, fn state ->
+ RBAC.add_service_account(org_id, service_account)
+
+ 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 describe_many(service_account_ids) do
+ service_accounts =
+ service_account_ids
+ |> Enum.map(fn id ->
+ describe(id)
+ end)
+ |> Enum.filter(fn
+ {:ok, _} -> true
+ _ -> false
+ end)
+ |> Enum.map(fn {:ok, account} -> account end)
+
+ {:ok, service_accounts}
+ 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: now_proto_timestamp()
+ }
+
+ 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
+
+ defp now_proto_timestamp do
+ seconds = DateTime.utc_now() |> DateTime.to_unix()
+ Google.Protobuf.Timestamp.new(seconds: seconds)
+ end
+end
diff --git a/front/test/support/stubs/feature.ex b/front/test/support/stubs/feature.ex
index 60fb23987..16fe246ae 100644
--- a/front/test/support/stubs/feature.ex
+++ b/front/test/support/stubs/feature.ex
@@ -138,7 +138,9 @@ 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},
+ {"rbac__project_roles", 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..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"
+ @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 e2a366f95..e06cfd789 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",
+ "organization.service_accounts.view",
+ "organization.service_accounts.manage"
]
@project_permissions [
@@ -223,6 +225,24 @@ defmodule Support.Stubs.RBAC do
add_member(@default_org_id, u.id, nil)
end
+ service_accounts = [
+ "Robot #1",
+ "Robot #2",
+ "Robot #3"
+ ]
+
+ for service_account_name <- service_accounts do
+ {:ok, {service_account, _}} =
+ Front.ServiceAccount.create(
+ @default_org_id,
+ service_account_name,
+ "Service account for testing",
+ ""
+ )
+
+ add_service_account(@default_org_id, service_account)
+ end
+
# Enable okta (includes rbac as well) for rtx
Feature.enable_feature(@default_org_id, "rbac__saml")
end
@@ -334,6 +354,22 @@ defmodule Support.Stubs.RBAC do
})
end
+ def add_service_account(org_id, service_account) do
+ DB.insert(:subjects, %{
+ id: service_account.id,
+ name: service_account.name,
+ type: "service_account"
+ })
+
+ DB.insert(:subject_role_bindings, %{
+ id: UUID.gen(),
+ org_id: org_id,
+ subject_id: service_account.id,
+ role_id: member_role_id(),
+ project_id: nil
+ })
+ end
+
def member_role_id do
DB.find_all_by(:rbac_roles, :name, "Member") |> List.first() |> Map.get(:id)
end
@@ -441,58 +477,58 @@ defmodule Support.Stubs.RBAC do
DB.find_all_by(:subject_role_bindings, :org_id, org_id)
|> Enum.filter(fn binding -> binding.project_id == project_id end)
- user_grouped_role_bindings = Enum.group_by(all_org_subject_role_bindings, & &1.subject_id)
-
- paginated_bindings =
- user_grouped_role_bindings
- |> Enum.drop(page.page_no * page_size)
- |> Enum.take(page.page_no * page_size + page_size)
+ subject_grouped_role_bindings =
+ Enum.group_by(all_org_subject_role_bindings, & &1.subject_id)
string_member_type =
RBAC.SubjectType.key(member_type) |> Atom.to_string() |> String.downcase()
+ members =
+ Enum.map(subject_grouped_role_bindings, fn {subject_id, bindings} ->
+ user =
+ DB.filter(:subjects, &(&1.id == subject_id and &1.type == string_member_type))
+ |> List.first()
+
+ if !is_nil(user) and
+ (member_name_contains == "" ||
+ String.downcase(user.name) =~ String.downcase(member_name_contains)) do
+ RBAC.ListMembersResponse.Member.new(
+ subject:
+ RBAC.Subject.new(
+ subject_type: member_type,
+ subject_id: user.id,
+ display_name: user.name
+ ),
+ subject_role_bindings:
+ Enum.map(bindings, fn binding ->
+ role = DB.find_by(:rbac_roles, :id, binding.role_id)
+
+ RBAC.SubjectRoleBinding.new(
+ role:
+ RBAC.Role.new(
+ id: role.id,
+ name: role.name
+ ),
+ source: RBAC.RoleBindingSource.value(:ROLE_BINDING_SOURCE_MANUALLY)
+ )
+ end)
+ )
+ else
+ nil
+ end
+ end)
+ |> Enum.filter(& &1)
+
+ paginated_members =
+ members
+ |> Enum.sort_by(& &1.subject.display_name)
+ |> Enum.chunk_every(page_size)
+ |> Enum.at(page.page_no, [])
+
RBAC.ListMembersResponse.new(
- members:
- Enum.map(paginated_bindings, fn {subject_id, bindings} ->
- filter_f = fn entity ->
- entity.id == subject_id && entity.type == string_member_type
- end
-
- user =
- DB.filter(:subjects, filter_f)
- |> List.first()
-
- if !is_nil(user) and
- (member_name_contains == "" ||
- String.downcase(user.name) =~ String.downcase(member_name_contains)) do
- RBAC.ListMembersResponse.Member.new(
- subject:
- RBAC.Subject.new(
- subject_type: member_type,
- subject_id: user.id,
- display_name: user.name
- ),
- subject_role_bindings:
- Enum.map(bindings, fn binding ->
- role = DB.find_by(:rbac_roles, :id, binding.role_id)
-
- RBAC.SubjectRoleBinding.new(
- role:
- RBAC.Role.new(
- id: role.id,
- name: role.name
- ),
- source: RBAC.RoleBindingSource.value(:ROLE_BINDING_SOURCE_MANUALLY)
- )
- end)
- )
- else
- nil
- end
- end)
- |> Enum.filter(fn user -> user != nil end),
+ members: paginated_members,
total_pages:
- ((user_grouped_role_bindings |> map_size()) / page_size)
+ (length(members) / page_size)
|> Float.ceil()
|> round()
)
@@ -603,17 +639,15 @@ defmodule Support.Stubs.RBAC do
req.role_assignment.project_id
end
- # If role is already assigned, remove id
- srb =
- DB.filter(:subject_role_bindings, fn entity ->
- entity.subject_id == req.role_assignment.subject.subject_id and
- entity.org_id == req.role_assignment.org_id and
- (project_id == nil or entity.project_id == project_id)
- end)
-
- if srb != [] do
- DB.delete(:subject_role_bindings, hd(srb).id)
- end
+ # If role is already assigned, remove it
+ DB.filter(:subject_role_bindings, fn entity ->
+ entity.subject_id == req.role_assignment.subject.subject_id and
+ entity.org_id == req.role_assignment.org_id and
+ (project_id == nil or entity.project_id == project_id)
+ end)
+ |> Enum.each(fn srb ->
+ DB.delete(:subject_role_bindings, srb.id)
+ end)
DB.insert(:subject_role_bindings, %{
id: UUID.gen(),
diff --git a/front/test/test_helper.exs b/front/test/test_helper.exs
index 486526879..c663ac7db 100644
--- a/front/test/test_helper.exs
+++ b/front/test/test_helper.exs
@@ -15,6 +15,8 @@ Application.put_env(:wallaby, :js_logger, file)
Support.Stubs.init()
+Mox.defmock(ServiceAccountMock, for: Front.ServiceAccount.Behaviour)
+
ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter])
ExUnit.start(trace: false, capture_log: true)
diff --git a/helm-chart/templates/configmaps/features.yaml b/helm-chart/templates/configmaps/features.yaml
index d1e7868c8..ecbc7a658 100644
--- a/helm-chart/templates/configmaps/features.yaml
+++ b/helm-chart/templates/configmaps/features.yaml
@@ -112,6 +112,8 @@ data:
enabled: true
wf_editor_via_jobs:
enabled: true
+ service_accounts:
+ enabled: true
{{- else }}
features.yml: |-
activity_monitor:
@@ -216,4 +218,6 @@ data:
enabled: true
new_project_onboarding:
enabled: true
+ service_accounts:
+ enabled: true
{{- end }}
diff --git a/helm-chart/templates/configmaps/internal-api-urls.yaml b/helm-chart/templates/configmaps/internal-api-urls.yaml
index c14d2eb85..fedb5cdaf 100644
--- a/helm-chart/templates/configmaps/internal-api-urls.yaml
+++ b/helm-chart/templates/configmaps/internal-api-urls.yaml
@@ -39,6 +39,7 @@ data:
INTERNAL_API_URL_TASK: zebra-task-api:50051
INTERNAL_API_URL_USER: guard-user-api:50051
INTERNAL_API_URL_VELOCITY: velocity-hub:50051
+ INTERNAL_API_URL_SERVICE_ACCOUNT: guard-service-account-api:50051
{{- if eq .Values.global.edition "ce" }}
INTERNAL_API_URL_RBAC: rbac:50051
INTERNAL_API_URL_OKTA: guard-okta-internal-api:50051
diff --git a/helm-chart/templates/configmaps/permissions.yaml b/helm-chart/templates/configmaps/permissions.yaml
index f5f940271..38b8b19f1 100644
--- a/helm-chart/templates/configmaps/permissions.yaml
+++ b/helm-chart/templates/configmaps/permissions.yaml
@@ -50,6 +50,10 @@ data:
description: "Create new dashboard views."
- name: "organization.instance_git_integration.manage"
description: "Manage the instance Git integration settings."
+ - name: "organization.service_accounts.view"
+ description: "View service accounts within the organization."
+ - name: "organization.service_accounts.manage"
+ description: "Manage service accounts within the organization."
project:
- name: "project.view"
description: "Access the project. This permission is needed to see any page within the project."
@@ -186,6 +190,10 @@ data:
description: "Create new dashboard views."
- name: "organization.instance_git_integration.manage"
description: "Manage the instance Git integration settings."
+ - name: "organization.service_accounts.view"
+ description: "View service accounts within the organization."
+ - name: "organization.service_accounts.manage"
+ description: "Manage service accounts within the organization."
project:
- name: "project.view"
description: "Access the project. This permission is needed to see any page within the project."
@@ -249,4 +257,5 @@ data:
description: "Manually stop running jobs or workflows."
- name: "project.job.attach"
description: "SSH into the running job, or start a debug session."
-{{- end }}
\ No newline at end of file
+
+{{- end }}
diff --git a/helm-chart/templates/configmaps/roles.yaml b/helm-chart/templates/configmaps/roles.yaml
index cf2c7ac93..458f4f40a 100644
--- a/helm-chart/templates/configmaps/roles.yaml
+++ b/helm-chart/templates/configmaps/roles.yaml
@@ -33,6 +33,8 @@ data:
- "organization.dashboards.view"
- "organization.dashboards.manage"
- "organization.instance_git_integration.manage"
+ - "organization.service_accounts.view"
+ - "organization.service_accounts.manage"
- name: "Admin"
description: "Admins can modify settings within the organization or any of its projects. However, they do not have access to billing information, and they cannot change general organization details, such as the organization name and URL."
maps_to: "Admin"
@@ -57,6 +59,8 @@ data:
- "organization.dashboards.view"
- "organization.dashboards.manage"
- "project.delete"
+ - "organization.service_accounts.view"
+ - "organization.service_accounts.manage"
- name: "Member"
description: "Members can access the organization's homepage and the projects they are assigned to. However, they are not able to modify any settings."
permissions:
@@ -197,6 +201,8 @@ data:
- "organization.dashboards.view"
- "organization.dashboards.manage"
- "organization.instance_git_integration.manage"
+ - "organization.service_accounts.view"
+ - "organization.service_accounts.manage"
- name: "Admin"
description: "Admins can modify settings within the organization or any of its projects. However, they do not have access to billing information, and they cannot change general organization details, such as the organization name and URL."
maps_to: "Admin"
@@ -234,6 +240,8 @@ data:
- "organization.custom_roles.view"
- "organization.dashboards.view"
- "organization.dashboards.manage"
+ - "organization.service_accounts.view"
+ - "organization.service_accounts.manage"
- "project.delete"
- name: "Member"
description: "Members can access the organization's homepage and the projects they are assigned to. However, they are not able to modify any settings."