From 36bc7b116d7b66a47f19e53bba3631b010030876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 20 Jul 2026 19:01:02 +0200 Subject: [PATCH 1/7] Allow creating accounts without a password in the AdminAPI, update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../admin-api-passwordless-accounts.add | 1 + docs/development/API/admin_api.md | 18 +++++++- .../admin_api/controllers/user_controller.ex | 43 ++++++++++++++++--- .../web/admin_api/views/account_view.ex | 30 ++++++++++--- .../operations/admin/user_operation.ex | 13 ++++-- .../controllers/user_controller_test.exs | 28 ++++++++++++ 6 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 changelog.d/admin-api-passwordless-accounts.add diff --git a/changelog.d/admin-api-passwordless-accounts.add b/changelog.d/admin-api-passwordless-accounts.add new file mode 100644 index 000000000..f5ea2626f --- /dev/null +++ b/changelog.d/admin-api-passwordless-accounts.add @@ -0,0 +1 @@ +Allow creating accounts without a password in the AdminAPI diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index 3719ceeb9..ef04944af 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -80,10 +80,24 @@ The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/ad { `nickname`, `email`, - `password` + `password` (optional) } ] -- Response: Array of user objects +- Response: Array of user objects. When a user is created without a `password`, `data` + object contains a `password_reset_link` the user can follow to set their own password. + +```json +[ + { + "data": { + "nickname": string, + "email": string, + "password_reset_link": string (optional) + }, + "type": "success" + } +] +``` ## `POST /api/v1/pleroma/admin/users/follow` diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 9ac275396..23af5b53c 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -8,13 +8,16 @@ defmodule Pleroma.Web.AdminAPI.UserController do import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 3] + alias Ecto.Multi alias Pleroma.ModerationLog alias Pleroma.User alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Search + alias Pleroma.Web.Endpoint alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Router @users_page_size 50 @@ -148,9 +151,24 @@ defmodule Pleroma.Web.AdminAPI.UserController do } = conn, _ ) do - changesets = + # Nicknames of users for which a password reset link will be generated. + passwordless_nicknames = users - |> Enum.map(fn %{nickname: nickname, email: email, password: password} -> + |> Enum.filter(fn user -> Map.get(user, :password) in ["", nil] end) + |> Enum.map(& &1.nickname) + + multi = + users + |> Enum.map(fn %{nickname: nickname, email: email} = attrs -> + password = + case Map.get(attrs, :password) do + password when is_binary(password) and password != "" -> + password + + _ -> + :crypto.strong_rand_bytes(16) |> Base.encode64() + end + user_data = %{ nickname: nickname, name: nickname, @@ -162,11 +180,11 @@ defmodule Pleroma.Web.AdminAPI.UserController do User.register_changeset(%User{}, user_data, need_confirmation: false) end) - |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> - Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + |> Enum.reduce(Multi.new(), fn changeset, multi -> + Multi.insert(multi, "user-" <> changeset.changes.nickname, changeset) end) - case Pleroma.Repo.transaction(changesets) do + case Pleroma.Repo.transaction(multi) do {:ok, users_map} -> users = users_map @@ -177,17 +195,28 @@ defmodule Pleroma.Web.AdminAPI.UserController do user end) + password_reset_links = + users + |> Enum.filter(fn user -> Enum.member?(passwordless_nicknames, user.nickname) end) + |> Map.new(fn user -> + {:ok, token} = Pleroma.PasswordResetToken.create_token(user) + {user.id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)} + end) + ModerationLog.insert_log(%{ actor: admin, subjects: users, action: "create" }) - render(conn, "created_many.json", users: users) + render(conn, "created_many.json", + users: users, + password_reset_links: password_reset_links + ) {:error, id, changeset, _} -> changesets = - Enum.map(changesets.operations, fn + Enum.map(multi.operations, fn {^id, {:changeset, _current_changeset, _}} -> changeset diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 280152241..cc715c2a5 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end - def render("created_many.json", %{users: users}) do - render_many(users, AccountView, "created.json", as: :user) + def render("created_many.json", %{users: users} = assigns) do + password_reset_links = Map.get(assigns, :password_reset_links, %{}) + + render_many(users, AccountView, "created.json", + as: :user, + password_reset_links: password_reset_links + ) end - def render("created.json", %{user: user}) do - %{ - type: "success", - code: 200, - data: %{ + def render("created.json", %{user: user} = assigns) do + password_reset_links = Map.get(assigns, :password_reset_links, %{}) + + data = + %{ nickname: user.nickname, email: user.email } + |> maybe_put_password_reset_link(Map.get(password_reset_links, user.id)) + + %{ + type: "success", + code: 200, + data: data } end @@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do } end + defp maybe_put_password_reset_link(data, nil), do: data + + defp maybe_put_password_reset_link(data, link), + do: Map.put(data, :password_reset_link, link) + def merge_account_views(%User{} = user) do MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex index a5179ac39..84cf789d8 100644 --- a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -82,8 +82,14 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do properties: %{ nickname: %Schema{type: :string}, email: %Schema{type: :string}, - password: %Schema{type: :string} - } + password: %Schema{ + type: :string, + description: + "Optional. When omitted, a `password_reset_link` is returned, " <> + "allowing the user to set their own password." + } + }, + required: [:nickname, :email] } } } @@ -102,7 +108,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do type: :object, properties: %{ email: %Schema{type: :string, format: :email}, - nickname: %Schema{type: :string} + nickname: %Schema{type: :string}, + password_reset_link: %Schema{type: :string} } } } diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 0e5650285..9808f9d0d 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -176,6 +176,34 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] end + test "Creating without a password returns a password reset link", %{conn: conn} do + [account1, account2] = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => "lain@example.org" + }, + %{ + "nickname" => "lain2", + "email" => "lain2@example.org", + "password" => "dupa.8" + } + ] + }) + |> json_response_and_validate_schema(200) + + assert account1["type"] == "success" + assert %{"password_reset_link" => link} = account1["data"] + assert link =~ "/api/v1/pleroma/password_reset/" + + assert account2["type"] == "success" + refute Map.has_key?(account2["data"], "password_reset_link") + end + test "Cannot create user with existing email", %{conn: conn} do user = insert(:user) From 34d827730ac3c1305c1170ed51f9907a499b9680 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 22:15:14 +0400 Subject: [PATCH 2/7] fix(admin-api): preserve user batch validation --- .../admin_api/controllers/user_controller.ex | 2 +- .../controllers/user_controller_test.exs | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 23af5b53c..4552f32c7 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -181,7 +181,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do User.register_changeset(%User{}, user_data, need_confirmation: false) end) |> Enum.reduce(Multi.new(), fn changeset, multi -> - Multi.insert(multi, "user-" <> changeset.changes.nickname, changeset) + Multi.insert(multi, Ecto.UUID.generate(), changeset) end) case Pleroma.Repo.transaction(multi) do diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 9808f9d0d..c2802164d 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -177,7 +177,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do end test "Creating without a password returns a password reset link", %{conn: conn} do - [account1, account2] = + accounts = conn |> put_req_header("accept", "application/json") |> put_req_header("content-type", "application/json") @@ -195,6 +195,10 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do ] }) |> json_response_and_validate_schema(200) + |> Map.new(fn account -> {account["data"]["nickname"], account} end) + + account1 = accounts["lain"] + account2 = accounts["lain2"] assert account1["type"] == "success" assert %{"password_reset_link" => link} = account1["data"] @@ -204,6 +208,36 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do refute Map.has_key?(account2["data"], "password_reset_link") end + test "Duplicate nicknames in a batch return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain1@example.org", "password" => "test"}, + %{"nickname" => "lain", "email" => "lain2@example.org", "password" => "test"} + ] + }) + + assert [_first, _second] = json_response(conn, 409) + refute User.get_by_nickname("lain") + end + + test "Blank nicknames return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "", "email" => "lain@example.org", "password" => "test"} + ] + }) + + assert [%{"error" => "nickname can't be blank"}] = json_response(conn, 409) + end + test "Cannot create user with existing email", %{conn: conn} do user = insert(:user) From d57fdcc7b03dc1381687a49f6afc266439d5c103 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 22:25:44 +0400 Subject: [PATCH 3/7] fix(admin-api): create reset tokens atomically --- .../admin_api/controllers/user_controller.ex | 63 ++++++++++++------- .../controllers/user_controller_test.exs | 30 +++++++++ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 4552f32c7..7146e4bea 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do alias Ecto.Multi alias Pleroma.ModerationLog + alias Pleroma.PasswordResetToken alias Pleroma.User alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline @@ -151,15 +152,11 @@ defmodule Pleroma.Web.AdminAPI.UserController do } = conn, _ ) do - # Nicknames of users for which a password reset link will be generated. - passwordless_nicknames = - users - |> Enum.filter(fn user -> Map.get(user, :password) in ["", nil] end) - |> Enum.map(& &1.nickname) - multi = users |> Enum.map(fn %{nickname: nickname, email: email} = attrs -> + passwordless? = Map.get(attrs, :password) in ["", nil] + password = case Map.get(attrs, :password) do password when is_binary(password) and password != "" -> @@ -178,17 +175,36 @@ defmodule Pleroma.Web.AdminAPI.UserController do bio: "." } - User.register_changeset(%User{}, user_data, need_confirmation: false) + {User.register_changeset(%User{}, user_data, need_confirmation: false), passwordless?} end) - |> Enum.reduce(Multi.new(), fn changeset, multi -> - Multi.insert(multi, Ecto.UUID.generate(), changeset) + |> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi -> + user_operation = {:user, Ecto.UUID.generate()} + multi = Multi.insert(multi, user_operation, changeset) + + if passwordless? do + Multi.run( + multi, + {:password_reset_token, Ecto.UUID.generate()}, + fn _repo, changes -> + changes + |> Map.fetch!(user_operation) + |> PasswordResetToken.create_token() + end + ) + else + multi + end end) case Pleroma.Repo.transaction(multi) do - {:ok, users_map} -> - users = - users_map + {:ok, results} -> + {users, password_reset_tokens} = + results |> Map.values() + |> Enum.split_with(&match?(%User{}, &1)) + + users = + users |> Enum.map(fn user -> {:ok, user} = User.post_register_action(user) @@ -196,11 +212,8 @@ defmodule Pleroma.Web.AdminAPI.UserController do end) password_reset_links = - users - |> Enum.filter(fn user -> Enum.member?(passwordless_nicknames, user.nickname) end) - |> Map.new(fn user -> - {:ok, token} = Pleroma.PasswordResetToken.create_token(user) - {user.id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)} + Map.new(password_reset_tokens, fn token -> + {token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)} end) ModerationLog.insert_log(%{ @@ -214,19 +227,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do password_reset_links: password_reset_links ) - {:error, id, changeset, _} -> + {:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} -> changesets = - Enum.map(multi.operations, fn + Enum.flat_map(multi.operations, fn {^id, {:changeset, _current_changeset, _}} -> - changeset + [changeset] - {_, {:changeset, current_changeset, _}} -> - current_changeset + {{:user, _}, {:changeset, current_changeset, _}} -> + [current_changeset] + + _ -> + [] end) conn |> put_status(:conflict) |> render("create_errors.json", changesets: changesets) + + {:error, {:password_reset_token, _}, _reason, _} -> + raise "Failed to create password reset token" end end diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index c2802164d..b6c19f37c 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -208,6 +208,36 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do refute Map.has_key?(account2["data"], "password_reset_link") end + test "Password reset token failures roll back the user batch", %{conn: conn} do + with_mock Pleroma.PasswordResetToken, [:passthrough], + create_token: fn user -> + if user.nickname == "lain2" do + {:error, :forced} + else + result = passthrough([user]) + send(self(), {:token_created, user.nickname}) + result + end + end do + assert_error_sent(500, fn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org"}, + %{"nickname" => "lain2", "email" => "lain2@example.org"} + ] + }) + end) + end + + assert_received {:token_created, "lain"} + refute User.get_by_nickname("lain") + refute User.get_by_nickname("lain2") + assert Repo.aggregate(Pleroma.PasswordResetToken, :count) == 0 + end + test "Duplicate nicknames in a batch return a conflict", %{conn: conn} do conn = conn From 3b3aad606508ecf1801a24420143d700c4cf01c6 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 22:45:50 +0400 Subject: [PATCH 4/7] fix(api): isolate sensitive idempotency responses --- .../admin_api/controllers/user_controller.ex | 7 + lib/pleroma/web/plugs/idempotency_plug.ex | 58 ++++++-- .../controllers/user_controller_test.exs | 45 ++++++ .../web/plugs/idempotency_plug_test.exs | 133 ++++++++++++++++++ 4 files changed, 231 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 7146e4bea..c94964937 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -152,6 +152,13 @@ defmodule Pleroma.Web.AdminAPI.UserController do } = conn, _ ) do + conn = + if Enum.any?(users, fn user -> Map.get(user, :password) in ["", nil] end) do + put_private(conn, :skip_idempotency_cache, true) + else + conn + end + multi = users |> Enum.map(fn %{nickname: nickname, email: email} = attrs -> diff --git a/lib/pleroma/web/plugs/idempotency_plug.ex b/lib/pleroma/web/plugs/idempotency_plug.ex index a3b7af869..81983efba 100644 --- a/lib/pleroma/web/plugs/idempotency_plug.ex +++ b/lib/pleroma/web/plugs/idempotency_plug.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do import Phoenix.Controller, only: [json: 2] import Plug.Conn + alias Pleroma.Config + @behaviour Plug @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do def call(conn, _), do: conn def process_request(conn, key) do - case @cachex.get(:idempotency_cache, key) do + cache_key = {conn.method, conn.request_path, actor_key(conn), key} + + case @cachex.get(:idempotency_cache, cache_key) do {:ok, nil} -> - cache_resposnse(conn, key) + cache_resposnse(conn, key, cache_key) {:ok, record} -> send_cached(conn, key, record) @@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do end end - defp cache_resposnse(conn, key) do - register_before_send(conn, fn conn -> - [request_id] = get_resp_header(conn, "x-request-id") - content_type = get_content_type(conn) + defp cache_resposnse(conn, key, cache_key) do + register_before_send(conn, &maybe_cache_response(&1, key, cache_key)) + end - record = {request_id, content_type, conn.status, conn.resp_body} - {:ok, _} = @cachex.put(:idempotency_cache, key, record) + defp maybe_cache_response( + %{private: %{skip_idempotency_cache: true}} = conn, + _key, + _cache_key + ), + do: conn - conn - |> put_resp_header("idempotency-key", key) - |> put_resp_header("x-original-request-id", request_id) - end) + defp maybe_cache_response(conn, key, cache_key) do + [request_id] = get_resp_header(conn, "x-request-id") + content_type = get_content_type(conn) + + record = {request_id, content_type, conn.status, conn.resp_body} + {:ok, _} = @cachex.put(:idempotency_cache, cache_key, record) + + conn + |> put_resp_header("idempotency-key", key) + |> put_resp_header("x-original-request-id", request_id) + end + + defp actor_key(%{assigns: assigns}) do + {principal_key(assigns), token_key(assigns[:token]), staff_privileges()} + end + + defp principal_key(%{user: %{id: id} = user}) do + {:user, id, Map.get(user, :is_admin), Map.get(user, :is_moderator)} + end + + defp principal_key(%{app: %{id: id}}), do: {:app, id} + defp principal_key(_assigns), do: nil + + defp token_key(%{id: id} = token), do: {id, token |> Map.get(:scopes, []) |> Enum.sort()} + defp token_key(_token), do: nil + + defp staff_privileges do + { + Config.get([:instance, :admin_privileges], []) |> Enum.sort(), + Config.get([:instance, :moderator_privileges], []) |> Enum.sort() + } end defp send_cached(conn, key, record) do diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index b6c19f37c..32f2c1d30 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -238,6 +238,51 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do assert Repo.aggregate(Pleroma.PasswordResetToken, :count) == 0 end + test "Password reset links are not cached by idempotency key", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [%{"nickname" => "lain", "email" => "lain@example.org"}] + }) + end + + assert [%{"data" => %{"password_reset_link" => _link}}] = + conn |> request.() |> json_response(200) + + second_conn = request.(conn) + + assert [_error] = json_response(second_conn, 409) + refute get_resp_header(second_conn, "idempotent-replayed") == ["true"] + end + + test "Explicit-password responses remain idempotent", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => "test"} + ] + }) + end + + assert [_account] = conn |> request.() |> json_response(200) + + second_conn = request.(conn) + + assert [_account] = json_response(second_conn, 200) + assert get_resp_header(second_conn, "idempotent-replayed") == ["true"] + end + test "Duplicate nicknames in a batch return a conflict", %{conn: conn} do conn = conn diff --git a/test/pleroma/web/plugs/idempotency_plug_test.exs b/test/pleroma/web/plugs/idempotency_plug_test.exs index 3b0131596..cc7580ca2 100644 --- a/test/pleroma/web/plugs/idempotency_plug_test.exs +++ b/test/pleroma/web/plugs/idempotency_plug_test.exs @@ -70,6 +70,139 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlugTest do assert [^key] = Conn.get_resp_header(conn, "idempotency-key") end + test "does not cache responses marked as sensitive" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "sensitive-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.put_private(:skip_idempotency_cache, true) + |> Conn.send_resp(200, "secret") + + conn = + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "second-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + + test "does not replay legacy unscoped cache entries" do + key = Ecto.UUID.generate() + record = {"legacy-request", "application/json", 200, "secret"} + assert {:ok, true} = Cachex.put(:idempotency_cache, key, record) + + conn = + :post + |> conn("/cofe") + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "new-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + + test "scopes cached responses to the route and authenticated actor" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "user-one", is_admin: false, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + for {path, user} <- [ + {"/tea", %{id: "user-one", is_admin: false, is_moderator: false}}, + {"/cofe", %{id: "user-two", is_admin: false, is_moderator: false}}, + {"/cofe", %{id: "user-one", is_admin: true, is_moderator: false}} + ] do + conn = + :post + |> conn(path) + |> Conn.assign(:user, user) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", Ecto.UUID.generate()) + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + end + + test "scopes cached responses to the OAuth application and token" do + key = Ecto.UUID.generate() + + :post + |> conn("/cofe") + |> Conn.assign(:app, %{id: "app-one"}) + |> Conn.assign(:token, %{id: "token-one", scopes: ["write"]}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + for {app, token} <- [ + {%{id: "app-two"}, %{id: "token-two", scopes: ["write"]}}, + {%{id: "app-one"}, %{id: "token-two", scopes: ["write"]}}, + {%{id: "app-one"}, %{id: "token-one", scopes: ["read"]}} + ] do + conn = + :post + |> conn("/cofe") + |> Conn.assign(:app, app) + |> Conn.assign(:token, token) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", Ecto.UUID.generate()) + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + end + + test "scopes cached responses to configured staff privileges" do + key = Ecto.UUID.generate() + clear_config([:instance, :admin_privileges], [:users_delete]) + + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "original-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + |> Conn.send_resp(200, "one") + + clear_config([:instance, :admin_privileges], []) + + conn = + :post + |> conn("/cofe") + |> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false}) + |> put_req_header("idempotency-key", key) + |> Conn.put_resp_header("x-request-id", "second-request") + |> Conn.put_resp_content_type("application/json") + |> IdempotencyPlug.call([]) + + refute conn.halted + refute conn.resp_body + end + test "passes conn downstream if idempotency is not present in headers" do orig_request_id = "test4" body = "testing" From 836f4a2d69e3945ca205eb1af7d3ebfea7c3153b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 22:52:23 +0400 Subject: [PATCH 5/7] fix(admin-api): reject explicit empty passwords --- docs/development/API/admin_api.md | 2 +- .../web/admin_api/controllers/user_controller.ex | 16 +++++++--------- .../api_spec/operations/admin/user_operation.ex | 3 ++- .../controllers/user_controller_test.exs | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index ef04944af..a59804c87 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -80,7 +80,7 @@ The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/ad { `nickname`, `email`, - `password` (optional) + `password` (optional; when provided, must not be empty) } ] - Response: Array of user objects. When a user is created without a `password`, `data` diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index c94964937..f0edfe4ba 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -153,7 +153,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do _ ) do conn = - if Enum.any?(users, fn user -> Map.get(user, :password) in ["", nil] end) do + if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do put_private(conn, :skip_idempotency_cache, true) else conn @@ -162,15 +162,13 @@ defmodule Pleroma.Web.AdminAPI.UserController do multi = users |> Enum.map(fn %{nickname: nickname, email: email} = attrs -> - passwordless? = Map.get(attrs, :password) in ["", nil] + {password, passwordless?} = + case Map.fetch(attrs, :password) do + {:ok, password} -> + {password, false} - password = - case Map.get(attrs, :password) do - password when is_binary(password) and password != "" -> - password - - _ -> - :crypto.strong_rand_bytes(16) |> Base.encode64() + :error -> + {:crypto.strong_rand_bytes(16) |> Base.encode64(), true} end user_data = %{ diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex index 84cf789d8..7c4725f56 100644 --- a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -86,7 +86,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do type: :string, description: "Optional. When omitted, a `password_reset_link` is returned, " <> - "allowing the user to set their own password." + "allowing the user to set their own password. When provided, " <> + "it must not be empty." } }, required: [:nickname, :email] diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 32f2c1d30..0bea0c185 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -208,6 +208,21 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do refute Map.has_key?(account2["data"], "password_reset_link") end + test "Explicit empty passwords return a conflict", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => ""} + ] + }) + + assert [%{"error" => "password can't be blank"}] = json_response(conn, 409) + refute User.get_by_nickname("lain") + end + test "Password reset token failures roll back the user batch", %{conn: conn} do with_mock Pleroma.PasswordResetToken, [:passthrough], create_token: fn user -> From d0b5a05075ec9c3566df8de90346c5969c3089f0 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 23:14:03 +0400 Subject: [PATCH 6/7] fix(admin-api): confirm created users --- .../web/admin_api/controllers/user_controller.ex | 2 +- .../controllers/user_controller_test.exs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index f0edfe4ba..82ac4bd9d 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -180,7 +180,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do bio: "." } - {User.register_changeset(%User{}, user_data, need_confirmation: false), passwordless?} + {User.register_changeset(%User{}, user_data, confirmed: true), passwordless?} end) |> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi -> user_operation = {:user, Ecto.UUID.generate()} diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 0bea0c185..7e05d67b7 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -176,6 +176,22 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] end + test "Admin-created users bypass account confirmation", %{conn: conn} do + clear_config([:instance, :account_activation_required], true) + + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org", "password" => "test"} + ] + }) + |> json_response_and_validate_schema(200) + + assert User.get_by_nickname("lain").is_confirmed + end + test "Creating without a password returns a password reset link", %{conn: conn} do accounts = conn From bb4826d8aec57391b29ad3312e34f4b5abe21383 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 23:24:52 +0400 Subject: [PATCH 7/7] fix(admin-api): structure reset token failures --- .../admin_api/controllers/user_controller.ex | 5 ++- .../controllers/user_controller_test.exs | 31 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 82ac4bd9d..ab8bf73ee 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -250,7 +250,10 @@ defmodule Pleroma.Web.AdminAPI.UserController do |> render("create_errors.json", changesets: changesets) {:error, {:password_reset_token, _}, _reason, _} -> - raise "Failed to create password reset token" + Pleroma.Web.AdminAPI.FallbackController.call( + conn, + {:password_reset_token_error, :creation_failed} + ) end end diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index 7e05d67b7..764f5e01f 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -240,6 +240,21 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do end test "Password reset token failures roll back the user batch", %{conn: conn} do + idempotency_key = Ecto.UUID.generate() + + request = fn conn -> + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{"nickname" => "lain", "email" => "lain@example.org"}, + %{"nickname" => "lain2", "email" => "lain2@example.org"} + ] + }) + end + with_mock Pleroma.PasswordResetToken, [:passthrough], create_token: fn user -> if user.nickname == "lain2" do @@ -250,23 +265,17 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do result end end do - assert_error_sent(500, fn -> - conn - |> put_req_header("accept", "application/json") - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{"nickname" => "lain", "email" => "lain@example.org"}, - %{"nickname" => "lain2", "email" => "lain2@example.org"} - ] - }) - end) + conn = request.(conn) + + assert json_response(conn, 500) == %{"error" => "Something went wrong"} end assert_received {:token_created, "lain"} refute User.get_by_nickname("lain") refute User.get_by_nickname("lain2") assert Repo.aggregate(Pleroma.PasswordResetToken, :count) == 0 + + assert [_account1, _account2] = conn |> request.() |> json_response(200) end test "Password reset links are not cached by idempotency key", %{conn: conn} do