diff --git a/ee/rbac/lib/internal_api/rbac.pb.ex b/ee/rbac/lib/internal_api/rbac.pb.ex index 5c7b4a4b4..a0568caea 100644 --- a/ee/rbac/lib/internal_api/rbac.pb.ex +++ b/ee/rbac/lib/internal_api/rbac.pb.ex @@ -380,6 +380,23 @@ defmodule InternalApi.RBAC.Permission do field(:scope, 4, type: InternalApi.RBAC.Scope, enum: true) end +defmodule InternalApi.RBAC.ListSubjectsRequest do + @moduledoc false + + use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.13.0" + + field(:org_id, 1, type: :string, json_name: "orgId") + field(:subject_ids, 2, repeated: true, type: :string, json_name: "subjectIds") +end + +defmodule InternalApi.RBAC.ListSubjectsResponse do + @moduledoc false + + use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.13.0" + + field(:subjects, 1, repeated: true, type: InternalApi.RBAC.Subject) +end + defmodule InternalApi.RBAC.RBAC.Service do @moduledoc false @@ -436,6 +453,8 @@ defmodule InternalApi.RBAC.RBAC.Service do InternalApi.RBAC.RefreshCollaboratorsRequest, InternalApi.RBAC.RefreshCollaboratorsResponse ) + + rpc(:ListSubjects, InternalApi.RBAC.ListSubjectsRequest, InternalApi.RBAC.ListSubjectsResponse) end defmodule InternalApi.RBAC.RBAC.Stub do diff --git a/ee/rbac/lib/rbac/grpc_servers/rbac_server.ex b/ee/rbac/lib/rbac/grpc_servers/rbac_server.ex index ab8585b0f..714391356 100644 --- a/ee/rbac/lib/rbac/grpc_servers/rbac_server.ex +++ b/ee/rbac/lib/rbac/grpc_servers/rbac_server.ex @@ -264,6 +264,18 @@ defmodule Rbac.GrpcServers.RbacServer do end) end + def list_subjects(%RBAC.ListSubjectsRequest{} = req, _stream) do + Watchman.benchmark("list_subjects.duration", fn -> + validate_uuid!(req.org_id) + + subjects = Rbac.Repo.Subject.find_by_ids_and_org(req.subject_ids, req.org_id) + + %RBAC.ListSubjectsResponse{ + subjects: Enum.map(subjects, &construct_grpc_subject/1) + } + end) + end + ### ### Helper functions ### @@ -443,6 +455,16 @@ defmodule Rbac.GrpcServers.RbacServer do } end + defp construct_grpc_subject(subject) do + subject_type = subject.type |> String.upcase() |> String.to_existing_atom() + + %RBAC.Subject{ + subject_type: subject_type, + subject_id: subject.id, + display_name: subject.name + } + end + defp scope_name_to_grpc_enum(name) do case name do "org_scope" -> :SCOPE_ORG diff --git a/ee/rbac/lib/rbac/repo/subject.ex b/ee/rbac/lib/rbac/repo/subject.ex index 2c85b97cb..4fdaec713 100644 --- a/ee/rbac/lib/rbac/repo/subject.ex +++ b/ee/rbac/lib/rbac/repo/subject.ex @@ -1,6 +1,6 @@ defmodule Rbac.Repo.Subject do use Rbac.Repo.Schema - import Ecto.Query, only: [where: 3] + import Ecto.Query schema "subjects" do has_many(:role_bindings, Rbac.Repo.SubjectRoleBinding) @@ -15,6 +15,15 @@ defmodule Rbac.Repo.Subject do __MODULE__ |> where([s], s.id == ^id) |> Rbac.Repo.one() end + @spec find_by_ids_and_org([String.t()], String.t()) :: [%__MODULE__{}] + def find_by_ids_and_org(subject_ids, org_id) do + __MODULE__ + |> join(:inner, [s], srb in assoc(s, :role_bindings)) + |> where([s, srb], s.id in ^subject_ids and srb.org_id == ^org_id) + |> distinct([s], s.id) + |> Rbac.Repo.all() + end + def changeset(subject, params \\ %{}) do subject |> cast(params, [:id, :name, :type]) diff --git a/ee/rbac/test/rbac/grpc_servers/rbac_server_test.exs b/ee/rbac/test/rbac/grpc_servers/rbac_server_test.exs index 81b748fa5..ac0b04db1 100644 --- a/ee/rbac/test/rbac/grpc_servers/rbac_server_test.exs +++ b/ee/rbac/test/rbac/grpc_servers/rbac_server_test.exs @@ -1068,6 +1068,84 @@ defmodule Rbac.GrpcServers.RbacServer.Test do end end + describe "list_subjects" do + alias InternalApi.RBAC.ListSubjectsRequest, as: Request + + test "invalid org_id returns error", state do + req = %Request{org_id: "invalid-uuid", subject_ids: []} + {:error, grpc_error} = state.grpc_channel |> Stub.list_subjects(req) + assert grpc_error.message =~ "Invalid uuid" + end + + test "returns subjects that are part of the organization", state do + user1_id = UUID.generate() + user2_id = UUID.generate() + user3_id = UUID.generate() + + Support.Factories.RbacUser.insert(user1_id, "User One") + Support.Factories.RbacUser.insert(user2_id, "User Two") + Support.Factories.RbacUser.insert(user3_id, "User Three") + + Support.Rbac.assign_org_role_by_name(@org_id, user1_id, "Admin") + Support.Rbac.assign_org_role_by_name(@org_id, user2_id, "Member") + + req = %Request{org_id: @org_id, subject_ids: [user1_id, user2_id, user3_id]} + {:ok, %{subjects: subjects}} = state.grpc_channel |> Stub.list_subjects(req) + + assert length(subjects) == 2 + subject_ids = Enum.map(subjects, & &1.subject_id) + assert user1_id in subject_ids + assert user2_id in subject_ids + refute user3_id in subject_ids + end + + test "returns empty list when no subjects match", state do + user_id = UUID.generate() + Support.Factories.RbacUser.insert(user_id, "User One") + + req = %Request{org_id: @org_id, subject_ids: [user_id]} + {:ok, %{subjects: subjects}} = state.grpc_channel |> Stub.list_subjects(req) + + assert subjects == [] + end + + test "returns subjects with correct type and display name", state do + user_id = UUID.generate() + Support.Factories.RbacUser.insert(user_id, "Test User") + Support.Rbac.assign_org_role_by_name(@org_id, user_id, "Admin") + + req = %Request{org_id: @org_id, subject_ids: [user_id]} + {:ok, %{subjects: subjects}} = state.grpc_channel |> Stub.list_subjects(req) + + assert length(subjects) == 1 + subject = hd(subjects) + assert subject.subject_id == user_id + assert subject.display_name == "Test User" + assert subject.subject_type == :USER + end + + test "returns empty list when subject_ids is empty", state do + req = %Request{org_id: @org_id, subject_ids: []} + {:ok, %{subjects: subjects}} = state.grpc_channel |> Stub.list_subjects(req) + + assert subjects == [] + end + + test "filters subjects by organization correctly", state do + other_org_id = UUID.generate() + user_id = UUID.generate() + + Support.Factories.RbacUser.insert(user_id, "User One") + Support.Rbac.create_org_roles(other_org_id) + Support.Rbac.assign_org_role_by_name(other_org_id, user_id, "Admin") + + req = %Request{org_id: @org_id, subject_ids: [user_id]} + {:ok, %{subjects: subjects}} = state.grpc_channel |> Stub.list_subjects(req) + + assert subjects == [] + end + end + ### ### Helper functions ### diff --git a/rbac/ce/lib/internal_api/rbac.pb.ex b/rbac/ce/lib/internal_api/rbac.pb.ex index 5c7b4a4b4..a0568caea 100644 --- a/rbac/ce/lib/internal_api/rbac.pb.ex +++ b/rbac/ce/lib/internal_api/rbac.pb.ex @@ -380,6 +380,23 @@ defmodule InternalApi.RBAC.Permission do field(:scope, 4, type: InternalApi.RBAC.Scope, enum: true) end +defmodule InternalApi.RBAC.ListSubjectsRequest do + @moduledoc false + + use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.13.0" + + field(:org_id, 1, type: :string, json_name: "orgId") + field(:subject_ids, 2, repeated: true, type: :string, json_name: "subjectIds") +end + +defmodule InternalApi.RBAC.ListSubjectsResponse do + @moduledoc false + + use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.13.0" + + field(:subjects, 1, repeated: true, type: InternalApi.RBAC.Subject) +end + defmodule InternalApi.RBAC.RBAC.Service do @moduledoc false @@ -436,6 +453,8 @@ defmodule InternalApi.RBAC.RBAC.Service do InternalApi.RBAC.RefreshCollaboratorsRequest, InternalApi.RBAC.RefreshCollaboratorsResponse ) + + rpc(:ListSubjects, InternalApi.RBAC.ListSubjectsRequest, InternalApi.RBAC.ListSubjectsResponse) end defmodule InternalApi.RBAC.RBAC.Stub do diff --git a/rbac/ce/lib/rbac/grpc_servers/rbac_server.ex b/rbac/ce/lib/rbac/grpc_servers/rbac_server.ex index 67422ba3b..a09b8e829 100644 --- a/rbac/ce/lib/rbac/grpc_servers/rbac_server.ex +++ b/rbac/ce/lib/rbac/grpc_servers/rbac_server.ex @@ -261,6 +261,21 @@ defmodule Rbac.GrpcServers.RbacServer do end) end + @spec list_subjects(RBAC.ListSubjectsRequest.t(), GRPC.Server.Stream.t()) :: + RBAC.ListSubjectsResponse.t() + def list_subjects(%RBAC.ListSubjectsRequest{} = req, _stream) do + Log.observe("grpc.rbac.list_subjects", fn -> + validate_uuid!(req.org_id) + + role_assignments = RoleAssignment.find_by_ids_and_org(req.subject_ids, req.org_id) + display_names_by_id = fetch_display_names(role_assignments) + + %RBAC.ListSubjectsResponse{ + subjects: Enum.map(role_assignments, &construct_grpc_subject(&1, display_names_by_id)) + } + end) + end + # ---------------- # Helper functions # ---------------- @@ -539,4 +554,14 @@ defmodule Rbac.GrpcServers.RbacServer do _ -> "user" end end + + defp construct_grpc_subject(assignment, display_names_by_id) do + subject_type = assignment.subject_type |> String.upcase() |> String.to_existing_atom() + + %RBAC.Subject{ + subject_type: subject_type, + subject_id: assignment.user_id, + display_name: display_names_by_id[assignment.user_id] || "" + } + end end diff --git a/rbac/ce/lib/rbac/models/role_assignment.ex b/rbac/ce/lib/rbac/models/role_assignment.ex index 4ff5b1aac..989a698c0 100644 --- a/rbac/ce/lib/rbac/models/role_assignment.ex +++ b/rbac/ce/lib/rbac/models/role_assignment.ex @@ -73,6 +73,18 @@ defmodule Rbac.Models.RoleAssignment do |> Repo.all() end + @doc """ + Finds role assignments by subject IDs and organization ID. + Returns distinct assignments filtered by org_id. + """ + def find_by_ids_and_org(subject_ids, org_id) do + from(r in __MODULE__, + where: r.user_id in ^subject_ids and r.org_id == ^org_id, + distinct: r.user_id + ) + |> Repo.all() + end + @doc """ Get user ids that are owner or admin in the given organization """ diff --git a/rbac/ce/test/rbac/grpc_servers/rbac_server_test.exs b/rbac/ce/test/rbac/grpc_servers/rbac_server_test.exs index 719250b84..064da1794 100644 --- a/rbac/ce/test/rbac/grpc_servers/rbac_server_test.exs +++ b/rbac/ce/test/rbac/grpc_servers/rbac_server_test.exs @@ -1141,6 +1141,137 @@ defmodule Rbac.GrpcServers.RbacServerTest do end end + describe "list_subjects/2" do + test "invalid org_id returns error", %{channel: channel} do + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: "invalid-uuid", + subject_ids: [] + } + + {:error, grpc_error} = Stub.list_subjects(channel, request) + assert grpc_error.message =~ "Invalid uuid" + end + + test "returns subjects that are part of the organization", %{channel: channel} do + org_id = Ecto.UUID.generate() + user1_id = Ecto.UUID.generate() + user2_id = Ecto.UUID.generate() + user3_id = Ecto.UUID.generate() + + Rbac.Support.RoleAssignmentsFixtures.role_assignment_fixture(%{ + user_id: user1_id, + role_id: Rbac.Roles.Admin.role().id, + org_id: org_id + }) + + Rbac.Support.RoleAssignmentsFixtures.role_assignment_fixture(%{ + user_id: user2_id, + role_id: Rbac.Roles.Member.role().id, + org_id: org_id + }) + + GrpcMock.stub(UserMock, :describe_many, fn _request, _ -> + %InternalApi.User.DescribeManyResponse{ + users: [ + %InternalApi.User.User{id: user1_id, name: "User One"}, + %InternalApi.User.User{id: user2_id, name: "User Two"} + ] + } + end) + + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: org_id, + subject_ids: [user1_id, user2_id, user3_id] + } + + {:ok, response} = Stub.list_subjects(channel, request) + + assert length(response.subjects) == 2 + subject_ids = Enum.map(response.subjects, & &1.subject_id) + assert user1_id in subject_ids + assert user2_id in subject_ids + refute user3_id in subject_ids + end + + test "returns empty list when no subjects match", %{channel: channel} do + org_id = Ecto.UUID.generate() + user_id = Ecto.UUID.generate() + + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: org_id, + subject_ids: [user_id] + } + + {:ok, response} = Stub.list_subjects(channel, request) + + assert response.subjects == [] + end + + test "returns subjects with correct type and display name", %{channel: channel} do + org_id = Ecto.UUID.generate() + user_id = Ecto.UUID.generate() + + Rbac.Support.RoleAssignmentsFixtures.role_assignment_fixture(%{ + user_id: user_id, + role_id: Rbac.Roles.Admin.role().id, + org_id: org_id + }) + + GrpcMock.stub(UserMock, :describe_many, fn _request, _ -> + %InternalApi.User.DescribeManyResponse{ + users: [%InternalApi.User.User{id: user_id, name: "Test User"}] + } + end) + + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: org_id, + subject_ids: [user_id] + } + + {:ok, response} = Stub.list_subjects(channel, request) + + assert length(response.subjects) == 1 + subject = hd(response.subjects) + assert subject.subject_id == user_id + assert subject.display_name == "Test User" + assert subject.subject_type == :USER + end + + test "returns empty list when subject_ids is empty", %{channel: channel} do + org_id = Ecto.UUID.generate() + + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: org_id, + subject_ids: [] + } + + {:ok, response} = Stub.list_subjects(channel, request) + + assert response.subjects == [] + end + + test "filters subjects by organization correctly", %{channel: channel} do + org_id = Ecto.UUID.generate() + other_org_id = Ecto.UUID.generate() + user_id = Ecto.UUID.generate() + + Rbac.Support.RoleAssignmentsFixtures.role_assignment_fixture(%{ + user_id: user_id, + role_id: Rbac.Roles.Admin.role().id, + org_id: other_org_id + }) + + request = %InternalApi.RBAC.ListSubjectsRequest{ + org_id: org_id, + subject_ids: [user_id] + } + + {:ok, response} = Stub.list_subjects(channel, request) + + assert response.subjects == [] + end + end + defp setup_assign_and_retract(channel) do alias InternalApi.{User, ResponseStatus}