diff --git a/Cargo.lock b/Cargo.lock index 864fc61962..104e74e4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,15 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -1007,7 +1016,7 @@ dependencies = [ "rand", "rand_core", "regex", - "reqwest", + "reqwest 0.11.27", "rsa", "rust-embed", "rust-ini", @@ -1035,6 +1044,7 @@ dependencies = [ "tracing-subscriber", "uaparser", "utoipa", + "utoipa-swagger-ui", "uuid", "webauthn-authenticator-rs", "webauthn-rs", @@ -1077,6 +1087,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "derive_more" version = "0.99.18" @@ -1901,6 +1922,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -1917,6 +1939,24 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.4.0", + "hyper-util", + "rustls 0.23.11", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", + "webpki-roots 0.26.3", +] + [[package]] name = "hyper-timeout" version = "0.4.1" @@ -1949,12 +1989,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" dependencies = [ "bytes", + "futures-channel", "futures-util", "http 1.1.0", "http-body 1.0.0", "hyper 1.4.0", "pin-project-lite", + "socket2", "tokio", + "tower", + "tower-service", + "tracing", ] [[package]] @@ -3236,6 +3281,53 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quinn" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.11", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf517c03a109db8100448a4be38d498df8a210a99fe0e1b9eaf39e78c640efe" +dependencies = [ + "bytes", + "rand", + "ring 0.17.8", + "rustc-hash", + "rustls 0.23.11", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46" +dependencies = [ + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.36" @@ -3375,7 +3467,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.29", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls", "ipnet", "js-sys", @@ -3403,8 +3495,51 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", - "winreg", + "webpki-roots 0.25.4", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.0", + "hyper-rustls 0.27.2", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.11", + "rustls-pemfile 2.1.2", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.26.3", + "winreg 0.52.0", ] [[package]] @@ -3541,6 +3676,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hex" version = "2.1.0" @@ -3604,6 +3745,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls" +version = "0.23.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4828ea528154ae444e5a642dbb7d5623354030dc9822b83fd9bb79683c7399d0" +dependencies = [ + "once_cell", + "ring 0.17.8", + "rustls-pki-types", + "rustls-webpki 0.102.5", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.7.1" @@ -4692,6 +4847,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.11", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -5135,6 +5301,24 @@ dependencies = [ "syn 2.0.68", ] +[[package]] +name = "utoipa-swagger-ui" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943e0ff606c6d57d410fd5663a4d7c074ab2c5f14ab903b9514565e59fa1189e" +dependencies = [ + "axum 0.7.5", + "mime_guess", + "regex", + "reqwest 0.12.5", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.9.1" @@ -5387,6 +5571,15 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.5.1" @@ -5605,6 +5798,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wyz" version = "0.5.1" @@ -5683,3 +5886,19 @@ dependencies = [ "quote", "syn 2.0.68", ] + +[[package]] +name = "zip" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc23c04387f4da0374be4533ad1208cbb091d5c11d070dfef13676ad6497164" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.2.6", + "num_enum", + "thiserror", +] diff --git a/Cargo.toml b/Cargo.toml index 6095cc4795..13e39f3a11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ webauthn-rs-proto = "0.5" x25519-dalek = { version = "2.0", features = ["static_secrets"] } # openapi utoipa = { version = "4", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "7", features = ["axum"] } [dev-dependencies] bytes = "1.6" diff --git a/src/db/models/device.rs b/src/db/models/device.rs index c546e7b523..c137e779c0 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -32,7 +32,7 @@ pub struct DeviceConfig { pub(crate) keepalive_interval: i32, } -#[derive(Clone, Deserialize, Model, Serialize, Debug)] +#[derive(Clone, Deserialize, Model, Serialize, Debug, ToSchema)] pub struct Device { pub id: Option, pub name: String, @@ -176,13 +176,13 @@ pub struct WireguardNetworkDevice { pub authorized_at: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] pub struct AddDevice { pub name: String, pub wireguard_pubkey: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, ToSchema)] pub struct ModifyDevice { pub name: String, pub wireguard_pubkey: String, diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 73f4476188..6f2c907c70 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -1,12 +1,13 @@ use model_derive::Model; use sqlx::{query, query_as, query_scalar, Error as SqlxError, PgConnection, PgExecutor}; +use utoipa::ToSchema; use crate::{ db::{models::error::ModelError, User, WireguardNetwork}, server_config, }; -#[derive(Model, Debug)] +#[derive(Model, Debug, ToSchema)] pub struct Group { pub(crate) id: Option, pub name: String, diff --git a/src/handlers/group.rs b/src/handlers/group.rs index aac79033dc..81e4a7454f 100644 --- a/src/handlers/group.rs +++ b/src/handlers/group.rs @@ -28,7 +28,7 @@ impl Groups { } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone, ToSchema)] pub(crate) struct BulkAssignToGroupsRequest { // groups by name groups: Vec, @@ -36,8 +36,23 @@ pub(crate) struct BulkAssignToGroupsRequest { users: Vec, } -// POST bulk assign users to one group for users overview -// assign many users to many groups at once +/// Bulk assign users to groups +/// +/// Assign many users to many groups at once. +/// +/// # Returns +/// If error occurs, it returns `WebError` object. +#[utoipa::path( + post, + path = "/api/v1/groups-assign", + responses( + (status = 200, description = "Successfully assign users to groups."), + (status = 400, description = "Bad request. Request contains users or groups that don't exist in db.", body = ApiResponse, example = json!({"msg": "Request contained users that doesn't exists in db."})), + (status = 401, description = "Unauthorized to assign users to groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to assign users to groups.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 500, description = "Cannot assign users to groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn bulk_assign_to_groups( _role: UserAdminRole, State(appstate): State, @@ -90,7 +105,31 @@ pub(crate) async fn bulk_assign_to_groups( }) } -/// GET: Retrieve all groups info +/// Retrieve all groups info +/// +/// For each group, the endpoint retrieves a `GroupInfo` object containing: group name, a list of members' usernames and a list of vpn_location. +/// +/// `There is another endpoint "/api/v1/group" that retrives only name of each groups if you don't want all information.` +/// +/// # Returns +/// Returns a list of `GroupInfo` objects or `WebError` if error occurs. +#[utoipa::path( + get, + path = "/api/v1/group-info", + responses( + (status = 200, description = "Successfully listed groups info.", body = [GroupInfo], example = json!([ + { + "name": "name", + "members": ["user"], + "vpn_locations": ["location"] + } + ])), + (status = 401, description = "Unauthorized to list groups info.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 401, description = "Unauthorized to assign users to groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to list groups info.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 500, description = "Cannot list groups info.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn list_groups_info( _role: UserAdminRole, State(appstate): State, @@ -116,13 +155,17 @@ pub(crate) async fn list_groups_info( }) } -/// GET: Retrieve all groups. +/// Retrieve all groups. +/// +/// # Returns +/// Returns a `Groups` object or `WebError` if error occurs. #[utoipa::path( get, path = "/api/v1/group", responses( - (status = 200, description = "Retrieve all groups.", body = Groups), - (status = 403, description = "Forbidden error: ...") + (status = 200, description = "Retrieve all groups.", body = Groups, example = json!({"groups": ["admin"]})), + (status = 401, description = "Unauthorized to retrive all groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Cannot retrive all groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ) )] pub(crate) async fn list_groups( @@ -142,7 +185,29 @@ pub(crate) async fn list_groups( }) } -/// GET: Retrieve group with `name`. +/// Retrieve group with `name`. +/// +/// # Returns +/// Returns a `GroupInfo` object or `WebError` if error occurs. +#[utoipa::path( + get, + path = "/api/v1/group/{name}", + params( + ("name" = String, description = "Group name") + ), + responses( + (status = 200, description = "Retrieve a group.", body = GroupInfo, example = json!( + { + "name": "name", + "members": ["user"], + "vpn_locations": ["location"] + } + )), + (status = 401, description = "Unauthorized to retrive a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Incorrect name of the group.", body = ApiResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Cannot retrive a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn get_group( _session: SessionInfo, State(appstate): State, @@ -164,7 +229,29 @@ pub(crate) async fn get_group( } } -/// POST: Create group with a given name and member list. +/// Create group +/// +/// Create group with a given name and member list. +/// +/// # Returns +/// Returns a `GroupsInfo` object or `WebError` if error occurs. +#[utoipa::path( + post, + path = "/api/v1/group", + request_body = EditGroupInfo, + responses( + (status = 201, description = "Successfully created a group and added users.", body = EditGroupInfo, example = json!( + { + "name": "name", + "members": ["user"] + } + )), + (status = 401, description = "Unauthorized to retrive a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to list groups info.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "Cannot create group: user don't exist.", body = ApiResponse, example = json!({"msg": "Failed to find user "})), + (status = 500, description = "Cannot retrive a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn create_group( _role: UserAdminRole, State(appstate): State, @@ -201,7 +288,24 @@ pub(crate) async fn create_group( }) } -/// PUT: Rename group and/or change group members. +/// Modify group +/// +/// Rename group and/or change group members. +/// +/// # Returns +/// Returns a `GroupsInfo` object or `WebError` if error occurs. +#[utoipa::path( + put, + path = "/api/v1/group/{name}", + request_body = EditGroupInfo, + responses( + (status = 201, description = "Successfully updated group."), + (status = 401, description = "Unauthorized to update user group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to update user group.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "Cannot update group: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Cannot update a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn modify_group( _role: UserAdminRole, State(appstate): State, @@ -258,10 +362,28 @@ pub(crate) async fn modify_group( Ok(ApiResponse::default()) } -/// DELETE: Remove group with `name`. +/// Remove group with `name`. +/// +/// Delete group and group members. +/// +/// # Returns +/// If error occurs it returns `WebError` object. +#[utoipa::path( + delete, + path = "/api/v1/group/{name}", + params( + ("name" = String, description = "Group name") + ), + responses( + (status = 200, description = "Successfully deleted a group."), + (status = 400, description = "Cannot delete admin group.", body = ApiResponse, example = json!({})), + (status = 401, description = "Unauthorized to delete group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Cannot delete group: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Failed to find group "})), + (status = 500, description = "Cannot delete a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn delete_group( _session: SessionInfo, - State(appstate): State, Path(name): Path, ) -> Result { @@ -291,7 +413,27 @@ pub(crate) async fn delete_group( } } -/// POST: Find a group with `name` and add `username` as a member. +/// Add a group member +/// +/// Find a group with `name` and add `username` as a member. +/// +/// # Returns +/// If error occurs it returns `WebError` object. +#[utoipa::path( + post, + path = "/api/v1/group/{name}", + params( + ("name" = String, description = "Group name") + ), + request_body = Username, + responses( + (status = 200, description = "Successfully add a new member to group."), + (status = 401, description = "Unauthorized to add a new group member.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to add a new group member.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "Cannot add a new group member: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Failed to find group "})), + (status = 500, description = "Cannot add a new group memmber.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn add_group_member( _role: UserAdminRole, State(appstate): State, @@ -320,7 +462,27 @@ pub(crate) async fn add_group_member( } } -/// DELETE: Remove `username` from group with `name`. +/// Remove `username` from group with `name`. +/// +/// Find a group with `name` and remove `username` as a member. +/// +/// # Returns +/// If error occurs it returns `WebError` object. +#[utoipa::path( + delete, + path = "/api/v1/group/{name}/user/{username}", + params( + ("name" = String, description = "Name of the group that you want to delete a user."), + ("username" = String, description = "Name of the user that you want to delete.") + ), + responses( + (status = 200, description = "Successfully remove a member from group.", body = ApiResponse, example = json!({})), + (status = 401, description = "Unauthorized to remove a group member.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to remove a group member.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "Cannot remove a group member: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Failed to find group "})), + (status = 500, description = "Cannot remove a group member.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub(crate) async fn remove_group_member( _role: UserAdminRole, State(appstate): State, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 59f2b2df76..83b2f9a141 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -172,7 +172,7 @@ impl AuthCode { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct GroupInfo { pub name: String, pub members: Vec, @@ -191,7 +191,7 @@ impl GroupInfo { } /// Dedicated `GroupInfo` variant for group modification operations. -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct EditGroupInfo { pub name: String, pub members: Vec, diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 591dcf3f01..bd5268978f 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -110,7 +110,7 @@ pub(crate) fn check_password_strength(password: &str) -> Result<(), WebError> { "enrolled": true, "first_name": "first_name", "groups": [ - "admin" + "group" ], "id": 1, "is_active": true, @@ -147,11 +147,10 @@ pub async fn list_users(_role: UserAdminRole, State(appstate): State) /// Returns `UserDetails` object or `WebError` if error occurs. #[utoipa::path( get, - path = "/api/v1/user/:username", + path = "/api/v1/user/{username}", params( ("username" = String, description = "name of a user"), ), - request_body = String, responses( (status = 200, description = "Return details about user.", body = UserDetails, example = json!( { @@ -325,7 +324,7 @@ pub async fn add_user( /// Returns json with `enrollment token` and `enrollment url` or `WebError` if error occurs. #[utoipa::path( post, - path = "/api/v1/user/:username/start_enrollment", + path = "/api/v1/user/{username}/start_enrollment", request_body = StartEnrollmentRequest, responses( (status = 201, description = "Trigger enrollment process manually.", body = ApiResponse, example = json!({"enrollment_token": "your_enrollment_token", "enrollment_url": "your_enrollment_token"})), @@ -407,7 +406,7 @@ pub async fn start_enrollment( /// Returns json with `enrollment token` and `enrollment url` or `WebError` if error occurs. #[utoipa::path( post, - path = "/api/v1/user/:username/start_desktop", + path = "/api/v1/user/{username}/start_desktop", request_body = StartEnrollmentRequest, responses( (status = 201, description = "Trigger enrollment process manually.", body = Json, example = json!({"enrollment_token": "your_enrollment_token", "enrollment_url": "your_enrollment_token"})), @@ -519,7 +518,7 @@ pub async fn username_available( /// If erorr occurs, endpoint will return `WebError` object. #[utoipa::path( put, - path = "/api/v1/user/:username", + path = "/api/v1/user/{username}", params( ("username" = String, description = "name of a user"), ), @@ -619,7 +618,7 @@ pub async fn modify_user( /// If erorr occurs, endpoint will return `WebError` object. #[utoipa::path( delete, - path = "/api/v1/user/:username", + path = "/api/v1/user/{username}", params( ("username" = String, description = "name of a user"), ), @@ -722,7 +721,7 @@ pub async fn change_self_password( /// If erorr occurs, endpoint will return `WebError` object. #[utoipa::path( put, - path = "/api/v1/user/:username/password", + path = "/api/v1/user/{username}/password", params( ("username" = String, description = "name of a user"), ), @@ -801,7 +800,7 @@ pub async fn change_password( /// If erorr occurs, endpoint will return `WebError` object. #[utoipa::path( post, - path = "/api/v1/user/:username/reset_password", + path = "/api/v1/user/{username}/reset_password", params( ("username" = String, description = "name of a user"), ), @@ -916,11 +915,10 @@ pub struct WalletInfoShort { /// Returns `WalletChallenge` object or `WebError` object if error occurs. #[utoipa::path( get, - path = "/api/v1/user/:username/challenge", + path = "/api/v1/user/{username}/challenge", params( ("username" = String, description = "name of a user"), ), - request_body = Json, responses( (status = 200, description = "Return successfully wallet challenge details.", body = WalletChallenge, example = json!( { @@ -952,7 +950,7 @@ pub async fn wallet_challenge( { if wallet.validation_timestamp.is_some() { error!( - "Can't generate wallet challange for user {username}, the wallet {} is already validated", + "Can't generate wallet challange for user {username}, the wallet {} is already validated", wallet_info.address ); return Err(WebError::ObjectNotFound("wrong address".into())); @@ -998,16 +996,15 @@ pub async fn wallet_challenge( /// It returns `WebError` object if error occurs. #[utoipa::path( get, - path = "/api/v1/user/:username/wallet", + path = "/api/v1/user/{username}/wallet", params( ("username" = String, description = "name of a user"), ), - request_body = Json, responses( (status = 200, description = "Successfully set wallet signature."), (status = 401, description = "Unauthorized to set a new signature.", body = Json, example = json!({"msg": "Session is required"})), (status = 403, description = "You don't have permission to set new signature to wallet.", body = Json, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Incorrect wallet signature or address, can't set new signature for user.", body = Json, example = json!({"msg": "wallet not found"})), + (status = 404, description = "Incorrect wallet signature or address, can't set new signature for user.", body = ApiResponse, example = json!({"msg": "wallet not found"})), (status = 500, description = "Cannot set a new wallet signature", body = Json, example = json!({"msg": "Internal server error"})) ) )] @@ -1060,7 +1057,7 @@ pub async fn set_wallet( /// Returns `RecoveryCodes` object or `WebError` object if error occurs. #[utoipa::path( put, - path = "/api/v1/user/:username/wallet/:address", + path = "/api/v1/user/{username}/wallet/{address}", params( ("username" = String, description = "name of a user"), ("address" = String, description = "address of a user portfel") @@ -1149,7 +1146,7 @@ pub async fn update_wallet( /// Returns `WebError` object if error occurs. #[utoipa::path( delete, - path = "/api/v1/user/:username/wallet/:address", + path = "/api/v1/user/{username}/wallet/{address}", params( ("username" = String, description = "name of a user"), ("address" = String, description = "address of a user portfel") @@ -1207,7 +1204,7 @@ pub async fn delete_wallet( /// Returns `WebError` object if error occurs. #[utoipa::path( delete, - path = "/api/v1/user/:username/security_key/:id", + path = "/api/v1/user/{username}/security_key/{id}", params( ("username" = String, description = "name of a user"), ("id" = i64, description = "id of security key that could point to passkey") @@ -1265,7 +1262,26 @@ pub async fn delete_security_key( get, path = "/api/v1/me", responses( - (status = 200, description = "Returns your own data."), + (status = 200, description = "Returns your own data.", body = UserInfo, example = json!( + { + "authorized_apps": [], + "email": "name@email.com", + "email_mfa_enabled": false, + "enrolled": true, + "first_name": "first_name", + "groups": [ + "group" + ], + "id": 1, + "is_active": true, + "last_name": "last_name", + "mfa_enabled": false, + "mfa_method": "None", + "phone": null, + "totp_enabled": false, + "username": "username" + } + )), (status = 401, description = "Unauthorized return own user data.", body = ApiResponse, example = json!({"msg": "Session is required"})), (status = 500, description = "Cannot retrive own user data.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ) @@ -1286,7 +1302,7 @@ pub async fn me(session: SessionInfo, State(appstate): State) -> ApiRe /// Returns `WebError` object if error occurs. #[utoipa::path( delete, - path = "/api/v1/user/:username/oauth_app/:oauth2client_id", + path = "/api/v1/user/{username}/oauth_app/{oauth2client_id}", params( ("username" = String, description = "name of a user"), ("oauth2client_id" = i64, description = "id of OAuth2 client") diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index 5780c7bae7..7fb332d8aa 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -458,6 +458,64 @@ pub async fn add_user_devices( } } +// assign IPs and generate configs for each network +#[derive(Serialize, ToSchema)] +pub struct AddDeviceResult { + configs: Vec, + device: Device, +} + +/// Add device +/// +/// Add a new device for a user by sending `AddDevice` object. +/// Notice that `wireguard_pubkey` must be unique to successfully add the device. +/// You can't add devices for `disabled` users, unless you are an admin. +/// +/// Device will be added to all networks in your company infrastructure. +/// +/// User will receive all new device details on email. +/// +/// # Returns +/// Returns `AddDeviceResult` object or `WebError` object if error occurs. +#[utoipa::path( + post, + path = "/api/v1/device/{device_id}", + params( + ("device_id" = String, description = "Name of a user.") + ), + request_body = AddDevice, + responses( + (status = 201, description = "Successfully added a new device for a user.", body = AddDeviceResult, example = json!( + { + "configs": [ + { + "network_id": 0, + "network_name": "network_name", + "config": "config", + "address": "0.0.0.0:8000", + "endpoint": "endpoint", + "allowed_ips": ["0.0.0.0:8000"], + "pubkey": "pubkey", + "dns": "8.8.8.8", + "mfa_enabled": false, + "keepalive_interval": 5 + } + ], + "device": { + "id": 0, + "name": "name", + "wireguard_pubkey": "wireguard_pubkey", + "user_id": 0, + "created": "2024-07-10T10:25:43.231Z" + } + } + )), + (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with already exists.", body = ApiResponse, example = json!({})), + (status = 401, description = "Unauthorized to add a new device for a user.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to add a new device for a user. You can't add a new device for a disabled user.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (status = 500, description = "Cannot add a new device for a user.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub async fn add_device( session: SessionInfo, State(appstate): State, @@ -518,13 +576,6 @@ pub async fn add_device( let mut transaction = appstate.pool.begin().await?; device.save(&mut *transaction).await?; - // assign IPs and generate configs for each network - #[derive(Serialize)] - struct AddDeviceResult { - configs: Vec, - device: Device, - } - let (network_info, configs) = device.add_to_all_networks(&mut transaction).await?; let mut network_ips: Vec = Vec::new(); @@ -579,6 +630,38 @@ pub async fn add_device( }) } +/// Modify device +/// +/// Update a device for a user by sending `ModifyDevice` object. +/// Notice that `wireguard_pubkey` must be diffrent from server's pubkey. +/// +/// Endpoint will trigger new update in gateway server. +/// +/// # Returns +/// Returns `Device` object or `WebError` object if error occurs. +#[utoipa::path( + put, + path = "/api/v1/device/{device_id}", + params( + ("device_id" = i64, description = "Id of device to update details.") + ), + request_body = ModifyDevice, + responses( + (status = 200, description = "Successfully updated a device.", body = Device, example = json!( + { + "id": 0, + "name": "name", + "wireguard_pubkey": "wireguard_pubkey", + "user_id": 0, + "created": "2024-07-10T10:25:43.231Z" + } + )), + (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with is a server's pubkey.", body = ApiResponse, example = json!({"msg": "device's pubkey must be different from server's pubkey"})), + (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})), + (status = 500, description = "Cannot update a device.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub async fn modify_device( session: SessionInfo, Path(device_id): Path, @@ -643,6 +726,31 @@ pub async fn modify_device( }) } +/// Get device +/// +/// # Returns +/// Returns `Device` object or `WebError` object if error occurs. +#[utoipa::path( + get, + path = "/api/v1/device/{device_id}", + params( + ("device_id" = i64, description = "Id of device to update details.") + ), + responses( + (status = 200, description = "Successfully updated a device.", body = Device, example = json!( + { + "id": 0, + "name": "name", + "wireguard_pubkey": "wireguard_pubkey", + "user_id": 0, + "created": "2024-07-10T10:25:43.231Z" + } + )), + (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with is a server's pubkey.", body = ApiResponse, example = json!({"msg": "device's pubkey must be different from server's pubkey"})), + (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})) + ) +)] pub async fn get_device( session: SessionInfo, Path(device_id): Path, @@ -657,6 +765,25 @@ pub async fn get_device( }) } +/// Delete device +/// +/// Delete user device and trigger new update in gateway server. +/// +/// # Returns +/// If error occurs it returns `WebError` object. +#[utoipa::path( + delete, + path = "/api/v1/device/{device_id}", + params( + ("device_id" = i64, description = "Id of device to update details.") + ), + responses( + (status = 200, description = "Successfully deleted device."), + (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})), + (status = 500, description = "Cannot update a device.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ) +)] pub async fn delete_device( session: SessionInfo, Path(device_id): Path, @@ -672,6 +799,27 @@ pub async fn delete_device( Ok(ApiResponse::default()) } +/// List all devices +/// +/// # Returns +/// Returns a list `Device` objects or `WebError` object if error occurs. +#[utoipa::path( + get, + path = "/api/v1/device", + responses( + (status = 200, description = "List all devices.", body = [Device], example = json!([ + { + "id": 0, + "name": "name", + "wireguard_pubkey": "wireguard_pubkey", + "user_id": 0, + "created": "2024-07-10T10:25:43.231Z" + } + ])), + (status = 401, description = "Unauthorized to list all devices.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to list all devices.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + ) +)] pub async fn list_devices(_role: VpnRole, State(appstate): State) -> ApiResult { debug!("Listing devices"); let devices = Device::all(&appstate.pool).await?; @@ -683,6 +831,32 @@ pub async fn list_devices(_role: VpnRole, State(appstate): State) -> A }) } +/// List user devices +/// +/// This endpoint requires `admin` role. +/// +/// # Returns +/// Returns a list of `Device` object or `WebError` object if error occurs. +#[utoipa::path( + get, + path = "/api/v1/device/user/{username}", + params( + ("username" = String, description = "Name of a user.") + ), + responses( + (status = 200, description = "List user devices.", body = [Device], example = json!([ + { + "id": 0, + "name": "name", + "wireguard_pubkey": "wireguard_pubkey", + "user_id": 0, + "created": "2024-07-10T10:25:43.231Z" + } + ])), + (status = 401, description = "Unauthorized to list user devices.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to list user devices.", body = ApiResponse, example = json!({"msg": "Admin access required"})), + ) +)] pub async fn list_user_devices( session: SessionInfo, State(appstate): State, diff --git a/src/lib.rs b/src/lib.rs index a47b9ef4fd..37f6a175dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,16 +12,8 @@ use axum::{ }; use assets::{index, svg, web_asset}; -use db::{models::device::UserDevice, UserDetails, UserInfo}; -use error::WebError; -use handlers::{ - group::Groups, - ssh_authorized_keys::{ - add_authentication_key, delete_authentication_key, fetch_authentication_keys, - }, - user::WalletInfoShort, - PasswordChange, PasswordChangeSelf, StartEnrollmentRequest, Username, WalletChange, - WalletSignature, +use handlers::ssh_authorized_keys::{ + add_authentication_key, delete_authentication_key, fetch_authentication_keys, }; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, @@ -46,6 +38,7 @@ use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, }; +use utoipa_swagger_ui::SwaggerUi; use self::{ appstate::AppState, @@ -157,9 +150,22 @@ pub(crate) const KEY_LENGTH: usize = 32; mod openapi { use super::*; + use db::{ + models::device::{ModifyDevice, UserDevice}, + AddDevice, UserDetails, UserInfo, + }; + use error::WebError; use utoipa::OpenApi; - use handlers::{group, user, ApiResponse}; + use handlers::{ + group::{self, BulkAssignToGroupsRequest, Groups}, + user::{self, WalletInfoShort}, + wireguard::AddDeviceResult, + ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, + StartEnrollmentRequest, Username, WalletChange, WalletSignature, + }; + + use handlers::wireguard as device; #[derive(OpenApi)] #[openapi( @@ -185,27 +191,54 @@ mod openapi { user::me, user::delete_authorized_app, // /device + device::add_device, + device::modify_device, + device::get_device, + device::delete_device, + device::list_devices, + device::list_user_devices, // /group + group::bulk_assign_to_groups, + group::list_groups_info, group::list_groups, + group::get_group, + group::create_group, + group::modify_group, + group::delete_group, + group::add_group_member, + group::remove_group_member, ), components( schemas( - ApiResponse, UserInfo, WebError, UserDetails, UserDevice, Groups, Username, StartEnrollmentRequest, PasswordChangeSelf, PasswordChange, WalletInfoShort, WalletSignature, WalletChange + ApiResponse, UserInfo, WebError, UserDetails, UserDevice, Groups, Username, StartEnrollmentRequest, PasswordChangeSelf, PasswordChange, WalletInfoShort, WalletSignature, WalletChange, AddDevice, AddDeviceResult, Device, ModifyDevice, BulkAssignToGroupsRequest, GroupInfo, EditGroupInfo ), ), tags( (name = "user", description = " Endpoints that allow to control user data. -Available actions: +Available actions: - list all users -- CRUD mechanism for user -- operation on user wallet -- operation on security key and authorized app +- CRUD mechanism for handling users +- operations on user wallet +- operations on security key and authorized app - change user password. "), - (name = "wireguard", description = "description"), - (name = "group", description = "description") + (name = "device", description = " +Endpoints that allow to control devices in your network. + +Available actions: +- list all devices or user devices +- CRUD mechanism for handling devices. + "), + (name = "group", description = " +Endpoints that allow to control groups in your network. + +Available actions: +- list all groups +- CRUD mechanism for handling groups +- add or delete a group member. + ") ) )] pub struct ApiDoc; @@ -434,6 +467,9 @@ pub fn build_webapp( .layer(Extension(worker_state)), ); + let swagger = + SwaggerUi::new("/api-docs").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()); + webapp .with_state(AppState::new( pool, @@ -455,6 +491,7 @@ pub fn build_webapp( }) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) + .merge(swagger) } /// Runs core web server exposing REST API.