diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index a53e854..56797de 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -89,6 +89,9 @@ async fn main() -> Result<(), Box> { .service(routes::v1::networks::get_networks) .service(routes::v1::organization::create_org_request) .service(routes::v1::networks::get_network_request) + .service(routes::v1::roles::create_role_request) + .service(routes::v1::roles::get_roles) + .service(routes::v1::roles::get_role) }).bind(CONFIG.server.bind)?.run().await?; Ok(()) diff --git a/trifid-api/src/routes/v1/roles.rs b/trifid-api/src/routes/v1/roles.rs index 85768c9..33aa3d8 100644 --- a/trifid-api/src/routes/v1/roles.rs +++ b/trifid-api/src/routes/v1/roles.rs @@ -14,19 +14,33 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . // -//#POST /v1/roles t+parity:full t+type:documented t+status:in_progress +//#POST /v1/roles t+parity:full t+type:documented t+status:done // This endpoint has full parity with the original API. It has been recreated from the original API documentation. -// This endpoint is in-progress and should not be expected to work, sometimes at all. +// This endpoint is considered done. No major features should be added or removed, unless it fixes bugs. +// +//#GET /v1/roles t+parity:full t+type:documented t+status:done +// This endpoint has full parity with the original API. It has been recreated from the original API documentation. +// This endpoint is considered done. No major features should be added or removed, unless it fixes bugs. +// +//#GET /v1/roles/{role_id} t+parity:full t+type:documented t+status:done +// This endpoint has full parity with the original API. It has been recreated from the original API documentation. +// This endpoint is considered done. No major features should be added or removed, unless it fixes bugs. -use actix_web::{HttpRequest, HttpResponse, post}; -use actix_web::web::{Data, Json}; +use std::time::{SystemTime, UNIX_EPOCH}; +use actix_web::{get, HttpRequest, HttpResponse, post}; +use actix_web::web::{Data, Json, Path, Query}; +use chrono::{TimeZone, Utc}; use log::error; use serde::{Deserialize, Serialize}; use crate::AppState; use crate::auth_tokens::{enforce_2fa, enforce_api_token, TokenInfo}; use crate::error::{APIError, APIErrorsResponse}; use trifid_api_entities::entity::organization; -use sea_orm::{EntityTrait, QueryFilter, ColumnTrait}; +use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, IntoActiveModel, ActiveModelTrait, QueryOrder, PaginatorTrait, ModelTrait}; +use trifid_api_entities::entity::firewall_rule; +use trifid_api_entities::entity::role; +use crate::cursor::Cursor; +use crate::tokens::random_id; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CreateRoleRequest { @@ -68,12 +82,12 @@ pub struct RolePortRange { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RoleCreateResponse { - pub data: RoleCreateResponseData, + pub data: RoleResponse, pub metadata: RoleCreateResponseMetadata } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct RoleCreateResponseData { +pub struct RoleResponse { pub id: Option, pub name: Option, pub description: Option, @@ -161,5 +175,482 @@ pub async fn create_role_request(req: Json, req_info: HttpReq } }; - HttpResponse::Ok().finish() -} \ No newline at end of file + let new_role_model = role::Model { + id: random_id("role"), + name: req.name.clone(), + description: req.description.clone(), + organization: org, + created_at: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as i64, + modified_at: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as i64, + }; + let firewall_rules: Vec = req.firewall_rules.iter().map(|i| { + firewall_rule::Model { + id: random_id("rule"), + role: new_role_model.id.clone(), + protocol: i.protocol.to_string(), + description: i.description.clone(), + allowed_role_id: i.allowed_role_id.clone(), + port_range_from: i.port_range.as_ref().unwrap_or(&RolePortRange { from: 0, to: 65535 }).from as i32, + port_range_to: i.port_range.as_ref().unwrap_or(&RolePortRange { from: 0, to: 65535 }).to as i32, + } + }).collect(); + + let new_role_model_clone = new_role_model.clone(); + let firewall_rules_clone = firewall_rules.clone(); + + let new_role_active_model = new_role_model.into_active_model(); + match new_role_active_model.insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error creating the new role. Please try again later".to_string(), + path: None + } + ], + }) + } + } + + for rule in &firewall_rules_clone { + let active_model = rule.clone().into_active_model(); + match active_model.insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error creating the new role. Please try again later".to_string(), + path: None + } + ], + }) + } + } + } + + HttpResponse::Ok().json(RoleCreateResponse { + data: RoleResponse { + id: Some(new_role_model_clone.id.clone()), + name: Some(new_role_model_clone.name.clone()), + description: Some(new_role_model_clone.description), + firewall_rules: req.firewall_rules.clone(), + created_at: Utc.timestamp_opt(new_role_model_clone.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + modified_at: Utc.timestamp_opt(new_role_model_clone.modified_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + }, + metadata: RoleCreateResponseMetadata {}, + }) +} + +impl ToString for RoleProtocol { + fn to_string(&self) -> String { + match self { + RoleProtocol::Any => "ANY".to_string(), + RoleProtocol::Tcp => "TCP".to_string(), + RoleProtocol::Udp => "UDP".to_string(), + RoleProtocol::Icmp => "ICMP".to_string() + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct ListRolesRequestOpts { + #[serde(default, rename = "includeCounts")] + pub include_counts: bool, + #[serde(default)] + pub cursor: String, + #[serde(default = "page_default", rename = "pageSize")] + pub page_size: u64 +} + +fn page_default() -> u64 { 25 } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetRolesResponse { + pub data: Vec, + pub metadata: GetRolesResponseMetadata +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetRolesResponseMetadata { + #[serde(rename = "totalCount")] + pub total_count: u64, + #[serde(rename = "hasNextPage")] + pub has_next_page: bool, + #[serde(rename = "hasPrevPage")] + pub has_prev_page: bool, + #[serde(default, rename = "prevCursor")] + pub prev_cursor: Option, + #[serde(default, rename = "nextCursor")] + pub next_cursor: Option, + #[serde(default)] + pub page: Option +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetRolesResponseMetadataPage { + pub count: u64, + pub start: u64 +} + +#[get("/v1/roles")] +pub async fn get_roles(opts: Query, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you either need to be a fully authenticated user OR a token with roles:list + let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["roles:list"], &db.conn).await.unwrap_or(TokenInfo::NotPresent); + + // If neither are present, throw an error + if matches!(session_info, TokenInfo::NotPresent) && matches!(api_token_info, TokenInfo::NotPresent) { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This endpoint requires either a fully authenticated user or a token with the roles:list scope".to_string(), + path: None, + } + ], + }) + } + + // If both are present, throw an error + if matches!(session_info, TokenInfo::AuthToken(_)) && matches!(api_token_info, TokenInfo::ApiToken(_)) { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(), + message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(), + path: None + } + ], + }) + } + + let org = match api_token_info { + TokenInfo::ApiToken(tkn) => tkn.organization, + _ => { + // we have a session token, which means we have to do a db request to get the organization that this user owns + let user = match session_info { + TokenInfo::AuthToken(tkn) => tkn.session_info.user, + _ => unreachable!() + }; + + let org = match organization::Entity::find().filter(organization::Column::Owner.eq(user.id)).one(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(org) = org { + org.id + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_NO_ORG".to_string(), + message: "This user does not own any organizations. Try using an API token instead.".to_string(), + path: None + } + ], + }) + } + } + }; + + let cursor: Cursor = match opts.cursor.clone().try_into() { + Ok(r) => r, + Err(e) => { + error!("invalid cursor: {}", e); + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_INVALID_CURSOR".to_string(), + message: "The provided cursor was invalid, please try again later.".to_string(), + path: None + } + ], + }) + } + }; + + let network_pages = role::Entity::find().filter(role::Column::Organization.eq(org)).order_by_asc(role::Column::CreatedAt).paginate(&db.conn, opts.page_size); + + let total = match network_pages.num_items().await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + let pages = match network_pages.num_pages().await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + let models = match network_pages.fetch_page(cursor.page).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + let mut models_mapped: Vec = vec![]; + + for u in models { + // fetch firewall rules + let rules = match firewall_rule::Entity::find().filter(firewall_rule::Column::Role.eq(&u.id)).all(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + let rules: Vec = rules.iter().map(|r| { + let protocol = match r.protocol.as_str() { + "ANY" => RoleProtocol::Any, + "TCP" => RoleProtocol::Tcp, + "UDP" => RoleProtocol::Udp, + "ICMP" => RoleProtocol::Icmp, + _ => unreachable!("database has been corrupted or manually edited") + }; + + + + let port_range = if r.port_range_from == 0 && r.port_range_to == 65535 || matches!(protocol, RoleProtocol::Icmp) { + None + } else { + Some(RolePortRange { + from: r.port_range_from as u16, + to: r.port_range_to as u16, + }) + }; + + RoleFirewallRule { + protocol, + description: r.description.clone(), + allowed_role_id: r.allowed_role_id.clone(), + port_range, + } + }).collect(); + + models_mapped.push(RoleResponse { + id: Some(u.id.clone()), + name: Some(u.name), + description: Some(u.description), + firewall_rules: rules, + created_at: Utc.timestamp_opt(u.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + modified_at: Utc.timestamp_opt(u.modified_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + }) + } + + let count = models_mapped.len() as u64; + + HttpResponse::Ok().json(GetRolesResponse { + data: models_mapped, + metadata: GetRolesResponseMetadata { + total_count: total, + has_next_page: cursor.page+1 != pages, + has_prev_page: cursor.page != 0, + prev_cursor: if cursor.page != 0 { + match (Cursor { page: cursor.page - 1 }).try_into() { + Ok(r) => Some(r), + Err(_) => None + } + } else { + None + }, + next_cursor: if cursor.page+1 != pages { + match (Cursor { page: cursor.page + 1 }).try_into() { + Ok(r) => Some(r), + Err(_) => None + } + } else { + None + }, + page: if opts.include_counts { + Some(GetRolesResponseMetadataPage { + count, + start: opts.page_size * cursor.page, + }) + } else { None }, + }, + }) +} + +#[get("/v1/roles/{role_id}")] +pub async fn get_role(net: Path, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you either need to be a fully authenticated user OR a token with roles:read + let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["roles:read"], &db.conn).await.unwrap_or(TokenInfo::NotPresent); + + // If neither are present, throw an error + if matches!(session_info, TokenInfo::NotPresent) && matches!(api_token_info, TokenInfo::NotPresent) { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This endpoint requires either a fully authenticated user or a token with the roles:read scope".to_string(), + path: None, + } + ], + }) + } + + // If both are present, throw an error + if matches!(session_info, TokenInfo::AuthToken(_)) && matches!(api_token_info, TokenInfo::ApiToken(_)) { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(), + message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(), + path: None + } + ], + }) + } + + let role: Option = match role::Entity::find().filter(role::Column::Id.eq(net.into_inner())).one(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(role) = role {// fetch firewall rules + let rules = match firewall_rule::Entity::find().filter(firewall_rule::Column::Role.eq(&role.id)).all(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + let rules: Vec = rules.iter().map(|r| { + let protocol = match r.protocol.as_str() { + "ANY" => RoleProtocol::Any, + "TCP" => RoleProtocol::Tcp, + "UDP" => RoleProtocol::Udp, + "ICMP" => RoleProtocol::Icmp, + _ => unreachable!("database has been corrupted or manually edited") + }; + + + + let port_range = if r.port_range_from == 0 && r.port_range_to == 65535 || matches!(protocol, RoleProtocol::Icmp) { + None + } else { + Some(RolePortRange { + from: r.port_range_from as u16, + to: r.port_range_to as u16, + }) + }; + + RoleFirewallRule { + protocol, + description: r.description.clone(), + allowed_role_id: r.allowed_role_id.clone(), + port_range, + } + }).collect(); + + + HttpResponse::Ok().json(GetRoleResponse { + data: RoleResponse { + id: Some(role.id.clone()), + name: Some(role.name.clone()), + description: Some(role.description.clone()), + firewall_rules: rules, + created_at: Utc.timestamp_opt(role.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + modified_at: Utc.timestamp_opt(role.modified_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), + }, + metadata: GetRoleResponseMetadata {}, + }) + } else { + HttpResponse::NotFound().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_MISSING_ROLE".to_string(), + message: "Role does not exist".to_string(), + path: None, + } + ], + }) + + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetRoleResponse { + pub data: RoleResponse, + pub metadata: GetRoleResponseMetadata +} +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetRoleResponseMetadata {} \ No newline at end of file