feat: complete Epic 1 production foundation
This commit is contained in:
+257
-114
@@ -31,6 +31,11 @@ pub enum ApiError {
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Unprocessable {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
NotFound {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
@@ -41,6 +46,16 @@ pub enum ApiError {
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
PayloadTooLarge {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
PreconditionRequired {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
RateLimited {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
@@ -95,6 +110,13 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Unprocessable {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::NotFound {
|
||||
message: message.into(),
|
||||
@@ -109,25 +131,58 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_too_large_with_context(
|
||||
message: impl Into<String>,
|
||||
context: Value,
|
||||
) -> Self {
|
||||
Self::PayloadTooLarge {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn precondition_required_with_context(
|
||||
message: impl Into<String>,
|
||||
context: Value,
|
||||
) -> Self {
|
||||
Self::PreconditionRequired {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
|
||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
pub(crate) fn code(&self) -> &str {
|
||||
if let Some(code) = self
|
||||
.context_ref()
|
||||
.and_then(|context| context.get("error_code"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
match self {
|
||||
Self::Unauthorized { .. } => "unauthorized",
|
||||
Self::Forbidden { .. } => "forbidden",
|
||||
Self::Validation { .. } => "validation_error",
|
||||
Self::Unprocessable { .. } => "unprocessable_entity",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::PayloadTooLarge { .. } => "payload_too_large",
|
||||
Self::PreconditionRequired { .. } => "precondition_required",
|
||||
Self::RateLimited { .. } => "rate_limited",
|
||||
Self::Internal { .. } => "internal_error",
|
||||
}
|
||||
@@ -147,8 +202,11 @@ impl IntoResponse for ApiError {
|
||||
Self::Unauthorized { .. }
|
||||
| Self::Forbidden { .. }
|
||||
| Self::Validation { .. }
|
||||
| Self::Unprocessable { .. }
|
||||
| Self::NotFound { .. }
|
||||
| Self::Conflict { .. }
|
||||
| Self::PayloadTooLarge { .. }
|
||||
| Self::PreconditionRequired { .. }
|
||||
| Self::RateLimited { .. } => {
|
||||
warn!(
|
||||
name: "admin.response.rejected",
|
||||
@@ -182,17 +240,24 @@ impl IntoResponse for ApiError {
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn context(&self) -> Option<Value> {
|
||||
fn context_ref(&self) -> Option<&Value> {
|
||||
match self {
|
||||
Self::Unauthorized { context, .. }
|
||||
| Self::Forbidden { context, .. }
|
||||
| Self::Validation { context, .. }
|
||||
| Self::Unprocessable { context, .. }
|
||||
| Self::NotFound { context, .. }
|
||||
| Self::Conflict { context, .. }
|
||||
| Self::PayloadTooLarge { context, .. }
|
||||
| Self::PreconditionRequired { context, .. }
|
||||
| Self::RateLimited { context, .. }
|
||||
| Self::Internal { context, .. } => context.clone(),
|
||||
| Self::Internal { context, .. } => context.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn context(&self) -> Option<Value> {
|
||||
self.context_ref().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegistryError> for ApiError {
|
||||
@@ -228,10 +293,41 @@ impl From<RegistryError> for ApiError {
|
||||
format!("platform api key {key_id} was not found"),
|
||||
json!({ "key_id": key_id }),
|
||||
),
|
||||
RegistryError::PlatformApiKeyInactive { key_id } => Self::conflict_with_context(
|
||||
"platform api key is not active",
|
||||
json!({
|
||||
"key_id": key_id,
|
||||
"error_code": "platform_api_key_not_active",
|
||||
"recovery": "create_replacement_key"
|
||||
}),
|
||||
),
|
||||
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
||||
format!("secret {secret_id} was not found"),
|
||||
json!({ "secret_id": secret_id }),
|
||||
),
|
||||
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
|
||||
"secret is not active",
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"error_code": "secret_not_active",
|
||||
"recovery": "rotate_or_replace_secret"
|
||||
}),
|
||||
),
|
||||
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
|
||||
"secret was updated concurrently",
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"error_code": "secret_concurrent_update",
|
||||
"recovery": "reload"
|
||||
}),
|
||||
),
|
||||
RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context(
|
||||
"master key rotation is in progress",
|
||||
json!({
|
||||
"error_code": "master_key_rotation_in_progress",
|
||||
"recovery": "retry_after_rotation"
|
||||
}),
|
||||
),
|
||||
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
||||
format!("invocation log {log_id} was not found"),
|
||||
json!({ "log_id": log_id }),
|
||||
@@ -268,6 +364,57 @@ impl From<RegistryError> for ApiError {
|
||||
json!({ "operation_id": operation_id }),
|
||||
)
|
||||
}
|
||||
RegistryError::OperationArchived { operation_id } => Self::conflict_with_context(
|
||||
format!("operation {operation_id} is archived"),
|
||||
json!({ "operation_id": operation_id, "error_code": "operation_archived" }),
|
||||
),
|
||||
RegistryError::OperationStaleVersion {
|
||||
operation_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::conflict_with_context(
|
||||
format!("operation {operation_id} has a stale base version"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"current_version": expected,
|
||||
"provided_version": actual,
|
||||
"error_code": "operation_stale_version",
|
||||
"recovery": "reload"
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidOperationTransition {
|
||||
operation_id,
|
||||
from,
|
||||
action,
|
||||
} => Self::conflict_with_context(
|
||||
format!("operation {operation_id} cannot perform {action} from {from}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"state": from,
|
||||
"action": action,
|
||||
"error_code": "operation_invalid_transition"
|
||||
}),
|
||||
),
|
||||
RegistryError::OperationDeleteForbidden { operation_id } => {
|
||||
Self::conflict_with_context(
|
||||
format!(
|
||||
"operation {operation_id} cannot be deleted because durable history exists"
|
||||
),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"error_code": "operation_delete_forbidden"
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::OperationAuthProfileUnavailable { operation_id } => {
|
||||
Self::unprocessable_with_context(
|
||||
"operation auth profile reference is unavailable",
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"error_code": "operation_auth_profile_invalid"
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
|
||||
format!("auth profile {auth_profile_id} was not found"),
|
||||
json!({ "auth_profile_id": auth_profile_id }),
|
||||
@@ -276,6 +423,17 @@ impl From<RegistryError> for ApiError {
|
||||
format!("operation {operation_id} already exists"),
|
||||
json!({ "operation_id": operation_id }),
|
||||
),
|
||||
RegistryError::PlatformApiKeyNameAlreadyExists { workspace_id, name } => {
|
||||
Self::conflict_with_context(
|
||||
"platform api key name already exists",
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"name": name,
|
||||
"error_code": "platform_api_key_name_conflict",
|
||||
"recovery": "choose_different_name"
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
|
||||
format!("workspace with slug {slug} already exists"),
|
||||
json!({ "slug": slug }),
|
||||
@@ -286,6 +444,8 @@ impl From<RegistryError> for ApiError {
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"name": name,
|
||||
"error_code": "secret_name_conflict",
|
||||
"recovery": "choose_different_name"
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -297,12 +457,29 @@ impl From<RegistryError> for ApiError {
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"auth_profile_id": auth_profile_id,
|
||||
"error_code": "secret_referenced_by_auth_profile",
|
||||
"recovery": "remove_or_update_auth_profile_reference"
|
||||
}),
|
||||
),
|
||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||
format!("user with email {email} already exists"),
|
||||
json!({ "email": email }),
|
||||
),
|
||||
RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => {
|
||||
Self::unauthorized("bootstrap request is invalid or expired")
|
||||
}
|
||||
RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"),
|
||||
RegistryError::AdminLoginRateLimited { retry_after_ms } => {
|
||||
Self::rate_limited_with_context(
|
||||
"login temporarily unavailable",
|
||||
json!({
|
||||
"retry_after_ms": retry_after_ms.clamp(1, 300_000),
|
||||
"error_code": "login_throttled",
|
||||
"recovery": "retry_after_delay"
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"),
|
||||
RegistryError::InvalidInitialVersion {
|
||||
operation_id,
|
||||
version,
|
||||
@@ -337,6 +514,39 @@ impl From<RegistryError> for ApiError {
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::ImmutableAgentVersion { agent_id, version } => {
|
||||
Self::conflict_with_context(
|
||||
"Agent Version is immutable; reload before editing",
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"version": version,
|
||||
"error_code": "agent_stale_revision",
|
||||
"recovery": "reload"
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context(
|
||||
"Agent state changed; reload before retrying",
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"error_code": "agent_stale_revision",
|
||||
"recovery": "reload"
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAgentTransition {
|
||||
agent_id,
|
||||
from,
|
||||
action,
|
||||
} => Self::conflict_with_context(
|
||||
format!("agent {agent_id} cannot transition from {from} using {action}"),
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"from": from,
|
||||
"action": action,
|
||||
"error_code": "agent_invalid_transition",
|
||||
"recovery": "reload"
|
||||
}),
|
||||
),
|
||||
RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id,
|
||||
field,
|
||||
@@ -347,6 +557,14 @@ impl From<RegistryError> for ApiError {
|
||||
"field": field,
|
||||
}),
|
||||
),
|
||||
RegistryError::OnboardingStaleRevision => Self::conflict_with_context(
|
||||
"onboarding state changed; reload before retrying",
|
||||
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
|
||||
),
|
||||
RegistryError::OnboardingIncomplete => Self::unprocessable_with_context(
|
||||
"onboarding domain steps are not complete",
|
||||
json!({"error_code":"onboarding_incomplete"}),
|
||||
),
|
||||
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
|
||||
format!("unsupported enum representation for field {field}"),
|
||||
json!({ "field": field }),
|
||||
@@ -370,10 +588,18 @@ impl From<RegistryError> for ApiError {
|
||||
format!("import job {job_id} was already applied with different parameters"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) => Self::internal("registry operation failed"),
|
||||
RegistryError::Migration(_)
|
||||
| RegistryError::Storage(_)
|
||||
| RegistryError::Serialization(_)
|
||||
| RegistryError::InvalidCorrelationIdentity { .. } => Self::internal(value.to_string()),
|
||||
| RegistryError::MasterKeyIdentityMismatch { .. }
|
||||
| RegistryError::InvalidMasterKeyIdentity
|
||||
| RegistryError::MasterKeyRotationNotFound { .. }
|
||||
| RegistryError::MasterKeyRotationConflict
|
||||
| RegistryError::MasterKeyRotationVerificationFailed
|
||||
| RegistryError::InvalidCorrelationIdentity { .. }
|
||||
| RegistryError::InvalidExecutionRecord { .. } => {
|
||||
Self::internal("registry operation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,117 +631,34 @@ impl From<StorageError> for ApiError {
|
||||
}
|
||||
|
||||
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||
let failure =
|
||||
crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate());
|
||||
execution_test_failure(&failure)
|
||||
}
|
||||
|
||||
pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value {
|
||||
execution_test_failure_localized(failure, crank_core::ExecutionLocale::En)
|
||||
}
|
||||
|
||||
pub fn execution_test_failure_localized(
|
||||
failure: &crank_core::ExecutionFailure,
|
||||
locale: crank_core::ExecutionLocale,
|
||||
) -> Value {
|
||||
let mut payload = json!({
|
||||
"code": runtime_test_failure_code(error),
|
||||
"message": safe_runtime_test_failure_message(error)
|
||||
"code": failure.error_code().as_str(),
|
||||
"message": failure.error_code().message(locale),
|
||||
"stage": failure.stage().as_str(),
|
||||
"retryability": failure.retryability().as_str(),
|
||||
"outcome_certainty": failure.outcome_certainty().as_str(),
|
||||
});
|
||||
if let Some(context) = runtime_error_context(error) {
|
||||
payload["context"] = context;
|
||||
if let Some(status) = failure.upstream_status() {
|
||||
payload["context"] = json!({ "upstream_status": status });
|
||||
}
|
||||
if let Some(challenge) = failure.confirmation() {
|
||||
payload["context"] = json!({
|
||||
"confirmation_token": challenge.token(),
|
||||
"expires_in_ms": challenge.expires_in_ms(),
|
||||
});
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn safe_runtime_test_failure_message(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "input schema validation failed",
|
||||
RuntimeError::Mapping(_) => "input mapping failed",
|
||||
RuntimeError::RestAdapter(_) | RuntimeError::ProtocolAdapter(_) => {
|
||||
"upstream execution failed"
|
||||
}
|
||||
RuntimeError::UnsupportedProtocol { .. } => "operation protocol is unsupported",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime concurrency limit exceeded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "prepared request is invalid",
|
||||
RuntimeError::ConfirmationRequired { .. } => "operation confirmation is required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "confirmation token is invalid",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation store is unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency store is unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "idempotent execution is in progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "idempotency key conflicts with the request",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "previous execution outcome is unknown",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "execution mode is unsupported",
|
||||
RuntimeError::MissingAuthProfile { .. } => "authorization profile is missing",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"authorization secret is missing"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "authorization secret is invalid",
|
||||
RuntimeError::SecretCrypto { .. } => "authorization secret processing failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
match error {
|
||||
RuntimeError::InvalidPreparedRequest { field, .. } => Some(json!({
|
||||
"field": field,
|
||||
})),
|
||||
RuntimeError::ConfirmationRequired {
|
||||
confirmation_token,
|
||||
expires_in_ms,
|
||||
safety_class,
|
||||
..
|
||||
} => Some(json!({
|
||||
"confirmation_token": confirmation_token,
|
||||
"expires_in_ms": expires_in_ms,
|
||||
"safety_class": safety_class,
|
||||
})),
|
||||
RuntimeError::InvalidConfirmationToken { operation_id }
|
||||
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyInProgress { operation_id }
|
||||
| RuntimeError::IdempotencyConflict { operation_id }
|
||||
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, .. } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
})),
|
||||
RuntimeError::SecretCrypto { .. } => None,
|
||||
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
|
||||
"auth_profile_id": auth_profile_id,
|
||||
})),
|
||||
RuntimeError::MissingSecret { secret_id } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
})),
|
||||
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"version": version,
|
||||
})),
|
||||
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
"mode": mode,
|
||||
})),
|
||||
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
|
||||
"protocol": protocol,
|
||||
})),
|
||||
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user