1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

admin: add APIs to list and get users

This commit is contained in:
Quentin Gliech
2024-07-24 16:19:31 +02:00
parent c177233b33
commit f5b4caf520
9 changed files with 1176 additions and 3 deletions

View File

@ -25,7 +25,9 @@ use tower_http::cors::{Any, CorsLayer};
mod call_context;
mod model;
mod params;
mod response;
mod v1;
use self::call_context::CallContext;
@ -36,7 +38,7 @@ where
{
let mut api = OpenApi::default();
let router = ApiRouter::<S>::new()
// TODO: add routes
.nest("/api/admin/v1", self::v1::router())
.finish_api_with(&mut api, |t| {
t.title("Matrix Authentication Service admin API")
.security_scheme(

View File

@ -12,10 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::Serialize;
use ulid::Ulid;
/// A resource, with a type and an ID
#[allow(dead_code)]
pub trait Resource {
/// The type of the resource
const KIND: &'static str;
@ -33,3 +35,72 @@ pub trait Resource {
format!("{}/{}", Self::PATH, self.id())
}
}
/// A user
#[derive(Serialize, JsonSchema)]
pub struct User {
#[serde(skip)]
id: Ulid,
/// The username (localpart) of the user
username: String,
/// When the user was created
created_at: DateTime<Utc>,
/// When the user was locked. If null, the user is not locked.
locked_at: Option<DateTime<Utc>>,
/// Whether the user can request admin privileges.
can_request_admin: bool,
}
impl User {
/// Samples of users with different properties for examples in the schema
pub fn samples() -> [Self; 3] {
[
Self {
id: Ulid::from_bytes([0x01; 16]),
username: "alice".to_owned(),
created_at: DateTime::default(),
locked_at: None,
can_request_admin: false,
},
Self {
id: Ulid::from_bytes([0x02; 16]),
username: "bob".to_owned(),
created_at: DateTime::default(),
locked_at: None,
can_request_admin: true,
},
Self {
id: Ulid::from_bytes([0x03; 16]),
username: "charlie".to_owned(),
created_at: DateTime::default(),
locked_at: Some(DateTime::default()),
can_request_admin: false,
},
]
}
}
impl From<mas_data_model::User> for User {
fn from(user: mas_data_model::User) -> Self {
Self {
id: user.id,
username: user.username,
created_at: user.created_at,
locked_at: user.locked_at,
can_request_admin: user.can_request_admin,
}
}
}
impl Resource for User {
const KIND: &'static str = "user";
const PATH: &'static str = "/api/admin/v1/users";
fn id(&self) -> Ulid {
self.id
}
}

View File

@ -0,0 +1,154 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Generated code from schemars violates this rule
#![allow(clippy::str_to_string)]
use std::num::NonZeroUsize;
use aide::OperationIo;
use async_trait::async_trait;
use axum::{
extract::{
rejection::{PathRejection, QueryRejection},
FromRequestParts, Path, Query,
},
response::IntoResponse,
Json,
};
use axum_macros::FromRequestParts;
use hyper::StatusCode;
use mas_storage::pagination::PaginationDirection;
use schemars::JsonSchema;
use serde::Deserialize;
use ulid::Ulid;
use super::response::ErrorResponse;
#[derive(Debug, thiserror::Error)]
#[error("Invalid ULID in path")]
pub struct UlidPathParamRejection(#[from] PathRejection);
impl IntoResponse for UlidPathParamRejection {
fn into_response(self) -> axum::response::Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse::from_error(&self)),
)
.into_response()
}
}
#[derive(JsonSchema, Debug, Clone, Copy, Deserialize)]
struct UlidInPath {
#[schemars(
with = "String",
title = "ULID",
description = "A ULID as per https://github.com/ulid/spec",
regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$")
)]
id: Ulid,
}
#[derive(FromRequestParts, OperationIo, Debug, Clone, Copy)]
#[from_request(rejection(UlidPathParamRejection))]
#[aide(input_with = "Path<UlidInPath>")]
pub struct UlidPathParam(#[from_request(via(Path))] UlidInPath);
impl std::ops::Deref for UlidPathParam {
type Target = Ulid;
fn deref(&self) -> &Self::Target {
&self.0.id
}
}
/// The default page size if not specified
const DEFAULT_PAGE_SIZE: usize = 10;
#[derive(Deserialize, JsonSchema, Clone, Copy)]
struct PaginationParams {
/// Retrieve the items before the given ID
#[serde(rename = "page[before]")]
#[schemars(with = "Option<String>")]
before: Option<Ulid>,
/// Retrieve the items after the given ID
#[serde(rename = "page[after]")]
#[schemars(with = "Option<String>")]
after: Option<Ulid>,
/// Retrieve the first N items
#[serde(rename = "page[first]")]
first: Option<NonZeroUsize>,
/// Retrieve the last N items
#[serde(rename = "page[last]")]
last: Option<NonZeroUsize>,
}
#[derive(Debug, thiserror::Error)]
pub enum PaginationRejection {
#[error("Invalid pagination parameters")]
Invalid(#[from] QueryRejection),
#[error("Cannot specify both `page[first]` and `page[last]` parameters")]
FirstAndLast,
}
impl IntoResponse for PaginationRejection {
fn into_response(self) -> axum::response::Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse::from_error(&self)),
)
.into_response()
}
}
/// An extractor for pagination parameters in the query string
#[derive(OperationIo, Debug, Clone, Copy)]
#[aide(input_with = "Query<PaginationParams>")]
pub struct Pagination(pub mas_storage::Pagination);
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for Pagination {
type Rejection = PaginationRejection;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let params = Query::<PaginationParams>::from_request_parts(parts, state).await?;
// Figure out the direction and the count out of the first and last parameters
let (direction, count) = match (params.first, params.last) {
// Make sure we don't specify both first and last
(Some(_), Some(_)) => return Err(PaginationRejection::FirstAndLast),
// Default to forward pagination with a default page size
(None, None) => (PaginationDirection::Forward, DEFAULT_PAGE_SIZE),
(Some(first), None) => (PaginationDirection::Forward, first.into()),
(None, Some(last)) => (PaginationDirection::Backward, last.into()),
};
Ok(Self(mas_storage::Pagination {
before: params.before,
after: params.after,
direction,
count,
}))
}
}

View File

@ -0,0 +1,37 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use aide::axum::{routing::get_with, ApiRouter};
use axum::extract::FromRequestParts;
use super::call_context::CallContext;
mod users;
pub fn router<S>() -> ApiRouter<S>
where
S: Clone + Send + Sync + 'static,
CallContext: FromRequestParts<S>,
{
ApiRouter::<S>::new()
.api_route("/users", get_with(self::users::list, self::users::list_doc))
.api_route(
"/users/:id",
get_with(self::users::get, self::users::get_doc),
)
.api_route(
"/users/by-username/:username",
get_with(self::users::by_username, self::users::by_username_doc),
)
}

View File

@ -0,0 +1,87 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use aide::{transform::TransformOperation, OperationIo};
use axum::{extract::Path, response::IntoResponse, Json};
use hyper::StatusCode;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
admin::{
call_context::CallContext,
model::User,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("User with username {0:?} not found")]
NotFound(String),
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound(_) => StatusCode::NOT_FOUND,
};
(status, Json(error)).into_response()
}
}
#[derive(Deserialize, JsonSchema)]
pub struct UsernamePathParam {
/// The username (localpart) of the user to get
username: String,
}
pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.description("Get a user by its username (localpart)")
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
let [sample, ..] = User::samples();
let response =
SingleResponse::new(sample, "/api/admin/v1/users/by-username/alice".to_owned());
t.description("User was found").example(response)
})
.response_with::<404, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::NotFound("alice".to_owned()));
t.description("User was not found").example(response)
})
}
#[tracing::instrument(name = "handler.admin.v1.users.by_username", skip_all, err)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
Path(UsernamePathParam { username }): Path<UsernamePathParam>,
) -> Result<Json<SingleResponse<User>>, RouteError> {
let self_path = format!("/api/admin/v1/users/by-username/{username}");
let user = repo
.user()
.find_by_username(&username)
.await?
.ok_or(RouteError::NotFound(username))?;
Ok(Json(SingleResponse::new(User::from(user), self_path)))
}

View File

@ -0,0 +1,79 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use aide::{transform::TransformOperation, OperationIo};
use axum::{response::IntoResponse, Json};
use hyper::StatusCode;
use ulid::Ulid;
use crate::{
admin::{
call_context::CallContext,
model::User,
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("User ID {0} not found")]
NotFound(Ulid),
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound(_) => StatusCode::NOT_FOUND,
};
(status, Json(error)).into_response()
}
}
pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.description("Get a user")
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
let [sample, ..] = User::samples();
let response = SingleResponse::new_canonical(sample);
t.description("User was found").example(response)
})
.response_with::<404, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
t.description("User was not found").example(response)
})
}
#[tracing::instrument(name = "handler.admin.v1.users.get", skip_all, err)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, RouteError> {
let user = repo
.user()
.lookup(*id)
.await?
.ok_or(RouteError::NotFound(*id))?;
Ok(Json(SingleResponse::new_canonical(User::from(user))))
}

View File

@ -0,0 +1,135 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use aide::{transform::TransformOperation, OperationIo};
use axum::{
extract::{rejection::QueryRejection, Query},
response::IntoResponse,
Json,
};
use axum_macros::FromRequestParts;
use hyper::StatusCode;
use mas_storage::{user::UserFilter, Page};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
admin::{
call_context::CallContext,
model::{Resource, User},
params::Pagination,
response::{ErrorResponse, PaginatedResponse},
},
impl_from_error_for_route,
};
#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum UserStatus {
/// The user is active
Active,
/// The user is locked
Locked,
}
#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
#[aide(input_with = "Query<FilterParams>")]
#[from_request(via(Query), rejection(RouteError))]
pub struct FilterParams {
#[serde(rename = "filter[can_request_admin]")]
can_request_admin: Option<bool>,
#[serde(rename = "filter[status]")]
status: Option<UserStatus>,
}
impl<'a> From<&'a FilterParams> for UserFilter<'a> {
fn from(val: &'a FilterParams) -> Self {
let filter = UserFilter::default();
let filter = match val.can_request_admin {
Some(true) => filter.can_request_admin_only(),
Some(false) => filter.cannot_request_admin_only(),
None => filter,
};
let filter = match val.status {
Some(UserStatus::Active) => filter.active_only(),
Some(UserStatus::Locked) => filter.locked_only(),
None => filter,
};
filter
}
}
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("Invalid filter parameters")]
InvalidFilter(#[from] QueryRejection),
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
};
(status, Json(error)).into_response()
}
}
pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.description("List users")
.response_with::<200, Json<PaginatedResponse<User>>, _>(|t| {
let users = User::samples();
let pagination = mas_storage::Pagination::first(users.len());
let page = Page {
edges: users.into(),
has_next_page: true,
has_previous_page: false,
};
t.description("Paginated response of users")
.example(PaginatedResponse::new(page, pagination, 42, User::PATH))
})
}
#[tracing::instrument(name = "handler.admin.v1.users.list", skip_all, err)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
Pagination(pagination): Pagination,
filter: FilterParams,
) -> Result<Json<PaginatedResponse<User>>, RouteError> {
let filter = UserFilter::from(&filter);
let page = repo.user().list(filter, pagination).await?;
let count = repo.user().count(filter).await?;
Ok(Json(PaginatedResponse::new(
page.map(User::from),
pagination,
count,
User::PATH,
)))
}

View File

@ -0,0 +1,23 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod by_username;
mod get;
mod list;
pub use self::{
by_username::{doc as by_username_doc, handler as by_username},
get::{doc as get_doc, handler as get},
list::{doc as list_doc, handler as list},
};