Skip to content

Commit 64ee2c4

Browse files
committed
controllers: add new /api/v1/users/:user_id/admin route
This provides an admin-only view of a user, including otherwise sensitive information such as their e-mail address and account lock status.
1 parent 064536e commit 64ee2c4

13 files changed

+232
-1
lines changed

src/controllers/user.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod admin;
12
pub mod email_notifications;
23
pub mod email_verification;
34
pub mod me;

src/controllers/user/admin.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use axum::{extract::Path, Json};
2+
use crates_io_database::schema::{emails, users};
3+
use diesel::{pg::Pg, prelude::*};
4+
use diesel_async::{AsyncConnection, RunQueryDsl};
5+
use http::request::Parts;
6+
7+
use crate::{
8+
app::AppState, auth::AuthCheck, models::User, sql::lower, util::errors::AppResult,
9+
views::EncodableAdminUser,
10+
};
11+
12+
/// Find user by login, returning the admin's view of the user.
13+
///
14+
/// Only site admins can use this endpoint.
15+
#[utoipa::path(
16+
get,
17+
path = "/api/v1/users/{user}/admin",
18+
params(
19+
("user" = String, Path, description = "Login name of the user"),
20+
),
21+
tags = ["admin", "users"],
22+
responses((status = 200, description = "Successful Response")),
23+
)]
24+
pub async fn get(
25+
state: AppState,
26+
Path(user_name): Path<String>,
27+
req: Parts,
28+
) -> AppResult<Json<EncodableAdminUser>> {
29+
let mut conn = state.db_read_prefer_primary().await?;
30+
AuthCheck::only_cookie()
31+
.require_admin()
32+
.check(&req, &mut conn)
33+
.await?;
34+
35+
get_user(
36+
|query| query.filter(lower(users::gh_login).eq(lower(user_name))),
37+
&mut conn,
38+
)
39+
.await
40+
.map(Json)
41+
}
42+
43+
/// A helper to get an [`EncodableAdminUser`] based on whatever filter predicate
44+
/// is provided in the callback.
45+
///
46+
/// It would be ill advised to do anything in `filter` other than calling
47+
/// [`QueryDsl::filter`] on the given query, but I'm not the boss of you.
48+
async fn get_user<Conn, F>(filter: F, conn: &mut Conn) -> AppResult<EncodableAdminUser>
49+
where
50+
Conn: AsyncConnection<Backend = Pg>,
51+
F: FnOnce(users::BoxedQuery<'_, Pg>) -> users::BoxedQuery<'_, Pg>,
52+
{
53+
let query = filter(users::table.into_boxed());
54+
55+
let (user, verified, email, verification_sent): (User, Option<bool>, Option<String>, bool) =
56+
query
57+
.left_join(emails::table)
58+
.select((
59+
User::as_select(),
60+
emails::verified.nullable(),
61+
emails::email.nullable(),
62+
emails::token_generated_at.nullable().is_not_null(),
63+
))
64+
.first(conn)
65+
.await?;
66+
67+
let verified = verified.unwrap_or(false);
68+
let verification_sent = verified || verification_sent;
69+
Ok(EncodableAdminUser::from(
70+
user,
71+
email,
72+
verified,
73+
verification_sent,
74+
))
75+
}

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
6161
.routes(routes!(team::find_team))
6262
.routes(routes!(user::me::get_authenticated_user))
6363
.routes(routes!(user::me::get_authenticated_user_updates))
64+
.routes(routes!(user::admin::get))
6465
.routes(routes!(token::list_api_tokens, token::create_api_token))
6566
.routes(routes!(token::find_api_token, token::revoke_api_token))
6667
.routes(routes!(token::revoke_current_api_token))

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
---
22
source: src/openapi.rs
33
expression: response.json()
4-
snapshot_kind: text
54
---
65
{
76
"components": {},
@@ -1650,6 +1649,33 @@ snapshot_kind: text
16501649
"users"
16511650
]
16521651
}
1652+
},
1653+
"/api/v1/users/{user}/admin": {
1654+
"get": {
1655+
"description": "Only site admins can use this endpoint.",
1656+
"operationId": "get",
1657+
"parameters": [
1658+
{
1659+
"description": "Login name of the user",
1660+
"in": "path",
1661+
"name": "user",
1662+
"required": true,
1663+
"schema": {
1664+
"type": "string"
1665+
}
1666+
}
1667+
],
1668+
"responses": {
1669+
"200": {
1670+
"description": "Successful Response"
1671+
}
1672+
},
1673+
"summary": "Find user by login, returning the admin's view of the user.",
1674+
"tags": [
1675+
"admin",
1676+
"users"
1677+
]
1678+
}
16531679
}
16541680
},
16551681
"servers": [

src/tests/routes/users/admin.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use http::StatusCode;
2+
use insta::{assert_json_snapshot, assert_snapshot};
3+
4+
use crate::tests::util::{RequestHelper, TestApp};
5+
6+
mod get {
7+
use super::*;
8+
9+
#[tokio::test(flavor = "multi_thread")]
10+
async fn get() {
11+
let (app, anon, user) = TestApp::init().with_user().await;
12+
let admin = app.db_new_admin_user("admin").await;
13+
14+
// Anonymous users should be forbidden.
15+
let response = anon.get::<()>("/api/v1/users/foo/admin").await;
16+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
17+
assert_snapshot!("anonymous-found", response.text());
18+
19+
let response = anon.get::<()>("/api/v1/users/bar/admin").await;
20+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
21+
assert_snapshot!("anonymous-not-found", response.text());
22+
23+
// Regular users should also be forbidden, even if they're requesting
24+
// themself.
25+
let response = user.get::<()>("/api/v1/users/foo/admin").await;
26+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
27+
assert_snapshot!("non-admin-found", response.text());
28+
29+
let response = user.get::<()>("/api/v1/users/bar/admin").await;
30+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
31+
assert_snapshot!("non-admin-not-found", response.text());
32+
33+
// Admin users are allowed, but still can't manifest users who don't
34+
// exist.
35+
let response = admin.get::<()>("/api/v1/users/bar/admin").await;
36+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
37+
assert_snapshot!("admin-not-found", response.text());
38+
39+
// Admin users are allowed, and should get an admin's eye view of the
40+
// requested user.
41+
let response = admin.get::<()>("/api/v1/users/foo/admin").await;
42+
assert_eq!(response.status(), StatusCode::OK);
43+
assert_json_snapshot!("admin-found", response.json());
44+
}
45+
}

src/tests/routes/users/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod admin;
12
mod read;
23
mod stats;
34
pub mod update;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.json()
4+
---
5+
{
6+
"avatar": null,
7+
"email": "[email protected]",
8+
"email_verification_sent": true,
9+
"email_verified": true,
10+
"id": 1,
11+
"is_admin": false,
12+
"lock": null,
13+
"login": "foo",
14+
"name": null,
15+
"publish_notifications": true,
16+
"url": "https://github.com/foo"
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"Not Found"}]}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"this action requires authentication"}]}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"this action requires authentication"}]}

0 commit comments

Comments
 (0)