Skip to content
Closed
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
82 changes: 39 additions & 43 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
use axum::{Json, Router, response::Html, routing::get};
use axum::{
Json, Router,
http::{HeaderValue, Method, header},
response::Html,
routing::get,
};
use dotenvy::dotenv;
use std::net::SocketAddr;
use tower_http::cors::AllowOrigin;
use tower_http::cors::CorsLayer;
use utoipa::OpenApi;

mod constants;
Expand All @@ -20,6 +27,36 @@ async fn main() {
.init();
let db = db::connect().await.expect("DB connection failed");

// front end url from env
// e.g. FRONTEND_URL="http://localhost:3000,https://mydomain.com"
let env_frontend_url =
std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let frontend_urls: Vec<HeaderValue> = env_frontend_url
.split(',')
.map(|s| {
s.trim()
.parse::<HeaderValue>()
.expect("invalid FRONTEND_URL")
})
.collect();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(frontend_urls))
.allow_credentials(true)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::CONTENT_TYPE,
header::AUTHORIZATION,
header::ACCEPT,
header::ORIGIN,
]);

// configure CORS
let app = Router::new()
.route("/api-docs/openapi.json", get(openapi_json))
Expand All @@ -30,11 +67,11 @@ async fn main() {
.merge(routes::sessions::routes())
.merge(routes::email_verify::routes())
// apply simple CORS middleware (handles preflight and adds headers)
.layer(axum::middleware::from_fn(cors_middleware))
.layer(axum::middleware::from_fn_with_state(
db.clone(),
middleware::auth::auth_middleware,
))
.layer(cors)
.with_state(db);

let addr = SocketAddr::from(([0, 0, 0, 0], 8001));
Expand Down Expand Up @@ -77,44 +114,3 @@ async fn swagger_ui_html() -> Html<&'static str> {
"#,
)
}

async fn cors_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> impl axum::response::IntoResponse {
use axum::http::{HeaderName, HeaderValue};

if req.method() == &axum::http::Method::OPTIONS {
let mut res = axum::http::Response::new(axum::body::Body::empty());
let headers = res.headers_mut();
headers.insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
headers.insert(
HeaderName::from_static("access-control-allow-headers"),
HeaderValue::from_static("*"),
);
headers.insert(
HeaderName::from_static("access-control-allow-methods"),
HeaderValue::from_static("GET,POST,PUT,DELETE,OPTIONS"),
);
return res;
}

let mut res = next.run(req).await;
let headers = res.headers_mut();
headers.insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
headers.insert(
HeaderName::from_static("access-control-allow-headers"),
HeaderValue::from_static("*"),
);
headers.insert(
HeaderName::from_static("access-control-allow-methods"),
HeaderValue::from_static("GET,POST,PUT,DELETE,OPTIONS"),
);
res
}
2 changes: 1 addition & 1 deletion src/models/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl Related<super::user_role::Entity> for Entity {
}

// User -> role (many-to-many 本体)
impl Related<super::role::Entity> for Entity {
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
super::user_role::Relation::User.def()
}
Expand Down
2 changes: 2 additions & 0 deletions src/routes/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ pub fn routes() -> Router<DbConn> {
.put(put_role),
)
.merge(roles_sub::search::routes())
.merge(roles_sub::permissions::routes())
.merge(roles_sub::users::routes())
}

/// すべてのロールを取得するための関数
Expand Down
2 changes: 2 additions & 0 deletions src/routes/roles_sub/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pub mod permissions;
pub mod search;
pub mod users;
99 changes: 99 additions & 0 deletions src/routes/roles_sub/permissions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use crate::{
constants::permissions::Permission, middleware::auth::AuthUser, models::user::Entity as User,
routes::users_sub::permissions::PermissionsResponse,
};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::*,
};
use sea_orm::*;

pub fn routes() -> Router<DbConn> {
Router::new().route("/users/{id}/permissions", get(get_permissions_bit))
}

/// ユーザーのロール一覧を取得し権限bitを合成する
#[utoipa::path(
get,
path = "/users/{id}/permissions",
tag = "users",
params(
("id" = String, Path, description = "ユーザーID")
),
responses(
(status = 200, description = "権限情報取得成功", body = PermissionsResponse),
(status = 403, description = "アクセス権限なし"),
(status = 404, description = "ユーザーが見つからない")
),
security(
("session_token" = [])
)
)]
pub async fn get_permissions_bit(
State(db): State<DbConn>,
Path(id): Path<String>,
auth_user: axum::Extension<AuthUser>,
) -> Result<impl IntoResponse, StatusCode> {
// Self-only access OR read permission
if auth_user.user_id != id {
let user_roles = crate::models::user_role::Entity::find()
.filter(crate::models::user_role::Column::UserId.eq(&auth_user.user_id))
.find_with_related(crate::models::role::Entity)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

let has_read_permission = user_roles.iter().any(|(_, roles)| {
roles
.iter()
.any(|r| (r.permission as i64 & Permission::USER_READ.bits() as i64) != 0)
});

if !has_read_permission {
return Err(StatusCode::FORBIDDEN);
}
}

let user = User::find_by_id(id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

if let Some(user) = user {
let roles = user
.find_related(crate::models::role::Entity)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

let mut permissions_bit: i64 = 0;
for role in roles {
permissions_bit |= role.permission as i64;
}
// bitだけではなく、テキストベースでも返す
let mut permissions_text = Vec::new();

let perm_u32 = permissions_bit as u32;
let (mut known_names, known_mask) = Permission::names_from_bits(perm_u32);
permissions_text.append(&mut known_names);

// 未定義のビットは PERMISSION_<index> でフォールバック
let remaining = perm_u32 & !known_mask;
for i in 0..32 {
if (remaining & (1u32 << i)) != 0 {
permissions_text.push(format!("PERMISSION_{}", i));
}
}
return Ok((
StatusCode::OK,
Json(PermissionsResponse {
permissions_bit,
permissions_text,
}),
));
}
Err(StatusCode::NOT_FOUND)
}
154 changes: 154 additions & 0 deletions src/routes/roles_sub/users.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::*,
};
use sea_orm::*;

use crate::{
constants::permissions::Permission,
middleware::{auth::AuthUser, permission_check},
models::role::Entity as Role,
models::user::Entity as User,
routes::{common_dtos::array_dto::ApiResponse, roles::RoleResponse},
};

pub fn routes() -> Router<DbConn> {
Router::new()
.route("/roles/{id}/users", get(get_all_users))
.route("/roles/{id}/users/{uid}", delete(delete_role).put(put_role))
//.merge(users_sub::books::routes())
}

/// すべてのロールを取得するための関数
#[utoipa::path(
get,
path = "/roles/{id}/users",
tag = "roles",
params(
("id" = String, Path, description = "ロールID")
),
responses(
(status = 200, description = "ロールに紐つくユーザー一覧取得成功"),
(status = 404, description = "ロールが見つからない")
),
security(
("session_token" = [])
)
)]
pub async fn get_all_users(
State(db): State<DbConn>,
Path(id): Path<String>,
auth_user: axum::Extension<AuthUser>,
) -> Result<impl IntoResponse, StatusCode> {
permission_check::require_permission(&auth_user, Permission::PERMISSION_MANAGE, &db).await?;

let role_with_users = Role::find_by_id(id.clone())
.find_with_related(User)
.all(&db)
.await
.unwrap();
if let Some((_, users)) = role_with_users.into_iter().next() {
let responses: Vec<crate::routes::users::DetailedUserResponse> = users
.into_iter()
.map(crate::routes::users::DetailedUserResponse::from)
.collect();
return Ok((StatusCode::OK, Json(ApiResponse { data: responses })));
}
Err(StatusCode::NOT_FOUND)
}

// ユーザーにロールを付与する
#[utoipa::path(
put,
path = "/roles/{id}/users/{uid}",
tag = "roles",
params(
("id" = String, Path, description = "ロールID"),
("uid" = String, Path, description = "ユーザーID")
),
responses(
(status = 201, description = "ロール付与成功", body = crate::routes::roles::RoleResponse),
(status = 403, description = "アクセス権限なし"),
(status = 404, description = "ユーザーまたはロールが見つからない")
),
security(
("session_token" = [])
)
)]
pub async fn put_role(
State(db): State<DbConn>,
Path((id, uid)): Path<(String, String)>,
auth_user: axum::Extension<AuthUser>,
) -> Result<impl IntoResponse, StatusCode> {
permission_check::require_permission(&auth_user, Permission::PERMISSION_MANAGE, &db).await?;

// user と role を同時に取りに行く(並列)
let (user_res, role_res) = futures::join!(
User::find_by_id(uid.clone()).one(&db),
Role::find_by_id(id.clone()).one(&db)
);

match (user_res.unwrap(), role_res.unwrap()) {
(Some(user), Some(role)) => {
let user_role = crate::models::user_role::ActiveModel {
user_id: Set(user.id.clone()),
role_id: Set(role.id.clone()),
..Default::default()
};
// 既にある場合はエラーになるかもしれないから、必要なら重複チェックを追加
let _ = user_role.insert(&db).await.unwrap();
Ok((StatusCode::CREATED, Json(RoleResponse::from(role))))
}
_ => Err(StatusCode::NOT_FOUND),
}
}

/// ユーザーからロールを削除する
#[utoipa::path(
delete,
path = "/roles/{id}/users/{uid}",
tag = "roles",
params(
("id" = String, Path, description = "ロールID"),
("uid" = String, Path, description = "ユーザーID")
),
responses(
(status = 204, description = "ロール削除成功"),
(status = 403, description = "アクセス権限なし"),
(status = 404, description = "ユーザーまたはロールが見つからない")
),
security(
("session_token" = [])
)
)]
pub async fn delete_role(
State(db): State<DbConn>,
Path((uid, id)): Path<(String, String)>,
auth_user: axum::Extension<AuthUser>,
) -> Result<impl IntoResponse, StatusCode> {
permission_check::require_permission(&auth_user, Permission::PERMISSION_MANAGE, &db).await?;

// まず role の存在は確認しておくとレスポンスに role を返せる(現在の実装と同じ振る舞い)
if let Some(role) = Role::find_by_id(id.clone()).one(&db).await.unwrap() {
// 中間テーブルの該当行を直接削除
let res = crate::models::user_role::Entity::delete_many()
.filter(
crate::models::user_role::Column::UserId
.eq(uid.clone())
.and(crate::models::user_role::Column::RoleId.eq(id.clone())),
)
.exec(&db)
.await
.unwrap();

if res.rows_affected > 0 {
return Ok((StatusCode::NO_CONTENT, Json(RoleResponse::from(role))));
} else {
return Err(StatusCode::NOT_FOUND);
}
}
Err(StatusCode::NOT_FOUND)
}
Loading