implement more roles stuff
This commit is contained in:
parent
4f50aa1362
commit
8bdcb5683e
|
@ -89,6 +89,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||
.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(())
|
||||
|
|
|
@ -14,19 +14,33 @@
|
|||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
//#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<String>,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
|
@ -161,5 +175,482 @@ pub async fn create_role_request(req: Json<CreateRoleRequest>, req_info: HttpReq
|
|||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().finish()
|
||||
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<firewall_rule::Model> = 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<RoleResponse>,
|
||||
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<String>,
|
||||
#[serde(default, rename = "nextCursor")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub page: Option<GetRolesResponseMetadataPage>
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GetRolesResponseMetadataPage {
|
||||
pub count: u64,
|
||||
pub start: u64
|
||||
}
|
||||
|
||||
#[get("/v1/roles")]
|
||||
pub async fn get_roles(opts: Query<ListRolesRequestOpts>, req_info: HttpRequest, db: Data<AppState>) -> 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<RoleResponse> = 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<RoleFirewallRule> = 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<String>, req_info: HttpRequest, db: Data<AppState>) -> 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<role::Model> = 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<RoleFirewallRule> = 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 {}
|
Loading…
Reference in New Issue