feat(api): admin-initiated user creation via POST /admin/users (0.43.0)
Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
This commit is contained in:
@@ -397,3 +397,209 @@ async fn delete_writes_audit_row(pool: PgPool) {
|
||||
assert_eq!(payload["username"], b_name);
|
||||
assert_eq!(payload["was_admin"], false);
|
||||
}
|
||||
|
||||
// ---- POST /admin/users (admin-create) --------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_requires_admin(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_username, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_unauthenticated_is_rejected(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_happy_path_creates_user_and_audit(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "invited01", "password": "freshpass1234" }),
|
||||
&a_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["username"], "invited01");
|
||||
assert_eq!(body["is_admin"], false);
|
||||
assert!(body["id"].as_str().is_some());
|
||||
assert!(
|
||||
body.get("password_hash").is_none(),
|
||||
"password_hash must never appear in admin-create response"
|
||||
);
|
||||
|
||||
let target_id =
|
||||
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
|
||||
let (actor, action, kind, target, payload): (
|
||||
Option<Uuid>,
|
||||
String,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
serde_json::Value,
|
||||
) = sqlx::query_as(
|
||||
"SELECT actor_user_id, action, target_kind, target_id, payload \
|
||||
FROM admin_audit ORDER BY at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actor, Some(a_id));
|
||||
assert_eq!(action, "create_user");
|
||||
assert_eq!(kind, "user");
|
||||
assert_eq!(target, Some(target_id));
|
||||
assert_eq!(payload["username"], "invited01");
|
||||
assert_eq!(payload["is_admin"], false);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_can_mint_an_admin_in_one_call(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({
|
||||
"username": "newadmin",
|
||||
"password": "freshpass1234",
|
||||
"is_admin": true
|
||||
}),
|
||||
&a_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["is_admin"], true);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_returns_409_on_duplicate(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||
// Seed an existing user via the public register path.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/register",
|
||||
json!({ "username": "taken", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "Taken", "password": "freshpass1234" }),
|
||||
&a_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"case-insensitive collision via the lower(username) index"
|
||||
);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "conflict");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_rejects_weak_password(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "okayname", "password": "short" }),
|
||||
&a_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "invalid_input");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_rejects_invalid_username(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "bad name!", "password": "freshpass1234" }),
|
||||
&a_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_user_works_even_when_self_register_disabled(pool: PgPool) {
|
||||
// The admin-create path must NOT be gated by ALLOW_SELF_REGISTER —
|
||||
// that's the entire point of having an admin-create endpoint.
|
||||
let h = common::harness_with_self_register_disabled(pool.clone());
|
||||
// Bootstrap an admin out-of-band since self-register would refuse.
|
||||
repo::user::bootstrap_admin(&pool, "root", "hunter2hunter2")
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "root", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let cookie = common::extract_session_cookie(&resp).unwrap();
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/admin/users",
|
||||
json!({ "username": "invited01", "password": "freshpass1234" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"admin must be able to mint users even with self-register off"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user