Merge pull request 'Allow creating accounts without a password in the AdminAPI, update docs' (#7937) from mkljczk/pleroma:admin-api-passwordless-user-creation into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7937
This commit is contained in:
lain 2026-07-27 07:17:14 +00:00
commit 8ab0e046b1
8 changed files with 478 additions and 39 deletions

View file

@ -0,0 +1 @@
Allow creating accounts without a password in the AdminAPI

View file

@ -80,10 +80,24 @@ The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/ad
{ {
`nickname`, `nickname`,
`email`, `email`,
`password` `password` (optional; when provided, must not be empty)
} }
] ]
- 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` ## `POST /api/v1/pleroma/admin/users/follow`

View file

@ -8,13 +8,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do
import Pleroma.Web.ControllerHelper, import Pleroma.Web.ControllerHelper,
only: [fetch_integer_param: 3] only: [fetch_integer_param: 3]
alias Ecto.Multi
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.PasswordResetToken
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50 @users_page_size 50
@ -148,9 +152,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do
} = conn, } = conn,
_ _
) do ) do
changesets = conn =
if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do
put_private(conn, :skip_idempotency_cache, true)
else
conn
end
multi =
users users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} -> |> Enum.map(fn %{nickname: nickname, email: email} = attrs ->
{password, passwordless?} =
case Map.fetch(attrs, :password) do
{:ok, password} ->
{password, false}
:error ->
{:crypto.strong_rand_bytes(16) |> Base.encode64(), true}
end
user_data = %{ user_data = %{
nickname: nickname, nickname: nickname,
name: nickname, name: nickname,
@ -160,44 +180,80 @@ defmodule Pleroma.Web.AdminAPI.UserController do
bio: "." bio: "."
} }
User.register_changeset(%User{}, user_data, need_confirmation: false) {User.register_changeset(%User{}, user_data, confirmed: true), passwordless?}
end) end)
|> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> |> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi ->
Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) 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) end)
case Pleroma.Repo.transaction(changesets) do case Pleroma.Repo.transaction(multi) do
{:ok, users_map} -> {:ok, results} ->
users = {users, password_reset_tokens} =
users_map results
|> Map.values() |> Map.values()
|> Enum.split_with(&match?(%User{}, &1))
users =
users
|> Enum.map(fn user -> |> Enum.map(fn user ->
{:ok, user} = User.post_register_action(user) {:ok, user} = User.post_register_action(user)
user user
end) end)
password_reset_links =
Map.new(password_reset_tokens, fn token ->
{token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)}
end)
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
actor: admin, actor: admin,
subjects: users, subjects: users,
action: "create" 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, _} -> {:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} ->
changesets = changesets =
Enum.map(changesets.operations, fn Enum.flat_map(multi.operations, fn
{^id, {:changeset, _current_changeset, _}} -> {^id, {:changeset, _current_changeset, _}} ->
changeset [changeset]
{_, {:changeset, current_changeset, _}} -> {{:user, _}, {:changeset, current_changeset, _}} ->
current_changeset [current_changeset]
_ ->
[]
end) end)
conn conn
|> put_status(:conflict) |> put_status(:conflict)
|> render("create_errors.json", changesets: changesets) |> render("create_errors.json", changesets: changesets)
{:error, {:password_reset_token, _}, _reason, _} ->
Pleroma.Web.AdminAPI.FallbackController.call(
conn,
{:password_reset_token_error, :creation_failed}
)
end end
end end

View file

@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
} }
end end
def render("created_many.json", %{users: users}) do def render("created_many.json", %{users: users} = assigns) do
render_many(users, AccountView, "created.json", as: :user) password_reset_links = Map.get(assigns, :password_reset_links, %{})
render_many(users, AccountView, "created.json",
as: :user,
password_reset_links: password_reset_links
)
end end
def render("created.json", %{user: user}) do def render("created.json", %{user: user} = assigns) do
%{ password_reset_links = Map.get(assigns, :password_reset_links, %{})
type: "success",
code: 200, data =
data: %{ %{
nickname: user.nickname, nickname: user.nickname,
email: user.email email: user.email
} }
|> maybe_put_password_reset_link(Map.get(password_reset_links, user.id))
%{
type: "success",
code: 200,
data: data
} }
end end
@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
} }
end 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 def merge_account_views(%User{} = user) do
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
|> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user}))

View file

@ -82,8 +82,15 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
properties: %{ properties: %{
nickname: %Schema{type: :string}, nickname: %Schema{type: :string},
email: %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. When provided, " <>
"it must not be empty."
}
},
required: [:nickname, :email]
} }
} }
} }
@ -102,7 +109,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
type: :object, type: :object,
properties: %{ properties: %{
email: %Schema{type: :string, format: :email}, email: %Schema{type: :string, format: :email},
nickname: %Schema{type: :string} nickname: %Schema{type: :string},
password_reset_link: %Schema{type: :string}
} }
} }
} }

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
import Phoenix.Controller, only: [json: 2] import Phoenix.Controller, only: [json: 2]
import Plug.Conn import Plug.Conn
alias Pleroma.Config
@behaviour Plug @behaviour Plug
@cachex Pleroma.Config.get([:cachex, :provider], Cachex) @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
def call(conn, _), do: conn def call(conn, _), do: conn
def process_request(conn, key) do 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} -> {:ok, nil} ->
cache_resposnse(conn, key) cache_resposnse(conn, key, cache_key)
{:ok, record} -> {:ok, record} ->
send_cached(conn, key, record) send_cached(conn, key, record)
@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
end end
end end
defp cache_resposnse(conn, key) do defp cache_resposnse(conn, key, cache_key) do
register_before_send(conn, fn conn -> register_before_send(conn, &maybe_cache_response(&1, key, cache_key))
[request_id] = get_resp_header(conn, "x-request-id") end
content_type = get_content_type(conn)
record = {request_id, content_type, conn.status, conn.resp_body} defp maybe_cache_response(
{:ok, _} = @cachex.put(:idempotency_cache, key, record) %{private: %{skip_idempotency_cache: true}} = conn,
_key,
_cache_key
),
do: conn
conn defp maybe_cache_response(conn, key, cache_key) do
|> put_resp_header("idempotency-key", key) [request_id] = get_resp_header(conn, "x-request-id")
|> put_resp_header("x-original-request-id", request_id) content_type = get_content_type(conn)
end)
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 end
defp send_cached(conn, key, record) do defp send_cached(conn, key, record) do

View file

@ -176,6 +176,183 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == []
end 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
|> 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)
|> 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"]
assert link =~ "/api/v1/pleroma/password_reset/"
assert account2["type"] == "success"
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
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
{:error, :forced}
else
result = passthrough([user])
send(self(), {:token_created, user.nickname})
result
end
end do
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
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
|> 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 test "Cannot create user with existing email", %{conn: conn} do
user = insert(:user) user = insert(:user)

View file

@ -70,6 +70,139 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlugTest do
assert [^key] = Conn.get_resp_header(conn, "idempotency-key") assert [^key] = Conn.get_resp_header(conn, "idempotency-key")
end 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 test "passes conn downstream if idempotency is not present in headers" do
orig_request_id = "test4" orig_request_id = "test4"
body = "testing" body = "testing"