mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
[codex] validate bootstrap auth and redact metadata [ci changed_files]
This commit is contained in:
@@ -73,29 +73,37 @@ impl AppsRequestProcessor {
|
||||
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
|
||||
}
|
||||
|
||||
let auth = self
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
|
||||
let empty_response = || AppsListResponse {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
};
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return Ok(Some(empty_response()));
|
||||
}
|
||||
|
||||
let auth = if config.model_provider.requires_openai_auth {
|
||||
if !self.auth_manager.is_external_chatgpt_auth_active() {
|
||||
self.auth_manager.reload().await;
|
||||
}
|
||||
self.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?
|
||||
} else {
|
||||
self.auth_manager.auth_cached()
|
||||
};
|
||||
if !config
|
||||
.features
|
||||
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|
||||
{
|
||||
return Ok(Some(AppsListResponse {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
}));
|
||||
return Ok(Some(empty_response()));
|
||||
}
|
||||
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Ok(Some(AppsListResponse {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
}));
|
||||
return Ok(Some(empty_response()));
|
||||
}
|
||||
|
||||
let request = request_id.clone();
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
@@ -55,11 +57,34 @@ use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
// Bazel CI can spend tens of seconds starting app-server subprocesses or
|
||||
// processing app-list RPCs under load.
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
async fn request_apps_list(mcp: &mut TestAppServer) -> Result<AppsListResponse> {
|
||||
let request_id = mcp
|
||||
.send_apps_list_request(AppsListParams {
|
||||
limit: Some(50),
|
||||
cursor: None,
|
||||
thread_id: None,
|
||||
force_refetch: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -89,6 +114,105 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_disabled_does_not_refresh_workload_identity() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let token_path = codex_home.path().join("projected-workload-token");
|
||||
std::fs::write(&token_path, "external-subject-token\n")?;
|
||||
write_apps_workload_identity_config(
|
||||
codex_home.path(),
|
||||
&server,
|
||||
&token_path,
|
||||
/*apps_enabled*/ false,
|
||||
)?;
|
||||
|
||||
let access_token = workload_identity_access_token()?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": access_token,
|
||||
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 2,
|
||||
"user_id": "user_test",
|
||||
"chatgpt_account_id": "workspace_test",
|
||||
"chatgpt_plan_type": "enterprise",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
std::fs::remove_file(&token_path)?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let AppsListResponse { data, next_cursor } = request_apps_list(&mut mcp).await?;
|
||||
assert!(data.is_empty());
|
||||
assert!(next_cursor.is_none());
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_local_provider_does_not_resolve_workload_identity() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let token_path = codex_home.path().join("projected-workload-token");
|
||||
std::fs::write(&token_path, "external-subject-token\n")?;
|
||||
write_local_provider_apps_workload_identity_config(codex_home.path(), &server, &token_path)?;
|
||||
|
||||
let access_token = workload_identity_access_token()?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": access_token.clone(),
|
||||
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user_id": "user_test",
|
||||
"chatgpt_account_id": "workspace_test",
|
||||
"chatgpt_plan_type": "enterprise",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/accounts/workspace_test/settings"))
|
||||
.and(header("authorization", format!("Bearer {access_token}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"beta_settings": { "enable_plugins": false },
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
std::fs::remove_file(&token_path)?;
|
||||
|
||||
let AppsListResponse { data, next_cursor } = request_apps_list(&mut mcp).await?;
|
||||
assert!(data.is_empty());
|
||||
assert!(next_cursor.is_none());
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
@@ -1653,6 +1777,85 @@ fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn write_apps_workload_identity_config(
|
||||
codex_home: &Path,
|
||||
server: &MockServer,
|
||||
token_path: &Path,
|
||||
apps_enabled: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let chatgpt_base_url = toml::Value::String(format!("{}/backend-api", server.uri()));
|
||||
let token_url = toml::Value::String(format!("{}/oauth/token", server.uri()));
|
||||
let token_path = toml::Value::String(token_path.to_string_lossy().into_owned());
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"chatgpt_base_url = {chatgpt_base_url}
|
||||
|
||||
[features]
|
||||
apps = {apps_enabled}
|
||||
|
||||
[workload_identity]
|
||||
identity_provider_id = "idp_test"
|
||||
identity_provider_mapping_id = "idpm_test"
|
||||
audience = "api://codex-test"
|
||||
token_url = {token_url}
|
||||
|
||||
[workload_identity.credential_source]
|
||||
type = "file"
|
||||
path = {token_path}
|
||||
"#,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn workload_identity_access_token() -> Result<String> {
|
||||
encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("workload@example.com")
|
||||
.plan_type("enterprise")
|
||||
.chatgpt_user_id("user_test")
|
||||
.chatgpt_account_id("workspace_test"),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_local_provider_apps_workload_identity_config(
|
||||
codex_home: &Path,
|
||||
server: &MockServer,
|
||||
token_path: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let chatgpt_base_url = toml::Value::String(format!("{}/backend-api", server.uri()));
|
||||
let token_url = toml::Value::String(format!("{}/oauth/token", server.uri()));
|
||||
let token_path = toml::Value::String(token_path.to_string_lossy().into_owned());
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"model_provider = "local"
|
||||
chatgpt_base_url = {chatgpt_base_url}
|
||||
|
||||
[model_providers.local]
|
||||
name = "Local test provider"
|
||||
base_url = "http://127.0.0.1:9/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[workload_identity]
|
||||
identity_provider_id = "idp_test"
|
||||
identity_provider_mapping_id = "idpm_test"
|
||||
audience = "api://codex-test"
|
||||
token_url = {token_url}
|
||||
|
||||
[workload_identity.credential_source]
|
||||
type = "file"
|
||||
path = {token_path}
|
||||
"#,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
|
||||
@@ -729,14 +729,17 @@ pub struct GhostSnapshotToml {
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
/// Returns workload identity only when the provider selected by this raw
|
||||
/// config needs OpenAI auth to fetch its cloud-managed config bundle.
|
||||
/// Returns validated workload identity only when the provider selected by
|
||||
/// this raw config needs OpenAI auth to fetch its cloud-managed config
|
||||
/// bundle.
|
||||
///
|
||||
/// Unknown provider IDs fail closed here; normal config loading will emit
|
||||
/// the more specific provider-not-found error later.
|
||||
pub fn workload_identity_for_cloud_config(&self) -> Option<WorkloadIdentityConfig> {
|
||||
pub fn workload_identity_for_cloud_config(
|
||||
&self,
|
||||
) -> std::io::Result<Option<WorkloadIdentityConfig>> {
|
||||
if self.forced_login_method == Some(ForcedLoginMethod::Api) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let provider_id = self.model_provider.as_deref().unwrap_or(OPENAI_PROVIDER_ID);
|
||||
@@ -750,11 +753,17 @@ impl ConfigToml {
|
||||
.or_else(|| self.model_providers.get(provider_id))
|
||||
.is_none_or(|provider| provider.requires_openai_auth);
|
||||
|
||||
if requires_openai_auth {
|
||||
self.workload_identity.clone()
|
||||
} else {
|
||||
None
|
||||
if !requires_openai_auth {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(workload_identity) = self.workload_identity.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
workload_identity.validate().map_err(|error| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string())
|
||||
})?;
|
||||
Ok(Some(workload_identity.clone()))
|
||||
}
|
||||
|
||||
/// Derive the effective permission profile from legacy sandbox config.
|
||||
@@ -1016,7 +1025,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.workload_identity_for_cloud_config(),
|
||||
config.workload_identity_for_cloud_config().unwrap(),
|
||||
Some(workload_identity)
|
||||
);
|
||||
}
|
||||
@@ -1037,7 +1046,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(config.workload_identity_for_cloud_config(), None);
|
||||
assert_eq!(config.workload_identity_for_cloud_config().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1048,7 +1057,26 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(config.workload_identity_for_cloud_config(), None);
|
||||
assert_eq!(config.workload_identity_for_cloud_config().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_config_rejects_invalid_workload_identity_before_bootstrap() {
|
||||
let mut workload_identity = workload_identity();
|
||||
workload_identity.token_url = "http://auth.example.com/oauth/token".to_string();
|
||||
let config = ConfigToml {
|
||||
workload_identity: Some(workload_identity),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = config
|
||||
.workload_identity_for_cloud_config()
|
||||
.expect_err("non-loopback HTTP token URL must be rejected");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workload_identity.token_url must use https or loopback http"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1061,7 +1089,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.workload_identity_for_cloud_config(),
|
||||
config.workload_identity_for_cloud_config().unwrap(),
|
||||
Some(workload_identity)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rollout::state_db;
|
||||
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
@@ -730,9 +731,15 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
let permission_profile = turn_context.model_visible_permission_profile();
|
||||
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
|
||||
&permission_profile,
|
||||
#[allow(deprecated)]
|
||||
&turn_context.cwd,
|
||||
);
|
||||
let sandbox_state = serde_json::to_value(SandboxState {
|
||||
permission_profile: Some(turn_context.permission_profile()),
|
||||
sandbox_policy: turn_context.sandbox_policy(),
|
||||
permission_profile: Some(permission_profile),
|
||||
sandbox_policy,
|
||||
codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(),
|
||||
#[allow(deprecated)]
|
||||
sandbox_cwd: turn_context.cwd.to_path_buf(),
|
||||
|
||||
@@ -38,12 +38,16 @@ use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::McpToolCallBeginEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::assert_regex_match;
|
||||
@@ -768,9 +772,14 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
)
|
||||
.await;
|
||||
|
||||
let credential_root = tempdir()?;
|
||||
let credential_path =
|
||||
AbsolutePathBuf::try_from(credential_root.path().join("projected-workload-token"))?;
|
||||
let credential_path_for_config = credential_path.clone();
|
||||
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
|
||||
let fixture = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.workload_identity_credential_deny_paths = vec![credential_path_for_config];
|
||||
insert_mcp_server(
|
||||
config,
|
||||
server_name,
|
||||
@@ -786,10 +795,25 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
let visible_permission_profile = PermissionProfile::read_only();
|
||||
let mut enforced_file_system_policy = visible_permission_profile.file_system_sandbox_policy();
|
||||
enforced_file_system_policy
|
||||
.entries
|
||||
.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: credential_path.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
});
|
||||
let enforced_permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
|
||||
visible_permission_profile.enforcement(),
|
||||
&enforced_file_system_policy,
|
||||
visible_permission_profile.network_sandbox_policy(),
|
||||
);
|
||||
fixture
|
||||
.submit_turn_with_permission_profile(
|
||||
"call the rmcp sandbox_meta tool",
|
||||
PermissionProfile::read_only(),
|
||||
enforced_permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -815,18 +839,28 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
let sandbox_meta = meta
|
||||
.get(MCP_SANDBOX_STATE_META_CAPABILITY)
|
||||
.expect("sandbox state metadata should be present");
|
||||
let (sandbox_policy, _) =
|
||||
turn_permission_fields(PermissionProfile::read_only(), fixture.config.cwd.as_path());
|
||||
let (sandbox_policy, _) = turn_permission_fields(
|
||||
visible_permission_profile.clone(),
|
||||
fixture.config.cwd.as_path(),
|
||||
);
|
||||
let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?;
|
||||
assert_eq!(
|
||||
sandbox_meta.get("sandboxPolicy"),
|
||||
Some(&expected_sandbox_policy)
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_meta.get("permissionProfile"),
|
||||
Some(&serde_json::to_value(&visible_permission_profile)?)
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_meta.get("sandboxCwd").and_then(Value::as_str),
|
||||
fixture.config.cwd.as_path().to_str()
|
||||
);
|
||||
assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false)));
|
||||
assert!(
|
||||
!serde_json::to_string(sandbox_meta)?.contains(credential_path.to_string_lossy().as_ref()),
|
||||
"enforcement-only workload credential path leaked into MCP metadata"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
let workload_identity = if oss {
|
||||
None
|
||||
} else {
|
||||
bootstrap_config_toml.workload_identity_for_cloud_config()
|
||||
bootstrap_config_toml.workload_identity_for_cloud_config()?
|
||||
};
|
||||
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
||||
codex_home.to_path_buf(),
|
||||
|
||||
@@ -959,7 +959,7 @@ pub async fn run_main(
|
||||
let workload_identity = if cli.oss {
|
||||
None
|
||||
} else {
|
||||
bootstrap_config_toml.workload_identity_for_cloud_config()
|
||||
bootstrap_config_toml.workload_identity_for_cloud_config()?
|
||||
};
|
||||
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
||||
codex_home.to_path_buf(),
|
||||
|
||||
@@ -332,7 +332,7 @@ async fn start_app_server_for_archive_command(
|
||||
let workload_identity = if cli.oss {
|
||||
None
|
||||
} else {
|
||||
config_toml.workload_identity_for_cloud_config()
|
||||
config_toml.workload_identity_for_cloud_config()?
|
||||
};
|
||||
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
||||
codex_home.to_path_buf(),
|
||||
|
||||
Reference in New Issue
Block a user