mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-30 07:46:36 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42a4096acb |
@@ -3282,10 +3282,18 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
let mut all_succeeded = true;
|
||||
let mut target_infos = Vec::with_capacity(tgt_arns.len());
|
||||
|
||||
for arn in tgt_arns {
|
||||
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(bucket, &arn).await else {
|
||||
all_succeeded = false;
|
||||
target_infos.push(force_delete_target_info(
|
||||
dobj.op_type,
|
||||
&arn,
|
||||
"",
|
||||
false,
|
||||
Err(TARGET_CLIENT_UNAVAILABLE_ERROR.to_string()),
|
||||
));
|
||||
debug!(
|
||||
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -3311,6 +3319,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
|
||||
let bucket = bucket.clone();
|
||||
let object_name = object_name.clone();
|
||||
let op_type = dobj.op_type;
|
||||
|
||||
join_set.spawn(async move {
|
||||
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
|
||||
@@ -3335,9 +3344,16 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return false;
|
||||
return force_delete_target_info(
|
||||
op_type,
|
||||
&tgt_client.arn,
|
||||
&tgt_client.endpoint,
|
||||
tgt_client.secure,
|
||||
Err(FORCE_DELETE_TARGET_OFFLINE_ERROR.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
if let Err(e) = tgt_client
|
||||
.remove_object(&tgt_client.bucket, &object_name, None, replication_force_delete_remove_options())
|
||||
.await
|
||||
@@ -3364,16 +3380,25 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return false;
|
||||
return force_delete_target_info(
|
||||
op_type,
|
||||
&tgt_client.arn,
|
||||
&tgt_client.endpoint,
|
||||
tgt_client.secure,
|
||||
Err(e.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
force_delete_target_info(op_type, &tgt_client.arn, &tgt_client.endpoint, tgt_client.secure, Ok(started.elapsed()))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok(success) => all_succeeded &= success,
|
||||
Ok(info) => {
|
||||
all_succeeded &= info.replication_status == ReplicationStatusType::Completed;
|
||||
target_infos.push(info);
|
||||
}
|
||||
Err(error) => {
|
||||
all_succeeded = false;
|
||||
error!(
|
||||
@@ -3390,6 +3415,8 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
}
|
||||
}
|
||||
|
||||
record_force_delete_target_stats(bucket, &target_infos).await;
|
||||
|
||||
if all_succeeded
|
||||
&& let Some(operation_id) = dobj.delete_object.force_delete_id
|
||||
&& let Err(error) = super::replication_pool::complete_force_delete_intent(storage, operation_id).await
|
||||
@@ -3409,6 +3436,53 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
all_succeeded
|
||||
}
|
||||
|
||||
const FORCE_DELETE_TARGET_OFFLINE_ERROR: &str = "replication target is offline";
|
||||
|
||||
/// One target's outcome of a replicated force-delete in the shape the
|
||||
/// replication statistics consume. A force-delete carries no per-version
|
||||
/// replication state, so the previous status is always `Pending`: that is
|
||||
/// the transition `ReplicationStats::update` counts as a failure, whereas an
|
||||
/// `Empty` previous status would drop the outcome and leave `replication
|
||||
/// status` reporting zero failures while the peer still holds the objects.
|
||||
fn force_delete_target_info(
|
||||
op_type: ReplicationType,
|
||||
arn: &str,
|
||||
endpoint: &str,
|
||||
secure: bool,
|
||||
outcome: std::result::Result<std::time::Duration, String>,
|
||||
) -> ReplicatedTargetInfo {
|
||||
let mut rinfo = ReplicatedTargetInfo {
|
||||
arn: arn.to_string(),
|
||||
op_type,
|
||||
endpoint: endpoint.to_string(),
|
||||
secure,
|
||||
prev_replication_status: ReplicationStatusType::Pending,
|
||||
..Default::default()
|
||||
};
|
||||
match outcome {
|
||||
Ok(duration) => {
|
||||
rinfo.replication_status = ReplicationStatusType::Completed;
|
||||
rinfo.duration = duration;
|
||||
}
|
||||
Err(error) => {
|
||||
rinfo.replication_status = ReplicationStatusType::Failed;
|
||||
rinfo.error = Some(error);
|
||||
}
|
||||
}
|
||||
rinfo
|
||||
}
|
||||
|
||||
async fn record_force_delete_target_stats(bucket: &str, targets: &[ReplicatedTargetInfo]) {
|
||||
let Some(stats) = runtime_sources::replication_stats() else {
|
||||
return;
|
||||
};
|
||||
for target in targets {
|
||||
stats
|
||||
.update(bucket, target, target.replication_status.clone(), target.prev_replication_status.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable_delete_target_info(dobj: &DeletedObjectReplicationInfo, arn: &str) -> ReplicatedTargetInfo {
|
||||
let mut rinfo = dobj
|
||||
.delete_object
|
||||
@@ -7444,6 +7518,51 @@ mod tests {
|
||||
server.join().expect("test HTTP server should finish");
|
||||
}
|
||||
|
||||
/// A force-delete the peer rejects must reach the replication failure
|
||||
/// counters. A replicated force-delete used to only log the rejection, so
|
||||
/// `replication status` reported zero failures while the sites diverged.
|
||||
#[tokio::test]
|
||||
async fn force_delete_target_outcomes_reach_replication_stats() {
|
||||
let stats = super::super::replication_state::ReplicationStats::new();
|
||||
let denied = force_delete_target_info(
|
||||
ReplicationType::Delete,
|
||||
"arn:rustfs:replication::denied:bucket",
|
||||
"http://peer-a:9000",
|
||||
false,
|
||||
Err("remove_object failed: AccessDenied".to_string()),
|
||||
);
|
||||
assert_eq!(denied.replication_status, ReplicationStatusType::Failed);
|
||||
assert_eq!(denied.prev_replication_status, ReplicationStatusType::Pending);
|
||||
assert_eq!(denied.error.as_deref(), Some("remove_object failed: AccessDenied"));
|
||||
|
||||
let accepted = force_delete_target_info(
|
||||
ReplicationType::Heal,
|
||||
"arn:rustfs:replication::accepted:bucket",
|
||||
"https://peer-b:9000",
|
||||
true,
|
||||
Ok(std::time::Duration::from_millis(3)),
|
||||
);
|
||||
assert_eq!(accepted.replication_status, ReplicationStatusType::Completed);
|
||||
assert!(accepted.error.is_none());
|
||||
|
||||
for target in [&denied, &accepted] {
|
||||
stats
|
||||
.update(
|
||||
"bucket",
|
||||
target,
|
||||
target.replication_status.clone(),
|
||||
target.prev_replication_status.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let bucket_stats = stats.get("bucket").await;
|
||||
let denied_stat = bucket_stats.stats.get(&denied.arn).expect("denied target: stats entry");
|
||||
assert_eq!((denied_stat.failed.count, denied_stat.replicated_count), (1, 0));
|
||||
let accepted_stat = bucket_stats.stats.get(&accepted.arn).expect("accepted target: stats entry");
|
||||
assert_eq!((accepted_stat.failed.count, accepted_stat.replicated_count), (0, 1));
|
||||
}
|
||||
|
||||
/// AWS answers `HEAD ?versionId=<source marker id>` with a bodiless 400.
|
||||
/// The marker is verified through the latest version instead: a
|
||||
/// versionless 404 with `x-amz-delete-marker: true` proves it propagated.
|
||||
|
||||
@@ -289,6 +289,16 @@ receiving site. If a group ended up disabled on one site only, re-enable it
|
||||
there explicitly with `mc admin group enable`; a snapshot or repair will not
|
||||
do it.
|
||||
|
||||
The managed `site-replicator-0` service account carries an embedded policy
|
||||
that the server owns. It names `s3:ForceDeleteObject` explicitly because a
|
||||
replicated force-delete (Console folder delete, `x-rustfs-force-delete: true`)
|
||||
is authorized on the peer like any other force-delete and `s3:*` does not
|
||||
grant that action. The reconcile tick refreshes an account whose stored policy
|
||||
predates the grant; do not edit the policy by hand, the next repair rewrites
|
||||
it. A bucket-replication target configured with its own credentials needs the
|
||||
same `s3:ForceDeleteObject` grant on the target side, or its force-deletes are
|
||||
rejected and counted as replication failures.
|
||||
|
||||
Treat IAM divergence as a security incident: a user deleted on one site can
|
||||
remain usable on an unreachable peer until replay or repair completes. A peer
|
||||
whose IAM entry is escalated does not receive scheduled snapshots either -
|
||||
|
||||
@@ -2092,8 +2092,7 @@ impl DefaultObjectUsecase {
|
||||
let put_admission = put_admission;
|
||||
let table_publication_guard = origin.take_table_publication_guard();
|
||||
let table_data_plane_publication_guards = origin.table_data_plane_publication_guards();
|
||||
// Keep the large commit future off the request stack when propagating task-local context.
|
||||
Box::pin(async move {
|
||||
async move {
|
||||
let _table_publication_guard = table_publication_guard;
|
||||
let _table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
let _put_admission = put_admission;
|
||||
@@ -2149,7 +2148,6 @@ impl DefaultObjectUsecase {
|
||||
result = "error",
|
||||
error_code = %err.code.as_str(),
|
||||
storage_error_code = ?diagnostic.storage_code,
|
||||
lock_failure_reason = diagnostic.lock_reason,
|
||||
io_error_kind = ?diagnostic.io_kind,
|
||||
rpc_error_code = ?diagnostic.rpc_code,
|
||||
pool_metadata_reason = diagnostic.pool_metadata.map(|context| context.reason),
|
||||
@@ -2254,7 +2252,7 @@ impl DefaultObjectUsecase {
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start);
|
||||
|
||||
Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned })
|
||||
})
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let put_commit_future = crate::app::storage_api::diagnostic::with_read_version_spawn_context(
|
||||
@@ -2262,11 +2260,6 @@ impl DefaultObjectUsecase {
|
||||
crate::app::storage_api::diagnostic::ReadVersionContextBoundary::AppPutOwner,
|
||||
put_commit_future,
|
||||
);
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let put_commit_future = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(
|
||||
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
|
||||
put_commit_future,
|
||||
);
|
||||
let put_commit = spawn_traced_join(put_commit_future);
|
||||
let commit_stage_start = put_stage_metrics_enabled.then(Instant::now);
|
||||
let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation {
|
||||
@@ -2347,68 +2340,6 @@ mod tests {
|
||||
use futures::StreamExt;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn put_commit_lock_failure_keeps_request_operation_in_spawned_owner() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
use rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard;
|
||||
use rustfs_lock::LockError;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
let (store, context) = runtime.block_on(Box::pin(real_store_test_context()));
|
||||
let bucket = format!("put-lock-context-{}", Uuid::new_v4());
|
||||
runtime
|
||||
.block_on(Box::pin(store.make_bucket(&bucket, &MakeBucketOptions::default())))
|
||||
.expect("test bucket");
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket)
|
||||
.key("test-object".to_string())
|
||||
.body(Some(StreamingBlob::from_bytes(Bytes::from_static(b"payload"))))
|
||||
.content_length(Some(7))
|
||||
.build()
|
||||
.expect("PUT input");
|
||||
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let error = metrics::with_local_recorder(&recorder, || {
|
||||
let fs = FS::new();
|
||||
let mut future = Box::pin(PUT_STORAGE_TEST_FAILURE.scope(
|
||||
(
|
||||
PutStorageTestStage::Commit,
|
||||
StorageError::Lock(LockError::timeout("private-bucket/private-key", Duration::from_secs(5))),
|
||||
),
|
||||
Box::pin(usecase.execute_put_object(&fs, build_request(input, Method::PUT))),
|
||||
));
|
||||
runtime.block_on(std::future::poll_fn(|cx| {
|
||||
request.in_scope(|| std::future::Future::poll(future.as_mut(), cx))
|
||||
}))
|
||||
})
|
||||
.expect_err("lock failure must reject the PUT");
|
||||
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
|
||||
let rows = snapshotter.snapshot().into_vec();
|
||||
let (key, _, _, value) = rows
|
||||
.iter()
|
||||
.find(|(key, _, _, _)| key.key().name() == "rustfs_s3_lock_failures_total")
|
||||
.expect("spawned commit lock failure must be counted");
|
||||
assert!(matches!(value, DebugValue::Counter(1)));
|
||||
let labels = key
|
||||
.key()
|
||||
.labels()
|
||||
.map(|label| (label.key(), label.value()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(labels["op"], S3Operation::PutObject.as_str());
|
||||
assert_eq!(labels["resource_class"], "object_namespace");
|
||||
assert_eq!(labels["mode"], "unknown");
|
||||
assert_eq!(labels["reason"], "timeout");
|
||||
assert!(!labels.values().any(|value| value.contains("private")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn put_read_quorum_failures_are_normalized_at_each_stage() {
|
||||
|
||||
+7
-122
@@ -64,35 +64,18 @@ fn lock_resource_class(resource: Option<&str>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_failure_reason(error: &LockError) -> &'static str {
|
||||
match error {
|
||||
LockError::Timeout { .. } => "timeout",
|
||||
LockError::AlreadyLocked { .. } => "contention",
|
||||
LockError::QuorumNotReached { .. } => "quorum",
|
||||
LockError::InsufficientNodes { .. } => "insufficient_nodes",
|
||||
LockError::Network { .. } => "network",
|
||||
LockError::Internal { .. } => "internal",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
fn record_s3_lock_failure(err: &StorageError) {
|
||||
let record = |resource: Option<&str>, mode: &'static str, reason: &'static str| {
|
||||
rustfs_io_metrics::s3_http_metrics::record_s3_http_lock_failure(lock_resource_class(resource), mode, reason);
|
||||
};
|
||||
match err {
|
||||
StorageError::Lock(lock_error) => {
|
||||
let resource = match lock_error {
|
||||
LockError::Timeout { resource, .. } | LockError::AlreadyLocked { resource, .. } => Some(resource.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
let reason = match lock_failure_reason(lock_error) {
|
||||
"insufficient_nodes" => "quorum",
|
||||
"network" | "internal" => "transport",
|
||||
reason => reason,
|
||||
};
|
||||
record(resource, "unknown", reason);
|
||||
}
|
||||
StorageError::Lock(lock_error) => match lock_error {
|
||||
LockError::Timeout { resource, .. } => record(Some(resource), "unknown", "timeout"),
|
||||
LockError::AlreadyLocked { resource, .. } => record(Some(resource), "unknown", "contention"),
|
||||
LockError::QuorumNotReached { .. } | LockError::InsufficientNodes { .. } => record(None, "unknown", "quorum"),
|
||||
LockError::Network { .. } | LockError::Internal { .. } => record(None, "unknown", "transport"),
|
||||
_ => record(None, "unknown", "other"),
|
||||
},
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode, bucket, object, ..
|
||||
} => {
|
||||
@@ -326,7 +309,6 @@ impl std::error::Error for ApiError {
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ApiErrorDiagnostic {
|
||||
pub storage_code: Option<StorageErrorCode>,
|
||||
pub lock_reason: Option<&'static str>,
|
||||
pub io_kind: Option<std::io::ErrorKind>,
|
||||
pub io_os_error: Option<i32>,
|
||||
pub rpc_code: Option<tonic::Code>,
|
||||
@@ -368,18 +350,6 @@ impl ApiErrorDiagnostic {
|
||||
};
|
||||
if let Some(storage) = error.downcast_ref::<StorageError>() {
|
||||
diagnostic.storage_code = Some(storage.code());
|
||||
match storage {
|
||||
StorageError::Lock(lock_error) => {
|
||||
diagnostic.lock_reason = Some(lock_failure_reason(lock_error));
|
||||
}
|
||||
StorageError::NamespaceLockQuorumUnavailable { .. } => {
|
||||
diagnostic.lock_reason = Some("quorum");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(lock_error) = error.downcast_ref::<LockError>() {
|
||||
diagnostic.lock_reason = Some(lock_failure_reason(lock_error));
|
||||
}
|
||||
if let Some(status) = error.downcast_ref::<tonic::Status>() {
|
||||
diagnostic.rpc_code = Some(status.code());
|
||||
@@ -1293,91 +1263,6 @@ mod tests {
|
||||
assert!(!format!("{:?}", error.diagnostic()).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_diagnostic_classifies_lock_failures_without_exposing_resources() {
|
||||
let cases = [
|
||||
(
|
||||
LockError::timeout("private-bucket/private-key", std::time::Duration::from_secs(5)),
|
||||
"timeout",
|
||||
),
|
||||
(
|
||||
LockError::AlreadyLocked {
|
||||
resource: "private-bucket/private-key".into(),
|
||||
owner: "private-owner".into(),
|
||||
},
|
||||
"contention",
|
||||
),
|
||||
(
|
||||
LockError::QuorumNotReached {
|
||||
required: 3,
|
||||
achieved: 2,
|
||||
},
|
||||
"quorum",
|
||||
),
|
||||
(
|
||||
LockError::InsufficientNodes {
|
||||
required: 3,
|
||||
available: 2,
|
||||
},
|
||||
"insufficient_nodes",
|
||||
),
|
||||
(LockError::internal("private-transport-detail"), "internal"),
|
||||
(LockError::network("private-network-detail", IoError::other("private-source")), "network"),
|
||||
(
|
||||
LockError::QueueFull {
|
||||
message: "private-queue-detail".into(),
|
||||
},
|
||||
"other",
|
||||
),
|
||||
];
|
||||
for (failure, expected) in cases {
|
||||
let api = ApiError::from(StorageError::Lock(failure));
|
||||
assert_eq!(api.code, S3ErrorCode::ServiceUnavailable);
|
||||
let diagnostic = api.diagnostic();
|
||||
assert_eq!(diagnostic.storage_code, Some(StorageErrorCode::Lock));
|
||||
assert_eq!(diagnostic.lock_reason, Some(expected));
|
||||
assert!(!format!("{diagnostic:?}").contains("private"));
|
||||
let s3: S3Error = api.into();
|
||||
assert_eq!(s3.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(super::ApiErrorDiagnostic::from_error(&s3).lock_reason, Some(expected));
|
||||
}
|
||||
|
||||
let nested = ApiError::from(StorageError::Io(IoError::other(StorageError::Lock(LockError::timeout(
|
||||
"private-bucket/private-key",
|
||||
std::time::Duration::from_secs(5),
|
||||
)))));
|
||||
assert_eq!(nested.diagnostic().lock_reason, Some("timeout"));
|
||||
assert!(!format!("{:?}", nested.diagnostic()).contains("private"));
|
||||
|
||||
let nested_lock = ApiError::from(StorageError::Lock(LockError::network(
|
||||
"private-transport",
|
||||
StorageError::Lock(LockError::timeout("private-bucket/private-key", std::time::Duration::from_secs(5))),
|
||||
)));
|
||||
assert_eq!(nested_lock.diagnostic().storage_code, Some(StorageErrorCode::Lock));
|
||||
assert_eq!(nested_lock.diagnostic().lock_reason, Some("timeout"));
|
||||
assert!(!format!("{:?}", nested_lock.diagnostic()).contains("private"));
|
||||
|
||||
let nested_direct = ApiError::from(StorageError::Lock(LockError::network(
|
||||
"private-transport",
|
||||
LockError::timeout("private-bucket/private-key", std::time::Duration::from_secs(5)),
|
||||
)));
|
||||
assert_eq!(nested_direct.diagnostic().lock_reason, Some("timeout"));
|
||||
assert!(!format!("{:?}", nested_direct.diagnostic()).contains("private"));
|
||||
|
||||
let unavailable = ApiError::from(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
bucket: "private-bucket".into(),
|
||||
object: "private-key".into(),
|
||||
required: 3,
|
||||
achieved: 2,
|
||||
});
|
||||
assert_eq!(unavailable.diagnostic().lock_reason, Some("quorum"));
|
||||
assert!(!format!("{:?}", unavailable.diagnostic()).contains("private"));
|
||||
|
||||
let other = ApiError::from(StorageError::ErasureWriteQuorum);
|
||||
assert_eq!(other.diagnostic().lock_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_diagnostic_survives_s3_conversion_without_changing_response() {
|
||||
let api = ApiError::from(StorageError::Io(IoError::from_raw_os_error(30)));
|
||||
|
||||
Reference in New Issue
Block a user