Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a basic user admin backend #10245

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct AuthCheck {
allow_token: bool,
endpoint_scope: Option<EndpointScope>,
crate_name: Option<String>,
only_admin: bool,
}

impl AuthCheck {
Expand All @@ -29,6 +30,7 @@ impl AuthCheck {
allow_token: true,
endpoint_scope: None,
crate_name: None,
only_admin: false,
}
}

Expand All @@ -38,6 +40,7 @@ impl AuthCheck {
allow_token: false,
endpoint_scope: None,
crate_name: None,
only_admin: false,
}
}

Expand All @@ -46,6 +49,7 @@ impl AuthCheck {
allow_token: self.allow_token,
endpoint_scope: Some(endpoint_scope),
crate_name: self.crate_name.clone(),
only_admin: false,
}
}

Expand All @@ -54,9 +58,15 @@ impl AuthCheck {
allow_token: self.allow_token,
endpoint_scope: self.endpoint_scope,
crate_name: Some(crate_name.to_string()),
only_admin: false,
}
}

pub fn require_admin(mut self) -> Self {
self.only_admin = true;
self
}

#[instrument(name = "auth.check", skip_all)]
pub async fn check(
&self,
Expand All @@ -65,6 +75,15 @@ impl AuthCheck {
) -> AppResult<Authentication> {
let auth = authenticate(parts, conn).await?;

if self.only_admin && !auth.user().is_admin {
let error_message = "User must be an admin to access this API";
parts.request_log().add("cause", error_message);

return Err(forbidden(
"this action can only be performed by a site admin",
));
}

if let Some(token) = auth.api_token() {
if !self.allow_token {
let error_message =
Expand Down
1 change: 1 addition & 0 deletions src/controllers/user.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod admin;
pub mod email_notifications;
pub mod email_verification;
pub mod me;
Expand Down
185 changes: 185 additions & 0 deletions src/controllers/user/admin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use axum::{extract::Path, Json};
use chrono::{NaiveDateTime, Utc};
use crates_io_database::schema::{emails, users};
use diesel::{pg::Pg, prelude::*};
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl};
use http::request::Parts;
use utoipa::ToSchema;

use crate::{
app::AppState, auth::AuthCheck, models::User, sql::lower, util::errors::AppResult,
util::rfc3339, views::EncodableAdminUser,
};

/// Find user by login, returning the admin's view of the user.
///
/// Only site admins can use this endpoint.
#[utoipa::path(
get,
path = "/api/v1/users/{user}/admin",
params(
("user" = String, Path, description = "Login name of the user"),
),
tags = ["admin", "users"],
responses((status = 200, description = "Successful Response")),
)]
pub async fn get(
state: AppState,
Path(user_name): Path<String>,
req: Parts,
) -> AppResult<Json<EncodableAdminUser>> {
let mut conn = state.db_read_prefer_primary().await?;
AuthCheck::only_cookie()
.require_admin()
.check(&req, &mut conn)
.await?;

get_user(
|query| query.filter(lower(users::gh_login).eq(lower(user_name))),
&mut conn,
)
.await
.map(Json)
}

#[derive(Deserialize, ToSchema)]
pub struct LockRequest {
/// The reason for locking the account. This is visible to the user.
reason: String,

/// When to lock the account until. If omitted, the lock will be indefinite.
#[serde(default, with = "rfc3339::option")]
until: Option<NaiveDateTime>,
}

/// Lock the given user.
///
/// Only site admins can use this endpoint.
#[utoipa::path(
put,
path = "/api/v1/users/{user}/lock",
params(
("user" = String, Path, description = "Login name of the user"),
),
request_body = LockRequest,
tags = ["admin", "users"],
responses((status = 200, description = "Successful Response")),
)]
pub async fn lock(
state: AppState,
Path(user_name): Path<String>,
req: Parts,
Json(LockRequest { reason, until }): Json<LockRequest>,
) -> AppResult<Json<EncodableAdminUser>> {
Comment on lines +55 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for new APIs I would prefer to avoid such "custom action" API endpoints and use a PATCH API instead (PATCH /api/v1/users/{user}/admin?) like we started for versions too and also to some degree already have for users (using PUT unfortunately...).

this will allow us to "easily" extend the functionality of this endpoint in the future instead of having to add new endpoints for basically every field we want to change.

let mut conn = state.db_read_prefer_primary().await?;
AuthCheck::only_cookie()
.require_admin()
.check(&req, &mut conn)
.await?;

// In theory, we could cook up a complicated update query that returns
// everything we need to build an `EncodableAdminUser`, but that feels hard.
// Instead, let's use a small transaction to get the same effect.
let user = conn
.transaction(|conn| {
async move {
let id = diesel::update(users::table)
.filter(lower(users::gh_login).eq(lower(user_name)))
.set((
users::account_lock_reason.eq(reason),
users::account_lock_until.eq(until),
))
.returning(users::id)
.get_result::<i32>(conn)
.await?;

get_user(|query| query.filter(users::id.eq(id)), conn).await
}
.scope_boxed()
})
.await?;

Ok(Json(user))
}

/// Unlock the given user.
///
/// Only site admins can use this endpoint.
#[utoipa::path(
delete,
path = "/api/v1/users/{user}/lock",
params(
("user" = String, Path, description = "Login name of the user"),
),
tags = ["admin", "users"],
responses((status = 200, description = "Successful Response")),
)]
pub async fn unlock(
state: AppState,
Path(user_name): Path<String>,
req: Parts,
) -> AppResult<Json<EncodableAdminUser>> {
let mut conn = state.db_read_prefer_primary().await?;
AuthCheck::only_cookie()
.require_admin()
.check(&req, &mut conn)
.await?;

// Again, let's do this in a transaction, even though we _technically_ don't
// need to.
let user = conn
.transaction(|conn| {
// Although this is called via the `DELETE` method, this is
// implemented as a soft deletion by setting the lock until time to
// now, thereby allowing us to have some sense of history of whether
// an account has been locked in the past.
async move {
let id = diesel::update(users::table)
.filter(lower(users::gh_login).eq(lower(user_name)))
.set(users::account_lock_until.eq(Utc::now().naive_utc()))
.returning(users::id)
.get_result::<i32>(conn)
.await?;

get_user(|query| query.filter(users::id.eq(id)), conn).await
}
.scope_boxed()
})
.await?;

Ok(Json(user))
}

/// A helper to get an [`EncodableAdminUser`] based on whatever filter predicate
/// is provided in the callback.
///
/// It would be ill advised to do anything in `filter` other than calling
/// [`QueryDsl::filter`] on the given query, but I'm not the boss of you.
async fn get_user<Conn, F>(filter: F, conn: &mut Conn) -> AppResult<EncodableAdminUser>
where
Conn: AsyncConnection<Backend = Pg>,
F: FnOnce(users::BoxedQuery<'_, Pg>) -> users::BoxedQuery<'_, Pg>,
{
let query = filter(users::table.into_boxed());

let (user, verified, email, verification_sent): (User, Option<bool>, Option<String>, bool) =
query
.left_join(emails::table)
.select((
User::as_select(),
emails::verified.nullable(),
emails::email.nullable(),
emails::token_generated_at.nullable().is_not_null(),
))
.first(conn)
.await?;

let verified = verified.unwrap_or(false);
let verification_sent = verified || verification_sent;
Ok(EncodableAdminUser::from(
user,
email,
verified,
verification_sent,
))
}
2 changes: 2 additions & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
.routes(routes!(team::find_team))
.routes(routes!(user::me::get_authenticated_user))
.routes(routes!(user::me::get_authenticated_user_updates))
.routes(routes!(user::admin::get))
.routes(routes!(user::admin::lock, user::admin::unlock))
.routes(routes!(token::list_api_tokens, token::create_api_token))
.routes(routes!(token::find_api_token, token::revoke_api_token))
.routes(routes!(token::revoke_current_api_token))
Expand Down
115 changes: 113 additions & 2 deletions src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
---
source: src/openapi.rs
expression: response.json()
snapshot_kind: text
---
{
"components": {},
"components": {
"schemas": {
"LockRequest": {
"properties": {
"reason": {
"description": "The reason for locking the account. This is visible to the user.",
"type": "string"
},
"until": {
"description": "When to lock the account until. If omitted, the lock will be indefinite.",
"format": "date-time",
"type": [
"string",
"null"
]
}
},
"required": [
"reason"
],
"type": "object"
}
}
},
"info": {
"contact": {
"email": "[email protected]",
Expand Down Expand Up @@ -1650,6 +1672,95 @@ snapshot_kind: text
"users"
]
}
},
"/api/v1/users/{user}/admin": {
"get": {
"description": "Only site admins can use this endpoint.",
"operationId": "get",
"parameters": [
{
"description": "Login name of the user",
"in": "path",
"name": "user",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful Response"
}
},
"summary": "Find user by login, returning the admin's view of the user.",
"tags": [
"admin",
"users"
]
}
},
"/api/v1/users/{user}/lock": {
"delete": {
"description": "Only site admins can use this endpoint.",
"operationId": "unlock",
"parameters": [
{
"description": "Login name of the user",
"in": "path",
"name": "user",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful Response"
}
},
"summary": "Unlock the given user.",
"tags": [
"admin",
"users"
]
},
"put": {
"description": "Only site admins can use this endpoint.",
"operationId": "lock",
"parameters": [
{
"description": "Login name of the user",
"in": "path",
"name": "user",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LockRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response"
}
},
"summary": "Lock the given user.",
"tags": [
"admin",
"users"
]
}
}
},
"servers": [
Expand Down
Loading