From 1b5700b6685b17104bc53b2b7525ea703a19ef41 Mon Sep 17 00:00:00 2001 From: nicolaeser Date: Tue, 11 Aug 2026 00:09:41 +0200 Subject: [PATCH] feat(auth): multi-user foundation with admin, shares, and WebUI bootstrap Add site roles, bootstrap admin credentials, resource collaboration shares for conversations/providers/projects, and owner-scoped ACL across auth, db, conversation, project, and provider paths for team WebUI hosting. --- .gitignore | 2 + Cargo.lock | 53 +- Cargo.toml | 2 +- crates/aionui-ai-agent/src/agent_task.rs | 1 + crates/aionui-ai-agent/src/factory/aionrs.rs | 95 ++- crates/aionui-ai-agent/src/factory/mod.rs | 43 +- .../src/manager/aionrs/agent.rs | 4 +- .../src/manager/aionrs/agent_test.rs | 1 + crates/aionui-ai-agent/src/routes/agent.rs | 24 +- crates/aionui-ai-agent/src/routes/remote.rs | 24 +- crates/aionui-ai-agent/src/routes/state.rs | 6 + crates/aionui-ai-agent/src/services/agent.rs | 5 +- .../src/services/provider_health.rs | 83 ++- crates/aionui-ai-agent/src/types.rs | 4 + .../tests/agent_types_integration.rs | 1 + .../tests/factory_provider_integration.rs | 23 +- crates/aionui-api-types/src/auth.rs | 111 +++ crates/aionui-api-types/src/lib.rs | 18 +- crates/aionui-api-types/src/share.rs | 106 +++ crates/aionui-api-types/tests/auth_types.rs | 7 +- crates/aionui-app/Cargo.toml | 1 + .../aionui-app/src/bootstrap/environment.rs | 119 +++- crates/aionui-app/src/config.rs | 106 +++ crates/aionui-app/src/lib.rs | 2 +- crates/aionui-app/src/router/routes.rs | 672 ++++++++++++++++-- crates/aionui-app/src/router/state.rs | 119 +++- crates/aionui-app/src/services.rs | 449 +++++++++++- crates/aionui-app/tests/acp_e2e.rs | 16 +- crates/aionui-app/tests/assistants_e2e.rs | 298 +++++++- crates/aionui-app/tests/auxiliary_e2e.rs | 58 +- crates/aionui-app/tests/common/mod.rs | 67 +- crates/aionui-app/tests/conversation_e2e.rs | 83 ++- crates/aionui-app/tests/custom_agent_e2e.rs | 57 ++ crates/aionui-app/tests/extension_e2e.rs | 77 +- crates/aionui-app/tests/file_e2e.rs | 349 ++++++++- crates/aionui-app/tests/local_mode.rs | 9 +- crates/aionui-app/tests/office_e2e.rs | 58 +- crates/aionui-app/tests/remote_agent_e2e.rs | 39 + crates/aionui-app/tests/skills_builtin_e2e.rs | 18 +- crates/aionui-app/tests/team_e2e.rs | 65 +- crates/aionui-app/tests/work_dir_e2e.rs | 3 + crates/aionui-assistant/src/lib.rs | 2 +- crates/aionui-assistant/src/routes.rs | 82 ++- crates/aionui-assistant/src/service.rs | 557 ++++++++++++++- crates/aionui-assistant/src/state.rs | 3 + crates/aionui-auth/Cargo.toml | 3 +- crates/aionui-auth/src/admin_service.rs | 208 ++++++ crates/aionui-auth/src/jwt.rs | 27 + crates/aionui-auth/src/lib.rs | 6 +- crates/aionui-auth/src/middleware.rs | 69 +- crates/aionui-auth/src/routes.rs | 583 ++++++++++++--- crates/aionui-auth/src/service.rs | 25 +- crates/aionui-auth/src/share_service.rs | 243 +++++++ crates/aionui-auth/tests/middleware_tests.rs | 2 + crates/aionui-auth/tests/route_tests.rs | 137 +++- crates/aionui-auth/tests/share_route_tests.rs | 235 ++++++ crates/aionui-channel/src/manager.rs | 12 +- .../src/plugins/weixin/login.rs | 334 ++++++++- .../aionui-channel/src/plugins/weixin/mod.rs | 2 +- .../src/plugins/weixin/plugin.rs | 104 ++- crates/aionui-channel/src/routes.rs | 64 +- .../tests/manager_integration.rs | 21 +- .../tests/weixin_integration.rs | 14 + crates/aionui-common/Cargo.toml | 2 + crates/aionui-common/src/lib.rs | 7 +- crates/aionui-common/src/public_url.rs | 206 ++++++ crates/aionui-common/src/user_paths.rs | 33 + crates/aionui-conversation/src/service.rs | 108 ++- crates/aionui-conversation/src/service_ops.rs | 14 +- .../src/session_context.rs | 2 +- crates/aionui-cron/src/routes.rs | 16 +- crates/aionui-cron/src/service.rs | 7 + crates/aionui-cron/src/state.rs | 4 + .../aionui-cron/tests/service_integration.rs | 91 ++- .../038_multi_user_identity_foundation.sql | 55 ++ .../039_user_scope_assistant_ids.sql | 42 ++ .../migrations/040_resource_collaboration.sql | 17 + crates/aionui-db/src/database.rs | 5 +- crates/aionui-db/src/lib.rs | 28 +- crates/aionui-db/src/models/mod.rs | 4 +- crates/aionui-db/src/models/resource_share.rs | 156 ++++ crates/aionui-db/src/models/user.rs | 46 ++ crates/aionui-db/src/repository/admin_user.rs | 88 +++ crates/aionui-db/src/repository/mod.rs | 7 + crates/aionui-db/src/repository/project.rs | 9 +- .../src/repository/resource_share.rs | 58 ++ .../src/repository/sqlite_admin_user.rs | 611 ++++++++++++++++ .../src/repository/sqlite_assistant.rs | 49 +- .../src/repository/sqlite_conversation.rs | 337 +++++---- .../src/repository/sqlite_project.rs | 41 +- .../src/repository/sqlite_provider.rs | 69 +- .../src/repository/sqlite_resource_share.rs | 433 +++++++++++ .../aionui-db/src/repository/sqlite_team.rs | 29 +- .../aionui-db/src/repository/sqlite_user.rs | 93 ++- crates/aionui-db/src/repository/team.rs | 15 +- crates/aionui-db/src/repository/user.rs | 18 + crates/aionui-db/tests/adoption_coverage.rs | 8 +- .../assistant_data_unification_schema.rs | 16 + .../tests/resource_share_repository.rs | 172 +++++ crates/aionui-db/tests/team_repository.rs | 67 +- crates/aionui-extension/src/hub_routes.rs | 86 ++- crates/aionui-extension/src/skill_routes.rs | 523 +++++++++++++- crates/aionui-extension/src/skill_service.rs | 40 +- .../tests/assistant_dispatch_test.rs | 4 +- crates/aionui-file/src/routes.rs | 311 +++++++- crates/aionui-file/src/service.rs | 59 +- crates/aionui-file/src/traits.rs | 14 + crates/aionui-mcp/Cargo.toml | 1 + crates/aionui-mcp/src/routes.rs | 159 +++++ crates/aionui-office/src/routes.rs | 16 +- crates/aionui-project/src/chat_files.rs | 26 +- crates/aionui-project/src/monitor/wire.rs | 1 + crates/aionui-project/src/routes.rs | 1 + crates/aionui-project/src/routes_test.rs | 2 + crates/aionui-project/src/scm/actor.rs | 28 +- crates/aionui-project/src/scm/runtime.rs | 14 + crates/aionui-project/src/service.rs | 225 +++++- crates/aionui-project/src/types.rs | 4 + .../aionui-project/tests/scm_request_path.rs | 62 +- crates/aionui-project/tests/service.rs | 18 + crates/aionui-shell/Cargo.toml | 2 +- crates/aionui-shell/src/routes.rs | 197 ++++- crates/aionui-shell/src/state.rs | 3 + .../aionui-system/src/bedrock_probe/routes.rs | 52 +- crates/aionui-system/src/lib.rs | 4 +- .../src/model_fetcher/fetchers.rs | 4 +- crates/aionui-system/src/model_fetcher/mod.rs | 98 ++- .../src/model_fetcher/network_guard.rs | 171 +++++ crates/aionui-system/src/protocol.rs | 70 +- crates/aionui-system/src/provider.rs | 173 ++++- crates/aionui-system/src/provider_network.rs | 147 ++++ crates/aionui-system/src/routes.rs | 35 +- crates/aionui-system/src/sysinfo.rs | 21 + .../tests/feedback_diagnostics_routes.rs | 4 +- .../aionui-system/tests/model_fetch_routes.rs | 47 +- .../tests/protocol_detection_routes.rs | 38 +- crates/aionui-system/tests/provider_routes.rs | 174 ++++- crates/aionui-system/tests/settings_routes.rs | 6 +- .../aionui-system/tests/system_info_routes.rs | 38 +- crates/aionui-team/src/mailbox.rs | 2 +- crates/aionui-team/src/provisioning.rs | 14 +- crates/aionui-team/src/service.rs | 35 +- crates/aionui-team/src/test_utils.rs | 19 +- crates/aionui-team/src/workspace.rs | 29 +- crates/aionui-team/tests/common/mod.rs | 15 +- .../tests/session_service_integration.rs | 15 +- 146 files changed, 11077 insertions(+), 904 deletions(-) create mode 100644 crates/aionui-api-types/src/share.rs create mode 100644 crates/aionui-auth/src/admin_service.rs create mode 100644 crates/aionui-auth/src/share_service.rs create mode 100644 crates/aionui-auth/tests/share_route_tests.rs create mode 100644 crates/aionui-common/src/public_url.rs create mode 100644 crates/aionui-db/migrations/038_multi_user_identity_foundation.sql create mode 100644 crates/aionui-db/migrations/039_user_scope_assistant_ids.sql create mode 100644 crates/aionui-db/migrations/040_resource_collaboration.sql create mode 100644 crates/aionui-db/src/models/resource_share.rs create mode 100644 crates/aionui-db/src/repository/admin_user.rs create mode 100644 crates/aionui-db/src/repository/resource_share.rs create mode 100644 crates/aionui-db/src/repository/sqlite_admin_user.rs create mode 100644 crates/aionui-db/src/repository/sqlite_resource_share.rs create mode 100644 crates/aionui-db/tests/resource_share_repository.rs create mode 100644 crates/aionui-system/src/model_fetcher/network_guard.rs create mode 100644 crates/aionui-system/src/provider_network.rs diff --git a/.gitignore b/.gitignore index 6b43ead06..51a733222 100644 --- a/.gitignore +++ b/.gitignore @@ -577,3 +577,5 @@ graphify-out/ .tmp/ .playwright-mcp/ +target-linux/ +.cargo-docker/ diff --git a/Cargo.lock b/Cargo.lock index 84b625a7b..6d082e59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,7 +334,7 @@ dependencies = [ [[package]] name = "aionui-ai-agent" -version = "0.1.63" +version = "0.1.64" dependencies = [ "agent-client-protocol", "aion-agent", @@ -387,7 +387,7 @@ dependencies = [ [[package]] name = "aionui-api-types" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-common", "serde", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "aionui-app" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aion-config", "aionui-ai-agent", @@ -448,6 +448,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "uuid", "windows-sys 0.61.2", "wiremock", @@ -455,7 +456,7 @@ dependencies = [ [[package]] name = "aionui-assets" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-common", "axum", @@ -470,7 +471,7 @@ dependencies = [ [[package]] name = "aionui-assistant" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -496,7 +497,7 @@ dependencies = [ [[package]] name = "aionui-auth" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-common", @@ -512,6 +513,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 2.0.18", "tokio", "tower", @@ -521,7 +523,7 @@ dependencies = [ [[package]] name = "aionui-channel" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -553,7 +555,7 @@ dependencies = [ [[package]] name = "aionui-common" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aes-gcm", "async-trait", @@ -563,14 +565,16 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", + "url", "uuid", ] [[package]] name = "aionui-conversation" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -601,7 +605,7 @@ dependencies = [ [[package]] name = "aionui-cron" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -630,7 +634,7 @@ dependencies = [ [[package]] name = "aionui-db" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-common", "async-trait", @@ -646,7 +650,7 @@ dependencies = [ [[package]] name = "aionui-extension" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -679,7 +683,7 @@ dependencies = [ [[package]] name = "aionui-file" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -710,7 +714,7 @@ dependencies = [ [[package]] name = "aionui-mcp" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -732,13 +736,14 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 0.8.23", + "tower", "tracing", "tracing-subscriber", ] [[package]] name = "aionui-office" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -766,7 +771,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-common", "aionui-runtime", @@ -786,7 +791,7 @@ dependencies = [ [[package]] name = "aionui-project" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -813,7 +818,7 @@ dependencies = [ [[package]] name = "aionui-realtime" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "axum", @@ -828,7 +833,7 @@ dependencies = [ [[package]] name = "aionui-runtime" -version = "0.1.63" +version = "0.1.64" dependencies = [ "dirs", "flate2", @@ -854,7 +859,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-common", "aionui-process", @@ -874,7 +879,7 @@ dependencies = [ [[package]] name = "aionui-shell" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -907,7 +912,7 @@ dependencies = [ [[package]] name = "aionui-system" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "aionui-auth", @@ -937,7 +942,7 @@ dependencies = [ [[package]] name = "aionui-team" -version = "0.1.63" +version = "0.1.64" dependencies = [ "agent-client-protocol", "aionui-ai-agent", @@ -966,7 +971,7 @@ dependencies = [ [[package]] name = "aionui-team-prompts" -version = "0.1.63" +version = "0.1.64" dependencies = [ "aionui-api-types", "serde", diff --git a/Cargo.toml b/Cargo.toml index a2b92cd4e..78bb1c9b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ members = [ ] [workspace.package] -version = "0.1.63" +version = "0.1.64" edition = "2024" license = "MIT" diff --git a/crates/aionui-ai-agent/src/agent_task.rs b/crates/aionui-ai-agent/src/agent_task.rs index 52198a17a..71cc67fbc 100644 --- a/crates/aionui-ai-agent/src/agent_task.rs +++ b/crates/aionui-ai-agent/src/agent_task.rs @@ -627,6 +627,7 @@ mod aionrs_config_option_tests { bedrock_config: None, runtime_env: Vec::new(), prompt_dump_dir: None, + tool_policy: aion_agent::tool_policy::ToolPolicy::Unrestricted, } } diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index da9fba085..da6dc907c 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -25,14 +25,28 @@ use crate::manager::aionrs::{AionrsAgentManager, sanitize_session_messages}; use crate::runtime_status::conversation_runtime_reporter; use crate::session_context::AionrsSessionBuildContext; use crate::types::{AionrsCompatOverrides, AionrsResolvedConfig}; + +fn runtime_tool_policy(host_tools_allowed: bool) -> aion_agent::tool_policy::ToolPolicy { + if host_tools_allowed { + aion_agent::tool_policy::ToolPolicy::Unrestricted + } else { + aion_agent::tool_policy::ToolPolicy::allow_only(std::iter::empty::()) + } +} + pub(super) async fn build( deps: Arc, build_context: AionrsSessionBuildContext, model: ProviderWithModel, ctx: FactoryContext, + host_tools_allowed: bool, ) -> Result { let mut overrides = build_context.config; - let resolved_skills = overrides.skills.clone(); + let resolved_skills = if host_tools_allowed { + overrides.skills.clone() + } else { + Vec::new() + }; // Merge preset assistant rules into system_prompt (used as custom_prompt // in aionrs's build_system_prompt). Mirrors the old architecture's @@ -48,8 +62,12 @@ pub(super) async fn build( }); } - let mut extra_mcp_servers = resolve_mcp_servers(&overrides); - if let Some(repo) = deps.mcp_server_repo.as_ref() { + let mut extra_mcp_servers = if host_tools_allowed { + resolve_mcp_servers(&overrides) + } else { + HashMap::new() + }; + if host_tools_allowed && let Some(repo) = deps.mcp_server_repo.as_ref() { for (name, config) in load_user_mcp_servers( repo.as_ref(), overrides.mcp_server_ids.as_deref(), @@ -62,14 +80,16 @@ pub(super) async fn build( extra_mcp_servers.entry(name).or_insert(config); } } - merge_session_snapshot_mcp_servers( - &mut extra_mcp_servers, - &overrides.session_mcp_servers, - &ctx.user_id, - &ctx.conversation_id, - deps.broadcaster.clone(), - ) - .await; + if host_tools_allowed { + merge_session_snapshot_mcp_servers( + &mut extra_mcp_servers, + &overrides.session_mcp_servers, + &ctx.user_id, + &ctx.conversation_id, + deps.broadcaster.clone(), + ) + .await; + } if !extra_mcp_servers.is_empty() { info!( @@ -109,6 +129,12 @@ pub(super) async fn build( row.is_full_url, model_overrides.openai_api_mode, ); + enforce_member_provider_runtime( + host_tools_allowed, + &row.platform, + base_url.as_deref().unwrap_or(&row.base_url), + ) + .await?; compat_overrides.image_input = model_overrides.image_input; if provider == "openai" { @@ -198,6 +224,7 @@ pub(super) async fn build( bedrock_config, runtime_env: ctx.runtime_env, prompt_dump_dir: crate::dev_prompt_dump::dump_dir_for_data_dir(&deps.data_dir, deps.dump_prompts), + tool_policy: runtime_tool_policy(host_tools_allowed), }; if let Some(system_prompt) = config.system_prompt.as_deref() @@ -236,6 +263,19 @@ pub(super) async fn build( Ok(AgentInstance::Aionrs(Arc::new(agent))) } +async fn enforce_member_provider_runtime( + host_tools_allowed: bool, + platform: &str, + base_url: &str, +) -> Result<(), AgentError> { + if host_tools_allowed { + return Ok(()); + } + aionui_system::validate_member_provider_runtime(platform, base_url) + .await + .map_err(|error| AgentError::bad_request(error.to_string())) +} + /// Map AionUi DB platform/protocol settings to the aionrs provider identifier. pub(crate) fn map_aionrs_provider( platform: &str, @@ -1948,4 +1988,37 @@ mod tests { assert_eq!(overrides.system_prompt.as_deref(), Some("Be concise.")); } + + #[test] + fn hosted_member_policy_denies_every_registered_tool() { + let policy = runtime_tool_policy(false); + for name in ["Read", "Write", "Edit", "ExecCommand", "Skill", "ToolSearch", "Spawn"] { + assert!(!policy.allows(name), "{name} must remain unavailable to hosted members"); + } + } + + #[test] + fn trusted_operator_policy_keeps_desktop_tool_surface() { + assert!(runtime_tool_policy(true).allows("ExecCommand")); + } + + #[tokio::test] + async fn hosted_member_runtime_rejects_private_and_unapproved_provider_endpoints() { + for base_url in ["http://127.0.0.1:11434/v1", "https://attacker.example/v1"] { + let error = enforce_member_provider_runtime(false, "custom", base_url) + .await + .unwrap_err(); + assert!( + matches!(error, AgentError::BadRequest(_)), + "{base_url} must fail closed" + ); + } + } + + #[tokio::test] + async fn trusted_operator_runtime_retains_local_provider_support() { + enforce_member_provider_runtime(true, "custom", "http://127.0.0.1:11434/v1") + .await + .unwrap(); + } } diff --git a/crates/aionui-ai-agent/src/factory/mod.rs b/crates/aionui-ai-agent/src/factory/mod.rs index 829b72651..31255b14b 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -9,7 +9,7 @@ mod context; use std::path::PathBuf; use std::sync::Arc; -use aionui_db::{IMcpServerRepository, IProviderRepository}; +use aionui_db::{IMcpServerRepository, IProviderRepository, IUserRepository, SiteRole}; use aionui_realtime::EventBroadcaster; use futures_util::FutureExt; @@ -27,6 +27,8 @@ use crate::types::BuildTaskOptions; pub struct AgentFactoryDeps { pub skill_manager: Arc, pub provider_repo: Arc, + /// Live user records used to enforce the hosted-runtime trust boundary. + pub user_repo: Arc, pub encryption_key: [u8; 32], pub agent_registry: Arc, pub acp_agent_service: Arc, @@ -41,6 +43,10 @@ pub struct AgentFactoryDeps { /// inject enabled servers into `session/new` (ELECTRON-1JG fix). /// `None` for tests/composition paths that do not need MCP injection. pub mcp_server_repo: Option>, + /// In hosted WebUI mode, only live local administrators may start agents + /// with host-process or unrestricted filesystem tools. Members retain the + /// built-in conversational agent with its tool surface disabled. + pub restrict_member_host_tools: bool, /// Subprocess spawner for the clean-slate session model. claude/codex always /// run through `SessionAgentTask` (direct-CLI) instead of the ACP manager, so /// the spawner is unconditionally wired — there is no fallback to the ACP path. @@ -73,14 +79,36 @@ pub fn build_agent_factory(deps: AgentFactoryDeps) -> AgentFactory { }) } +fn can_run_host_tools(restrict_member_host_tools: bool, site_role: SiteRole) -> bool { + !restrict_member_host_tools || site_role == SiteRole::Admin +} + async fn build_agent(deps: Arc, options: BuildTaskOptions) -> Result { let context = options.context; let ctx = FactoryContext::resolve(&context).await?; let model = context.model.clone(); + let host_tools_allowed = if deps.restrict_member_host_tools { + let user = deps + .user_repo + .find_active_by_id(&ctx.user_id) + .await + .map_err(|error| AgentError::internal(format!("Failed to authorize agent runtime: {error}")))? + .ok_or_else(|| AgentError::unauthorized("Active user required for agent runtime"))?; + can_run_host_tools(true, user.site_role) + } else { + true + }; match context.kind { - AgentSessionKind::Acp(acp_context) => acp::build(deps, *acp_context, ctx).await, - AgentSessionKind::Aionrs(aionrs_context) => aionrs::build(deps, *aionrs_context, model, ctx).await, - AgentSessionKind::Antigravity(agy_context) => antigravity::build(deps, *agy_context, ctx).await, + AgentSessionKind::Aionrs(aionrs_context) => { + aionrs::build(deps, *aionrs_context, model, ctx, host_tools_allowed).await + } + AgentSessionKind::Acp(acp_context) if host_tools_allowed => acp::build(deps, *acp_context, ctx).await, + AgentSessionKind::Antigravity(agy_context) if host_tools_allowed => { + antigravity::build(deps, *agy_context, ctx).await + } + AgentSessionKind::Acp(_) | AgentSessionKind::Antigravity(_) => Err(AgentError::forbidden( + "External agent runtimes are restricted to administrators in hosted multi-user mode", + )), } } @@ -95,4 +123,11 @@ mod tests { panic!("compile-time check only"); }; } + + #[test] + fn hosted_runtime_trusts_only_live_site_admins() { + assert!(can_run_host_tools(true, SiteRole::Admin)); + assert!(!can_run_host_tools(true, SiteRole::Member)); + assert!(can_run_host_tools(false, SiteRole::Member)); + } } diff --git a/crates/aionui-ai-agent/src/manager/aionrs/agent.rs b/crates/aionui-ai-agent/src/manager/aionrs/agent.rs index 8e5c9677a..2ff5a2b4c 100644 --- a/crates/aionui-ai-agent/src/manager/aionrs/agent.rs +++ b/crates/aionui-ai-agent/src/manager/aionrs/agent.rs @@ -217,7 +217,9 @@ impl AionrsAgentManager { let is_resume = resume_session.is_some(); let provider_label = config.provider_label.clone(); - let mut bootstrap = AgentBootstrap::new(config, &workspace, sink).runtime_env(runtime_env); + let mut bootstrap = AgentBootstrap::new(config, &workspace, sink) + .runtime_env(runtime_env) + .tool_policy(config_extra.tool_policy.clone()); if let Some(session) = resume_session { info!( conversation_id = %conversation_id, diff --git a/crates/aionui-ai-agent/src/manager/aionrs/agent_test.rs b/crates/aionui-ai-agent/src/manager/aionrs/agent_test.rs index 9310f1e0b..917447058 100644 --- a/crates/aionui-ai-agent/src/manager/aionrs/agent_test.rs +++ b/crates/aionui-ai-agent/src/manager/aionrs/agent_test.rs @@ -41,6 +41,7 @@ fn make_test_config() -> AionrsResolvedConfig { bedrock_config: None, runtime_env: Vec::new(), prompt_dump_dir: None, + tool_policy: aion_agent::tool_policy::ToolPolicy::Unrestricted, } } diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index b50c5ddb4..2513c127b 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -10,6 +10,7 @@ use axum::Router; use axum::extract::rejection::JsonRejection; use axum::extract::{Extension, Json, Path, State}; +use axum::middleware::from_fn; use axum::routing::{get, patch, post, put}; use aionui_api_types::{ @@ -17,18 +18,21 @@ use aionui_api_types::{ DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetAgentOverridesRequest, SetEnabledRequest, TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, }; -use aionui_auth::CurrentUser; +use aionui_auth::{CurrentUser, admin_required_middleware}; use aionui_common::ApiError; +use aionui_db::SiteRole; use crate::routes::error_mapping::agent_error_to_api_error; use crate::routes::state::AgentRouterState; pub fn agent_routes(state: AgentRouterState) -> Router { - Router::new() + let public_catalog = Router::new() .route("/api/agents/logos", get(list_agent_logos)) + .route("/api/agents/provider-health-check", post(provider_health_check)); + + let host_management = Router::new() .route("/api/agents/management", get(list_management_agents)) .route("/api/agents/{id}/health-check", post(health_check_by_id)) - .route("/api/agents/provider-health-check", post(provider_health_check)) .route("/api/agents/{id}/enabled", patch(set_agent_enabled)) .route( "/api/agents/{id}/overrides", @@ -36,8 +40,15 @@ pub fn agent_routes(state: AgentRouterState) -> Router { ) .route("/api/agents/custom", post(create_custom)) .route("/api/agents/custom/{id}", put(update_custom).delete(delete_custom)) - .route("/api/agents/custom/try-connect", post(try_connect_custom)) - .with_state(state) + .route("/api/agents/custom/try-connect", post(try_connect_custom)); + + let host_management = if state.require_host_admin { + host_management.route_layer(from_fn(admin_required_middleware)) + } else { + host_management + }; + + public_catalog.merge(host_management).with_state(state) } async fn list_agent_logos( @@ -86,10 +97,11 @@ async fn provider_health_check( body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let host_access_allowed = !state.require_host_admin || user.site_role == SiteRole::Admin; Ok(Json(ApiResponse::ok( state .service - .provider_health_check(&user.id, req) + .provider_health_check(&user.id, req, host_access_allowed) .await .map_err(agent_error_to_api_error)?, ))) diff --git a/crates/aionui-ai-agent/src/routes/remote.rs b/crates/aionui-ai-agent/src/routes/remote.rs index 4bdaf320c..ba1187b7b 100644 --- a/crates/aionui-ai-agent/src/routes/remote.rs +++ b/crates/aionui-ai-agent/src/routes/remote.rs @@ -16,13 +16,14 @@ use axum::Router; use axum::extract::rejection::JsonRejection; use axum::extract::{Extension, Json, Path, State}; use axum::http::StatusCode; +use axum::middleware::from_fn; use axum::routing::{get, post}; use aionui_api_types::{ ApiResponse, CreateRemoteAgentRequest, HandshakeResponse, RemoteAgentListItem, RemoteAgentResponse, TestRemoteAgentConnectionRequest, UpdateRemoteAgentRequest, }; -use aionui_auth::CurrentUser; +use aionui_auth::{CurrentUser, admin_required_middleware}; use aionui_common::ApiError; use super::error_mapping::agent_error_to_api_error; @@ -32,12 +33,23 @@ use super::state::RemoteAgentRouterState; /// /// All routes require authentication (applied by the caller). pub fn remote_agent_routes(state: RemoteAgentRouterState) -> Router { - Router::new() - .route("/api/remote-agents", get(list).post(create)) + let personal_catalog = Router::new() + .route("/api/remote-agents", get(list)) + .route("/api/remote-agents/{id}", get(get_one)); + + let connection_management = Router::new() + .route("/api/remote-agents", post(create)) .route("/api/remote-agents/test-connection", post(test_connection)) - .route("/api/remote-agents/{id}", get(get_one).put(update).delete(delete_one)) - .route("/api/remote-agents/{id}/handshake", post(handshake)) - .with_state(state) + .route("/api/remote-agents/{id}", axum::routing::put(update).delete(delete_one)) + .route("/api/remote-agents/{id}/handshake", post(handshake)); + + let connection_management = if state.require_host_admin { + connection_management.route_layer(from_fn(admin_required_middleware)) + } else { + connection_management + }; + + personal_catalog.merge(connection_management).with_state(state) } async fn list( diff --git a/crates/aionui-ai-agent/src/routes/state.rs b/crates/aionui-ai-agent/src/routes/state.rs index be1479628..db39f6f4f 100644 --- a/crates/aionui-ai-agent/src/routes/state.rs +++ b/crates/aionui-ai-agent/src/routes/state.rs @@ -6,10 +6,16 @@ use crate::{AgentRegistry, AgentService, RemoteAgentService}; #[derive(Clone)] pub struct RemoteAgentRouterState { pub service: Arc, + /// Require a live site administrator for operations that create, mutate, + /// or connect remote agents. Enabled for every hosted identity mode. + pub require_host_admin: bool, } #[derive(Clone)] pub struct AgentRouterState { pub agent_registry: Arc, pub service: Arc, + /// Require a live site administrator for host-process discovery and + /// custom-agent management. Enabled for every hosted identity mode. + pub require_host_admin: bool, } diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index 322e0a541..432d7886f 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -110,8 +110,11 @@ impl AgentService { &self, user_id: &str, req: ProviderHealthCheckRequest, + host_access_allowed: bool, ) -> Result { - self.provider_health.health_check(user_id, req).await + self.provider_health + .health_check(user_id, req, host_access_allowed) + .await } pub async fn set_agent_overrides( diff --git a/crates/aionui-ai-agent/src/services/provider_health.rs b/crates/aionui-ai-agent/src/services/provider_health.rs index 3453f1202..580a2d418 100644 --- a/crates/aionui-ai-agent/src/services/provider_health.rs +++ b/crates/aionui-ai-agent/src/services/provider_health.rs @@ -46,6 +46,7 @@ impl ProviderHealthCheckService { &self, user_id: &str, req: ProviderHealthCheckRequest, + host_access_allowed: bool, ) -> Result { if req.provider_id.trim().is_empty() { return Err(AgentError::bad_request("provider_id is required")); @@ -64,6 +65,8 @@ impl ProviderHealthCheckService { .ok_or_else(|| AgentError::bad_request(format!("Provider '{provider_id}' not found")))?; let config = self.resolve_probe_config(&row, model)?; + let effective_base_url = config.base_url.as_deref().unwrap_or(&row.base_url); + enforce_provider_runtime_access(host_access_allowed, &row.platform, effective_base_url).await?; run_probe(row.id, row.platform, config).await } @@ -105,10 +108,24 @@ impl ProviderHealthCheckService { bedrock_config, runtime_env: Vec::new(), prompt_dump_dir: None, + tool_policy: aion_agent::tool_policy::ToolPolicy::allow_only(std::iter::empty::()), }) } } +async fn enforce_provider_runtime_access( + host_access_allowed: bool, + platform: &str, + base_url: &str, +) -> Result<(), AgentError> { + if host_access_allowed { + return Ok(()); + } + aionui_system::validate_member_provider_runtime(platform, base_url) + .await + .map_err(|error| AgentError::bad_request(error.to_string())) +} + async fn run_probe( provider_id: String, platform: String, @@ -341,16 +358,18 @@ mod tests { const TEST_KEY: [u8; 32] = [0xAB; 32]; const TEST_USER_ID: &str = "user-1"; - struct UnusedProviderRepository; + struct TestProviderRepository { + provider: Option, + } #[async_trait::async_trait] - impl IProviderRepository for UnusedProviderRepository { + impl IProviderRepository for TestProviderRepository { async fn list(&self, _user_id: &str) -> Result, DbError> { unreachable!("provider repo is not used by resolve_probe_config") } async fn find_by_id(&self, _user_id: &str, _id: &str) -> Result, DbError> { - unreachable!("provider repo is not used by resolve_probe_config") + Ok(self.provider.clone()) } async fn create(&self, _params: CreateProviderParams<'_>) -> Result { @@ -373,7 +392,7 @@ mod tests { fn test_service() -> ProviderHealthCheckService { ProviderHealthCheckService { - provider_repo: Arc::new(UnusedProviderRepository), + provider_repo: Arc::new(TestProviderRepository { provider: None }), encryption_key: TEST_KEY, data_dir: PathBuf::from("/tmp/aioncore-provider-health-test"), } @@ -470,4 +489,60 @@ mod tests { ProviderHealthCheckErrorKind::Timeout ); } + + #[tokio::test] + async fn hosted_member_health_probe_rejects_private_and_unapproved_endpoints() { + for (base_url, expected) in [ + ("http://127.0.0.1:11434/v1", "private or local"), + ("https://attacker.example/v1", "approved WebUI member endpoint"), + ] { + let error = enforce_provider_runtime_access(false, "custom", base_url) + .await + .unwrap_err(); + match error { + AgentError::BadRequest(message) => assert!( + message.contains(expected), + "{base_url} returned an unexpected rejection: {message}" + ), + other => panic!("{base_url} returned unexpected error: {other}"), + } + } + } + + #[tokio::test] + async fn hosted_member_health_check_blocks_private_provider_before_bootstrap() { + let mut provider = test_provider(); + provider.platform = "custom".to_owned(); + provider.base_url = "http://127.0.0.1:11434/v1".to_owned(); + let service = ProviderHealthCheckService { + provider_repo: Arc::new(TestProviderRepository { + provider: Some(provider), + }), + encryption_key: TEST_KEY, + data_dir: PathBuf::from("/tmp/aioncore-provider-health-test"), + }; + + let error = service + .health_check( + TEST_USER_ID, + ProviderHealthCheckRequest { + provider_id: "provider-1".to_owned(), + model: "gpt-4o".to_owned(), + }, + false, + ) + .await + .unwrap_err(); + match error { + AgentError::BadRequest(message) => assert!(message.contains("private or local")), + other => panic!("unexpected error: {other}"), + } + } + + #[tokio::test] + async fn trusted_local_health_probe_retains_private_provider_support() { + enforce_provider_runtime_access(true, "custom", "http://127.0.0.1:11434/v1") + .await + .unwrap(); + } } diff --git a/crates/aionui-ai-agent/src/types.rs b/crates/aionui-ai-agent/src/types.rs index ce55c7de1..f92db8a54 100644 --- a/crates/aionui-ai-agent/src/types.rs +++ b/crates/aionui-ai-agent/src/types.rs @@ -165,6 +165,10 @@ pub struct AionrsResolvedConfig { pub runtime_env: Vec<(String, String)>, /// Prompt dump directory when development prompt dumps are enabled. pub prompt_dump_dir: Option, + /// Server-enforced runtime tool policy. Hosted members receive an empty + /// allow-list because aionrs filesystem and shell tools execute in the + /// backend process and are not an operating-system sandbox. + pub tool_policy: aion_agent::tool_policy::ToolPolicy, } #[cfg(test)] diff --git a/crates/aionui-ai-agent/tests/agent_types_integration.rs b/crates/aionui-ai-agent/tests/agent_types_integration.rs index c6e27ecaa..2fbd4e89d 100644 --- a/crates/aionui-ai-agent/tests/agent_types_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_types_integration.rs @@ -107,6 +107,7 @@ fn make_aionrs_config() -> AionrsResolvedConfig { bedrock_config: None, runtime_env: Vec::new(), prompt_dump_dir: None, + tool_policy: aion_agent::tool_policy::ToolPolicy::Unrestricted, } } diff --git a/crates/aionui-ai-agent/tests/factory_provider_integration.rs b/crates/aionui-ai-agent/tests/factory_provider_integration.rs index 106b2a77c..9929ba203 100644 --- a/crates/aionui-ai-agent/tests/factory_provider_integration.rs +++ b/crates/aionui-ai-agent/tests/factory_provider_integration.rs @@ -12,8 +12,8 @@ use aionui_ai_agent::types::BuildTaskOptions; use aionui_api_types::AionrsBuildExtra; use aionui_common::{AgentType, ProviderWithModel, encrypt_string}; use aionui_db::{ - CreateProviderParams, IAcpSessionRepository, IProviderRepository, SqliteAcpSessionRepository, - SqliteAgentMetadataRepository, SqliteProviderRepository, init_database_memory, + CreateProviderParams, IAcpSessionRepository, IProviderRepository, IUserRepository, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteProviderRepository, SqliteUserRepository, init_database_memory, }; use aionui_realtime::BroadcastEventBus; @@ -25,18 +25,20 @@ fn test_encryption_key() -> [u8; 32] { async fn setup() -> ( Arc, + Arc, Arc, Arc, ) { let db = init_database_memory().await.unwrap(); let pool = db.pool().clone(); let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(pool.clone())); + let user_repo: Arc = Arc::new(SqliteUserRepository::new(pool.clone())); let metadata_repo = Arc::new(SqliteAgentMetadataRepository::new(pool.clone())); let registry = AgentRegistry::new(metadata_repo); registry.hydrate().await.unwrap(); let session_repo: Arc = Arc::new(SqliteAcpSessionRepository::new(pool)); let acp_agent_service = AcpSessionSyncService::new(session_repo); - (provider_repo, registry, acp_agent_service) + (provider_repo, user_repo, registry, acp_agent_service) } async fn insert_test_provider(repo: &dyn IProviderRepository, id: &str, platform: &str) { @@ -66,6 +68,7 @@ async fn insert_test_provider(repo: &dyn IProviderRepository, id: &str, platform fn make_factory( provider_repo: Arc, + user_repo: Arc, agent_registry: Arc, acp_agent_service: Arc, ) -> aionui_ai_agent::task_manager::AgentFactory { @@ -83,6 +86,7 @@ fn make_factory( build_agent_factory(AgentFactoryDeps { skill_manager: AcpSkillManager::new(skill_paths), provider_repo, + user_repo, encryption_key: test_encryption_key(), agent_registry, acp_agent_service, @@ -91,6 +95,7 @@ fn make_factory( broadcaster: Arc::new(BroadcastEventBus::new(16)), backend_binary_path: Arc::new(PathBuf::from("/tmp/aionrs-test/aioncore")), mcp_server_repo: None, + restrict_member_host_tools: false, session_spawner, // No hook bridge in this test: it exercises provider wiring, not the // Antigravity permission path. @@ -131,8 +136,8 @@ fn make_aionrs_options( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aionrs_factory_returns_error_for_missing_provider() { - let (provider_repo, agent_registry, acp_agent_service) = setup().await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let (provider_repo, user_repo, agent_registry, acp_agent_service) = setup().await; + let factory = make_factory(provider_repo, user_repo, agent_registry, acp_agent_service); let options = make_aionrs_options( "conv-test-1", @@ -160,9 +165,9 @@ async fn aionrs_factory_returns_error_for_missing_provider() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aionrs_factory_resolves_provider_from_db() { - let (provider_repo, agent_registry, acp_agent_service) = setup().await; + let (provider_repo, user_repo, agent_registry, acp_agent_service) = setup().await; insert_test_provider(&*provider_repo, "prov-001", "openai").await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let factory = make_factory(provider_repo, user_repo, agent_registry, acp_agent_service); let options = make_aionrs_options( "conv-test-2", @@ -181,9 +186,9 @@ async fn aionrs_factory_resolves_provider_from_db() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aionrs_factory_respects_use_model_override() { - let (provider_repo, agent_registry, acp_agent_service) = setup().await; + let (provider_repo, user_repo, agent_registry, acp_agent_service) = setup().await; insert_test_provider(&*provider_repo, "prov-002", "openai").await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let factory = make_factory(provider_repo, user_repo, agent_registry, acp_agent_service); let options = make_aionrs_options( "conv-test-3", diff --git a/crates/aionui-api-types/src/auth.rs b/crates/aionui-api-types/src/auth.rs index 4f6b31b06..35b70c663 100644 --- a/crates/aionui-api-types/src/auth.rs +++ b/crates/aionui-api-types/src/auth.rs @@ -7,6 +7,108 @@ use serde::{Deserialize, Serialize}; pub struct PublicUser { pub id: String, pub username: String, + pub role: UserRole, + pub status: AccountStatus, + pub must_change_password: bool, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + #[default] + Member, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AccountStatus { + #[default] + Active, + Disabled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AdminUserType { + Local, + Aionpro, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdminUser { + pub id: String, + pub username: String, + pub user_type: AdminUserType, + pub role: UserRole, + pub status: AccountStatus, + pub must_change_password: bool, + pub created_at: i64, + pub updated_at: i64, + pub last_login: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListAdminUsersQuery { + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdminUserListResponse { + pub items: Vec, + pub total: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateAdminUserRequest { + pub username: String, + pub role: UserRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TemporaryPasswordResponse { + pub user: AdminUser, + pub temporary_password: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateAdminUsernameRequest { + pub username: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateAdminRoleRequest { + pub role: UserRole, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateAdminStatusRequest { + pub status: AccountStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListAdminAuditQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AdminAuditEntry { + pub id: String, + pub occurred_at: i64, + pub actor_user_id: Option, + pub actor_username: Option, + pub action: String, + pub target_user_id: Option, + pub target_username: Option, + pub details: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AdminAuditListResponse { + pub items: Vec, + pub next_cursor: Option, } /// Login request body for `POST /login`. @@ -209,6 +311,9 @@ mod tests { let user = PublicUser { id: "auth_1712345678_abc".into(), username: "admin".into(), + role: UserRole::Admin, + status: AccountStatus::Active, + must_change_password: false, }; let json = serde_json::to_value(&user).unwrap(); assert_eq!(json["id"], "auth_1712345678_abc"); @@ -235,6 +340,9 @@ mod tests { let user = PublicUser { id: "user_1".into(), username: "admin".into(), + role: UserRole::Admin, + status: AccountStatus::Active, + must_change_password: false, }; let resp = LoginResponse::new(user.clone(), "jwt_token".into()); assert!(resp.success); @@ -249,6 +357,9 @@ mod tests { PublicUser { id: "auth_123".into(), username: "admin".into(), + role: UserRole::Admin, + status: AccountStatus::Active, + must_change_password: false, }, "eyJhbGciOi".into(), ); diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 2f3f33b08..0a02812fa 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -27,6 +27,7 @@ mod provider; mod remote_agent; mod response; mod runtime; +mod share; mod shell; mod skill; mod system; @@ -70,11 +71,14 @@ pub use assistant::{ is_local_avatar_value, }; pub use auth::{ - AuthStatusResponse, ChangePasswordRequest, EnsureExternalSessionRequest, EnsureExternalSessionResponse, - EnsureExternalUserRequest, EnsureExternalUserResponse, ExternalUserType, InternalAuthErrorCode, LoginRequest, - LoginResponse, PublicUser, QrLoginRequest, RefreshResponse, RefreshTokenRequest, RevokeExternalSessionRequest, - RevokeExternalSessionResponse, UserInfoResponse, WebuiChangePasswordRequest, WebuiChangeUsernameRequest, - WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, + AccountStatus, AdminAuditEntry, AdminAuditListResponse, AdminUser, AdminUserListResponse, AdminUserType, + AuthStatusResponse, ChangePasswordRequest, CreateAdminUserRequest, EnsureExternalSessionRequest, + EnsureExternalSessionResponse, EnsureExternalUserRequest, EnsureExternalUserResponse, ExternalUserType, + InternalAuthErrorCode, ListAdminAuditQuery, ListAdminUsersQuery, LoginRequest, LoginResponse, PublicUser, + QrLoginRequest, RefreshResponse, RefreshTokenRequest, RevokeExternalSessionRequest, RevokeExternalSessionResponse, + TemporaryPasswordResponse, UpdateAdminRoleRequest, UpdateAdminStatusRequest, UpdateAdminUsernameRequest, + UserInfoResponse, UserRole, WebuiChangePasswordRequest, WebuiChangeUsernameRequest, WebuiChangeUsernameResponse, + WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; pub use channel::{ ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, @@ -151,6 +155,10 @@ pub use runtime::{ EnsureNodeRuntimeRequest, EnsureNodeRuntimeResponse, RuntimeFailureKind, RuntimeResourceKind, RuntimeStatusPayload, RuntimeStatusPhase, RuntimeStatusScope, RuntimeStatusScopeKind, }; +pub use share::{ + CreateShareRequest, DirectoryUser, ListSharesQuery, ResourceShare, ShareListResponse, SharePermission, + ShareResourceType, UserDirectoryResponse, +}; pub use shell::{ CheckToolInstalledRequest, CheckToolInstalledResponse, DeepgramSpeechToTextConfig, OpenAISpeechToTextConfig, OpenExternalRequest, OpenFileRequest, OpenFolderWithRequest, ShowItemInFolderRequest, SpeechToTextConfig, diff --git a/crates/aionui-api-types/src/share.rs b/crates/aionui-api-types/src/share.rs new file mode 100644 index 000000000..6605486cd --- /dev/null +++ b/crates/aionui-api-types/src/share.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; + +/// Permission granted by an explicit resource share. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharePermission { + View, + Edit, +} + +/// Resource types that support multi-user sharing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ShareResourceType { + Conversation, + Project, + Provider, +} + +/// A single resource share row exposed to clients. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ResourceShare { + pub id: String, + pub resource_type: ShareResourceType, + pub resource_id: String, + pub owner_user_id: String, + pub grantee_user_id: String, + pub grantee_username: Option, + pub permission: SharePermission, + pub created_at: i64, + pub created_by: String, +} + +/// Request body for `POST /api/shares`. +#[derive(Debug, Clone, Deserialize)] +pub struct CreateShareRequest { + pub resource_type: ShareResourceType, + pub resource_id: String, + pub grantee_username: String, + pub permission: SharePermission, +} + +/// Query for listing shares on a resource. +#[derive(Debug, Clone, Deserialize)] +pub struct ListSharesQuery { + pub resource_type: ShareResourceType, + pub resource_id: String, +} + +/// Response for share list endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShareListResponse { + pub items: Vec, +} + +/// Minimal directory entry for the share picker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DirectoryUser { + pub id: String, + pub username: String, +} + +/// Response for `GET /api/users/directory`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UserDirectoryResponse { + pub items: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_share_request_snake_case() { + let raw = json!({ + "resource_type": "conversation", + "resource_id": "conv_1", + "grantee_username": "alice", + "permission": "edit" + }); + let req: CreateShareRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.resource_type, ShareResourceType::Conversation); + assert_eq!(req.permission, SharePermission::Edit); + assert_eq!(req.grantee_username, "alice"); + } + + #[test] + fn resource_share_serialization() { + let share = ResourceShare { + id: "share_1".into(), + resource_type: ShareResourceType::Provider, + resource_id: "prov_1".into(), + owner_user_id: "u1".into(), + grantee_user_id: "u2".into(), + grantee_username: Some("bob".into()), + permission: SharePermission::View, + created_at: 100, + created_by: "u1".into(), + }; + let json = serde_json::to_value(&share).unwrap(); + assert_eq!(json["resource_type"], "provider"); + assert_eq!(json["permission"], "view"); + assert_eq!(json["grantee_username"], "bob"); + } +} diff --git a/crates/aionui-api-types/tests/auth_types.rs b/crates/aionui-api-types/tests/auth_types.rs index 828d789ff..7ed112502 100644 --- a/crates/aionui-api-types/tests/auth_types.rs +++ b/crates/aionui-api-types/tests/auth_types.rs @@ -1,8 +1,8 @@ //! Black-box tests for auth DTO serialization/deserialization. use aionui_api_types::{ - AuthStatusResponse, ChangePasswordRequest, LoginRequest, LoginResponse, PublicUser, QrLoginRequest, - RefreshTokenRequest, + AccountStatus, AuthStatusResponse, ChangePasswordRequest, LoginRequest, LoginResponse, PublicUser, QrLoginRequest, + RefreshTokenRequest, UserRole, }; // --- LoginRequest --- @@ -48,6 +48,9 @@ fn login_response_serialization_matches_spec() { PublicUser { id: "auth_1712345678_abc".into(), username: "admin".into(), + role: UserRole::Admin, + status: AccountStatus::Active, + must_change_password: false, }, "eyJhbGciOiJIUzI1NiJ9".into(), ); diff --git a/crates/aionui-app/Cargo.toml b/crates/aionui-app/Cargo.toml index a8e735c2d..95c2cbe2d 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -51,6 +51,7 @@ tower-http.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true +url.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/aionui-app/src/bootstrap/environment.rs b/crates/aionui-app/src/bootstrap/environment.rs index 720705a79..0a28242d3 100644 --- a/crates/aionui-app/src/bootstrap/environment.rs +++ b/crates/aionui-app/src/bootstrap/environment.rs @@ -1,10 +1,11 @@ //! Bootstrap layers shared by non-MCP subcommands. +use std::path::PathBuf; use std::time::Instant; use tracing::info; -use aionui_app::{AppConfig, IdentityMode}; +use aionui_app::{AppConfig, IdentityMode, parse_allowed_origins, validate_local_client_secret}; use aionui_db::Database; use crate::cli::Cli; @@ -49,7 +50,25 @@ pub fn init_environment(cli: &Cli, merged_path: &str) -> Result Result Result Result, +) -> Result, BootstrapError> { + if identity_mode == IdentityMode::Local { + let secret = secret.ok_or_else(|| { + BootstrapError::new( + BootstrapErrorCode::ConfigInvalid, + "config.local_client_secret", + "Local identity mode requires AIONCORE_LOCAL_CLIENT_SECRET", + ) + })?; + validate_local_client_secret(&secret).map_err(|error| { + BootstrapError::new( + BootstrapErrorCode::ConfigInvalid, + "config.local_client_secret", + "AIONCORE_LOCAL_CLIENT_SECRET is invalid", + ) + .with_field("reason", error) + })?; + Ok(Some(secret)) + } else { + Ok(None) + } +} + fn validate_identity_environment( identity_mode: IdentityMode, bootstrap_secret: Option<&str>, + bootstrap_workspace: Option<&std::path::Path>, ) -> Result<(), BootstrapError> { if identity_mode == IdentityMode::AionPro && bootstrap_secret.is_none() { return Err(BootstrapError::new( @@ -92,6 +145,14 @@ fn validate_identity_environment( )); } + if bootstrap_workspace.is_some_and(|path| !path.is_absolute()) { + return Err(BootstrapError::new( + BootstrapErrorCode::ConfigInvalid, + "config.bootstrap_workspace", + "AIONUI_BOOTSTRAP_WORKSPACE must be an absolute path", + )); + } + Ok(()) } @@ -188,7 +249,7 @@ mod tests { #[test] fn aionpro_identity_requires_bootstrap_secret() { - let err = validate_identity_environment(IdentityMode::AionPro, None) + let err = validate_identity_environment(IdentityMode::AionPro, None, None) .expect_err("AionPro startup must require bootstrap secret"); assert_eq!(err.code(), BootstrapErrorCode::ConfigInvalid); @@ -197,15 +258,61 @@ mod tests { #[test] fn aionpro_identity_accepts_bootstrap_secret() { - validate_identity_environment(IdentityMode::AionPro, Some("secret")) + validate_identity_environment(IdentityMode::AionPro, Some("secret"), None) .expect("AionPro startup should accept configured bootstrap secret"); } #[test] fn non_aionpro_identity_does_not_require_bootstrap_secret() { - validate_identity_environment(IdentityMode::WebUi, None) + validate_identity_environment(IdentityMode::WebUi, None, None) .expect("WebUI startup should not require bootstrap secret"); - validate_identity_environment(IdentityMode::Local, None) + validate_identity_environment(IdentityMode::Local, None, None) .expect("local startup should not require bootstrap secret"); } + + #[test] + fn bootstrap_workspace_must_be_absolute() { + let err = validate_identity_environment( + IdentityMode::WebUi, + None, + Some(std::path::Path::new("relative/workspace")), + ) + .expect_err("relative bootstrap workspace must fail closed"); + + assert_eq!(err.code(), BootstrapErrorCode::ConfigInvalid); + assert_eq!(err.stage(), "config.bootstrap_workspace"); + } + + #[test] + fn local_identity_requires_a_valid_per_launch_client_secret() { + let missing = resolve_local_client_secret(IdentityMode::Local, None).unwrap_err(); + assert_eq!(missing.code(), BootstrapErrorCode::ConfigInvalid); + assert_eq!(missing.stage(), "config.local_client_secret"); + + let invalid = resolve_local_client_secret(IdentityMode::Local, Some("too-short".to_string())).unwrap_err(); + assert_eq!(invalid.code(), BootstrapErrorCode::ConfigInvalid); + assert_eq!(invalid.stage(), "config.local_client_secret"); + + assert_eq!( + resolve_local_client_secret( + IdentityMode::Local, + Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), + ) + .unwrap() + .as_deref(), + Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678") + ); + } + + #[test] + fn non_local_identity_does_not_retain_the_local_client_secret() { + assert!( + resolve_local_client_secret( + IdentityMode::WebUi, + Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), + ) + .unwrap() + .is_none() + ); + } } diff --git a/crates/aionui-app/src/config.rs b/crates/aionui-app/src/config.rs index 1798778f4..e777e5d0b 100644 --- a/crates/aionui-app/src/config.rs +++ b/crates/aionui-app/src/config.rs @@ -37,6 +37,20 @@ pub struct AppConfig { pub local: bool, pub identity_mode: IdentityMode, pub bootstrap_secret: Option, + /// Optional operator-configured workspace exposed only to the seeded + /// `system_default_user` in authenticated user-session modes. + pub bootstrap_workspace: Option, + /// Enable one-time initial administrator provisioning. The process-level + /// WebUI bootstrap enables this explicitly; library/test defaults do not + /// perform credential filesystem side effects. + pub bootstrap_initial_admin: bool, + /// Per-launch capability shared only with the packaged local client. + /// Required in Local mode; never accepted in a URL or cookie. + pub local_client_secret: Option, + /// Exact browser origins allowed to reach Core cross-origin. WebUI mode + /// remains same-origin; Local additionally permits the packaged `null` + /// origin at router assembly time. + pub allowed_origins: Vec, /// Dump prompt diagnostics under `data_dir/prompt-dumps`. pub dump_prompts: bool, /// Explicitly authorize backup and rebuild for corruption-like local databases. @@ -83,12 +97,75 @@ impl Default for AppConfig { local: false, identity_mode: IdentityMode::WebUi, bootstrap_secret: None, + bootstrap_workspace: None, + bootstrap_initial_admin: false, + local_client_secret: None, + allowed_origins: Vec::new(), dump_prompts: false, recover_corrupted_database: false, } } } +/// Parse and normalize `AIONCORE_ALLOWED_ORIGINS`. +/// +/// Entries are comma-separated exact HTTP(S) origins, or the literal `null` +/// for packaged/native webviews. Paths, credentials, query strings, +/// fragments, wildcards, and opaque schemes are rejected. +pub fn parse_allowed_origins(raw: Option<&str>) -> Result, String> { + let mut origins = Vec::new(); + for entry in raw.unwrap_or_default().split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let normalized = if entry == "null" { + "null".to_string() + } else { + if entry == "*" { + return Err("wildcard origins are not allowed".to_string()); + } + let parsed = url::Url::parse(entry).map_err(|error| format!("invalid origin '{entry}': {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(format!("origin '{entry}' must use http or https")); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(format!("origin '{entry}' must not contain credentials")); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err(format!("origin '{entry}' must not contain a path, query, or fragment")); + } + let origin = parsed.origin().ascii_serialization(); + if origin == "null" { + return Err(format!("origin '{entry}' is not a tuple origin")); + } + origin + }; + if !origins.contains(&normalized) { + origins.push(normalized); + } + } + Ok(origins) +} + +/// Validate the per-launch Local client capability. +/// +/// The launcher supplies 32 random bytes as unpadded Base64URL (43 ASCII +/// characters), which is safe in both an HTTP header value and a WebSocket +/// subprotocol token. +pub fn validate_local_client_secret(secret: &str) -> Result<(), String> { + if secret.len() != 43 { + return Err("local client secret must encode exactly 32 random bytes as unpadded Base64URL".to_string()); + } + if !secret + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err("local client secret must contain only unpadded Base64URL characters".to_string()); + } + Ok(()) +} + /// Derive a 32-byte encryption key from the JWT secret using SHA-256. pub fn derive_encryption_key(jwt_secret: &str) -> [u8; 32] { let mut hasher = Sha256::new(); @@ -110,6 +187,7 @@ mod tests { assert_eq!(config.app_version, env!("CARGO_PKG_VERSION")); assert_eq!(config.identity_mode, IdentityMode::WebUi); assert!(config.bootstrap_secret.is_none()); + assert!(config.bootstrap_workspace.is_none()); assert!(!config.dump_prompts); assert!(!config.recover_corrupted_database); } @@ -142,4 +220,32 @@ mod tests { }; assert_eq!(config.database_path(), PathBuf::from("/tmp/aionui/aionui-backend.db")); } + + #[test] + fn allowed_origins_are_normalized_and_deduplicated() { + assert_eq!( + parse_allowed_origins(Some(" https://EXAMPLE.com/,null,https://example.com ")).unwrap(), + vec!["https://example.com", "null"] + ); + } + + #[test] + fn allowed_origins_reject_wildcards_and_non_origin_urls() { + for value in [ + "*", + "file:///tmp/index.html", + "https://user@example.com", + "https://example.com/path", + "https://example.com?query=1", + ] { + assert!(parse_allowed_origins(Some(value)).is_err(), "{value} must be rejected"); + } + } + + #[test] + fn local_client_secret_requires_32_bytes_of_unpadded_base64url() { + assert!(validate_local_client_secret("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678").is_ok()); + assert!(validate_local_client_secret("too-short").is_err()); + assert!(validate_local_client_secret("abcdefghijklmnopqrstuvwxyzABCDEFGH01234567=").is_err()); + } } diff --git a/crates/aionui-app/src/lib.rs b/crates/aionui-app/src/lib.rs index 533b0a1b0..fa6f2eefb 100644 --- a/crates/aionui-app/src/lib.rs +++ b/crates/aionui-app/src/lib.rs @@ -9,7 +9,7 @@ mod config; mod router; mod services; -pub use config::{AppConfig, IdentityMode, derive_encryption_key}; +pub use config::{AppConfig, IdentityMode, derive_encryption_key, parse_allowed_origins, validate_local_client_secret}; pub use router::{ ChannelOrchestratorComponents, ModuleStates, RouterBuildError, RouterRuntime, build_assistant_state, build_conversation_state, build_extension_states, build_module_states, build_ws_state, create_router, diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index 23604705e..ab1f1a0b6 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -5,13 +5,13 @@ use std::time::Instant; use axum::Json; use axum::extract::DefaultBodyLimit; -use axum::extract::Request; -use axum::http::{HeaderName, Method, StatusCode, header}; +use axum::extract::{Request, State}; +use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header}; use axum::middleware::{Next, from_fn_with_state}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Router, middleware}; -use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use tower_http::cors::{AllowCredentials, AllowOrigin, Any, CorsLayer}; use aionui_ai_agent::{ RuntimeTokenScope, RuntimeTokenService, TEAM_RUNTIME_TOKEN_SESSION_GENERATION, agent_routes, remote_agent_routes, @@ -113,23 +113,26 @@ pub async fn create_router_with_runtime(services: &AppServices) -> Result<(Route // Restore enabled channel plugins (starts receiving IM messages) let chan_mgr = channel_components.manager; let chan_factory = channel_components.plugin_factory; - let chan_owner_user_id = channel_components.owner_user_id; + let restore_owner_user_ids = channel_components.restore_owner_user_ids; tokio::spawn(async move { - if let Some(chan_owner_user_id) = chan_owner_user_id { - if let Err(e) = chan_mgr.restore_plugins(&chan_owner_user_id, &chan_factory).await { + if restore_owner_user_ids.is_empty() { + tracing::info!( + stage = "channel.restore", + "skipping channel plugin restore until an owner user is available" + ); + return; + } + + for owner_user_id in restore_owner_user_ids { + if let Err(e) = chan_mgr.restore_plugins(&owner_user_id, &chan_factory).await { tracing::warn!( code = "BOOTSTRAP_DEGRADED_CHANNEL_RESTORE", stage = "channel.restore", - owner_user_id = %chan_owner_user_id, + owner_user_id = %owner_user_id, error = %e, "failed to restore channel plugins" ); } - } else { - tracing::info!( - stage = "channel.restore", - "skipping channel plugin restore until an owner user is available" - ); } }); tracing::info!( @@ -189,6 +192,9 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates let auth_state = AuthRouterState { jwt_service: services.jwt_service.clone(), user_repo: services.user_repo.clone(), + admin_user_repo: services.admin_user_repo.clone(), + share_repo: services.share_repo.clone(), + initial_admin_credentials_file: services.initial_admin_credentials_file.clone(), fs_adopter: Some(Arc::new(SkillFilesystemAdopter { skill_paths: services.skill_paths.clone(), skill_repo: services.skill_repo.clone(), @@ -405,44 +411,200 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates "startup: route tree build with states completed" ); - if services.identity_mode.is_local() { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers(Any); - router.layer(cors) + let allowed_origins = effective_allowed_origins(services); + let router = match services.identity_mode { + crate::config::IdentityMode::Local => router + .layer(middleware::from_fn_with_state( + LocalClientSecretPolicy::new( + services + .local_client_secret + .clone() + .expect("validated Local services must carry a client secret"), + ), + local_client_secret_guard, + )) + .layer(middleware::from_fn_with_state( + NativeOriginPolicy { + allowed: allowed_origins.clone(), + }, + native_origin_guard, + )), + crate::config::IdentityMode::AionPro => router.layer(middleware::from_fn_with_state( + NativeOriginPolicy { + allowed: allowed_origins.clone(), + }, + native_origin_guard, + )), + crate::config::IdentityMode::WebUi => router, + }; + + match services.identity_mode { + crate::config::IdentityMode::Local => { + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::list(allowed_origins.iter().cloned())) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers(Any); + router.layer(cors) + } + crate::config::IdentityMode::WebUi => { + // The WebUI is served by the AionUI web host on the same origin. + // Do not opt credentialed browser requests into cross-origin access. + router + } + crate::config::IdentityMode::AionPro => { + if allowed_origins.is_empty() { + return router; + } + // AionPro uses an external renderer that needs cookies and CSRF + // headers. Only operator-configured exact origins are trusted. + let credential_origins = allowed_origins.clone(); + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::list(allowed_origins.iter().cloned())) + .allow_credentials(AllowCredentials::predicate(move |origin, _| { + credential_origins.iter().any(|allowed| allowed == origin) + })) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + HeaderName::from_static("x-csrf-token"), + ]); + router.layer(cors) + } + } +} + +#[derive(Clone)] +struct NativeOriginPolicy { + allowed: Arc<[HeaderValue]>, +} + +#[derive(Clone)] +struct LocalClientSecretPolicy { + secret: Arc, + websocket_protocol: Arc, +} + +impl LocalClientSecretPolicy { + fn new(secret: Arc) -> Self { + Self { + websocket_protocol: Arc::from(format!("aionui-local-v1.{secret}")), + secret, + } + } +} + +fn effective_allowed_origins(services: &AppServices) -> Arc<[HeaderValue]> { + let mut values = Vec::new(); + if services.identity_mode == crate::config::IdentityMode::Local { + values.push(HeaderValue::from_static("null")); + } + for origin in services.allowed_origins.iter() { + if let Ok(value) = HeaderValue::try_from(origin.as_str()) + && !values.contains(&value) + { + values.push(value); + } + } + values.into() +} + +async fn native_origin_guard(State(policy): State, request: Request, next: Next) -> Response { + if request.uri().path() == "/api/ws-token" || is_websocket_upgrade(&request) { + let mut origins = request.headers().get_all(header::ORIGIN).iter(); + if let Some(origin) = origins.next() + && (origins.next().is_some() || !policy.allowed.iter().any(|allowed| allowed == origin)) + { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponse::new( + "Request origin is not allowed.", + "ORIGIN_NOT_ALLOWED", + )), + ) + .into_response(); + } + } + next.run(request).await +} + +async fn local_client_secret_guard( + State(policy): State, + request: Request, + next: Next, +) -> Response { + let health_exempt = request.method() == Method::GET && request.uri().path() == "/health"; + let authenticated = if is_websocket_upgrade(&request) { + websocket_protocol_matches(request.headers(), &policy.websocket_protocol) } else { - // Non-local (external identity) mode: the desktop renderer is a - // cross-origin browser context (localhost:5173 in dev, file:// when - // packaged) authenticating with the session cookie, so responses must - // opt in to credentialed CORS. Credentialed mode forbids wildcards: - // reflect the request origin and enumerate headers explicitly - // (x-csrf-token is required by the CSRF double-submit middleware). - let cors = CorsLayer::new() - .allow_origin(AllowOrigin::mirror_request()) - .allow_credentials(true) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - header::CONTENT_TYPE, - header::AUTHORIZATION, - HeaderName::from_static("x-csrf-token"), - ]); - router.layer(cors) + header_secret_matches(request.headers(), &policy.secret) + }; + if !health_exempt && !authenticated { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new( + "Local client capability required.", + "LOCAL_CLIENT_SECRET_REQUIRED", + )), + ) + .into_response(); + } + next.run(request).await +} + +fn is_websocket_upgrade(request: &Request) -> bool { + request + .headers() + .get(header::UPGRADE) + .is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"websocket")) +} + +fn header_secret_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all("x-aionui-local-secret").iter(); + let Some(actual) = values.next() else { + return false; + }; + values.next().is_none() && constant_time_eq(actual.as_bytes(), expected.as_bytes()) +} + +fn websocket_protocol_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { + let mut matched: Option<&str> = None; + for value in headers.get_all(header::SEC_WEBSOCKET_PROTOCOL) { + let Ok(value) = value.to_str() else { + return false; + }; + for protocol in value.split(',').map(str::trim) { + if protocol.is_empty() || matched.replace(protocol).is_some() { + return false; + } + } + } + matched.is_some_and(|actual| constant_time_eq(actual.as_bytes(), expected.as_bytes())) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut diff = left.len() ^ right.len(); + for idx in 0..max_len { + let left = left.get(idx).copied().unwrap_or(0); + let right = right.get(idx).copied().unwrap_or(0); + diff |= usize::from(left ^ right); } + diff == 0 } /// Adapter running the on-disk side of AionUi → AionPro adoption over the @@ -555,12 +717,34 @@ fn boundary_error_for_status(status: StatusCode) -> Option<(&'static str, &'stat #[cfg(test)] mod tests { - use axum::http::StatusCode; + use axum::{ + body::{Body, to_bytes}, + http::{Method, Request, StatusCode, header}, + }; + use tempfile::TempDir; + use tower::ServiceExt; use super::{boundary_error_for_status, create_router_with_runtime, is_global_websocket_event}; - use crate::config::AppConfig; + use crate::config::{AppConfig, IdentityMode}; use crate::services::AppServices; + const TEST_LOCAL_CLIENT_SECRET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGH012345678"; + const TEST_LOCAL_WS_PROTOCOL: &str = "aionui-local-v1.abcdefghijklmnopqrstuvwxyzABCDEFGH012345678"; + + fn local_websocket_request(path: &str, origin: Option<&str>, protocol: &str) -> Request { + let mut builder = Request::builder() + .uri(path) + .header(header::CONNECTION, "upgrade") + .header(header::UPGRADE, "websocket") + .header(header::SEC_WEBSOCKET_VERSION, "13") + .header(header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .header(header::SEC_WEBSOCKET_PROTOCOL, protocol); + if let Some(origin) = origin { + builder = builder.header(header::ORIGIN, origin); + } + builder.body(Body::empty()).unwrap() + } + #[test] fn boundary_error_for_status_covers_common_fallback_statuses() { let cases = [ @@ -601,4 +785,392 @@ mod tests { .await .expect("router runtime should build"); } + + async fn router_for_identity_mode(identity_mode: IdentityMode) -> (axum::Router, AppServices, TempDir) { + router_for_identity_mode_with_origins(identity_mode, &[]).await + } + + async fn router_for_identity_mode_with_origins( + identity_mode: IdentityMode, + allowed_origins: &[&str], + ) -> (axum::Router, AppServices, TempDir) { + let temp_dir = tempfile::tempdir().unwrap(); + let config = AppConfig { + data_dir: temp_dir.path().join("data"), + work_dir: temp_dir.path().join("work"), + local: identity_mode == IdentityMode::Local, + identity_mode, + bootstrap_secret: (identity_mode == IdentityMode::AionPro).then(|| "test-bootstrap-secret".to_string()), + local_client_secret: (identity_mode == IdentityMode::Local).then(|| TEST_LOCAL_CLIENT_SECRET.to_string()), + allowed_origins: allowed_origins.iter().map(|value| (*value).to_string()).collect(), + ..AppConfig::default() + }; + let db = aionui_db::init_database_memory().await.unwrap(); + let services = AppServices::from_config(db, &config).await.unwrap(); + let router = super::create_router(&services).await.unwrap(); + (router, services, temp_dir) + } + + fn assert_no_cors_opt_in(response: &axum::response::Response) { + assert!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none()); + assert!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) + .is_none() + ); + } + + #[tokio::test] + async fn webui_cross_origin_response_does_not_opt_in_to_cors() { + let (router, services, _temp_dir) = router_for_identity_mode(IdentityMode::WebUi).await; + let response = router + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_no_cors_opt_in(&response); + services.database.close().await; + } + + #[tokio::test] + async fn webui_cross_origin_login_preflight_is_not_approved() { + let (router, services, _temp_dir) = router_for_identity_mode(IdentityMode::WebUi).await; + let response = router + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri("/login") + .header(header::ORIGIN, "https://attacker.example") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, Method::POST.as_str()) + .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_no_cors_opt_in(&response); + services.database.close().await; + } + + #[tokio::test] + async fn webui_originless_direct_login_still_works() { + let (router, services, _temp_dir) = router_for_identity_mode(IdentityMode::WebUi).await; + let password_hash = aionui_auth::hash_password("test-password").unwrap(); + services + .user_repo + .set_system_user_credentials("admin", &password_hash) + .await + .unwrap(); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"username":"admin","password":"test-password"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_no_cors_opt_in(&response); + services.database.close().await; + } + + #[tokio::test] + async fn local_mode_only_allows_packaged_null_origin_by_default() { + let (router, services, _temp_dir) = router_for_identity_mode(IdentityMode::Local).await; + let attacker = router + .clone() + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(attacker.status(), StatusCode::OK); + assert_no_cors_opt_in(&attacker); + + let packaged = router + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "null") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(packaged.status(), StatusCode::OK); + assert_eq!( + packaged.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&header::HeaderValue::from_static("null")) + ); + assert!( + packaged + .headers() + .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) + .is_none() + ); + services.database.close().await; + } + + #[tokio::test] + async fn local_mode_allows_an_explicit_dev_origin() { + let (router, services, _temp_dir) = + router_for_identity_mode_with_origins(IdentityMode::Local, &["http://127.0.0.1:5173"]).await; + let response = router + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "http://127.0.0.1:5173") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&header::HeaderValue::from_static("http://127.0.0.1:5173")) + ); + services.database.close().await; + } + + #[tokio::test] + async fn aionpro_only_allows_configured_credentialed_cors() { + let (router, services, _temp_dir) = + router_for_identity_mode_with_origins(IdentityMode::AionPro, &["https://desktop.example"]).await; + let response = router + .clone() + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "https://desktop.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&header::HeaderValue::from_static("https://desktop.example")) + ); + assert_eq!( + response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS), + Some(&header::HeaderValue::from_static("true")) + ); + + let attacker = router + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_no_cors_opt_in(&attacker); + services.database.close().await; + } + + #[tokio::test] + async fn local_ws_token_and_websocket_reject_unlisted_origins_but_allow_native_origins() { + let (router, services, _temp_dir) = router_for_identity_mode(IdentityMode::Local).await; + let local_jwt = services.jwt_service.sign("system_default_user", "local_user").unwrap(); + let missing_secret = router + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/settings") + .header(header::ORIGIN, "null") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_secret.status(), StatusCode::UNAUTHORIZED); + + let missing_ws_token_secret = router + .clone() + .oneshot( + Request::builder() + .uri("/api/ws-token") + .header(header::ORIGIN, "null") + .header(header::AUTHORIZATION, format!("Bearer {local_jwt}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_ws_token_secret.status(), StatusCode::UNAUTHORIZED); + + let allowed_http = router + .clone() + .oneshot( + Request::builder() + .uri("/api/ws-token") + .header(header::ORIGIN, "null") + .header("x-aionui-local-secret", TEST_LOCAL_CLIENT_SECRET) + .header(header::AUTHORIZATION, format!("Bearer {local_jwt}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if allowed_http.status() != StatusCode::OK { + let status = allowed_http.status(); + let body = to_bytes(allowed_http.into_body(), usize::MAX).await.unwrap(); + panic!( + "allowed local ws-token failed with {status}: {}", + String::from_utf8_lossy(&body) + ); + } + + let attacker_http = router + .clone() + .oneshot( + Request::builder() + .uri("/api/ws-token") + .header(header::ORIGIN, "https://attacker.example") + .header("x-aionui-local-secret", TEST_LOCAL_CLIENT_SECRET) + .header(header::AUTHORIZATION, format!("Bearer {local_jwt}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(attacker_http.status(), StatusCode::FORBIDDEN); + + for path in ["/ws", "/api/stt/stream"] { + let missing_protocol = router + .clone() + .oneshot( + Request::builder() + .uri(path) + .header(header::ORIGIN, "null") + .header(header::CONNECTION, "upgrade") + .header(header::UPGRADE, "websocket") + .header(header::SEC_WEBSOCKET_VERSION, "13") + .header(header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_protocol.status(), StatusCode::UNAUTHORIZED, "{path}"); + + let attacker = router + .clone() + .oneshot(local_websocket_request( + path, + Some("https://attacker.example"), + TEST_LOCAL_WS_PROTOCOL, + )) + .await + .unwrap(); + assert_eq!(attacker.status(), StatusCode::FORBIDDEN, "{path}"); + + let wrong_secret = router + .clone() + .oneshot(local_websocket_request( + path, + Some("null"), + "aionui-local-v1.abcdefghijklmnopqrstuvwxyzABCDEFGH012345679", + )) + .await + .unwrap(); + assert_eq!(wrong_secret.status(), StatusCode::UNAUTHORIZED, "{path}"); + + let packaged = router + .clone() + .oneshot(local_websocket_request(path, Some("null"), TEST_LOCAL_WS_PROTOCOL)) + .await + .unwrap(); + assert_ne!(packaged.status(), StatusCode::FORBIDDEN, "{path}"); + assert_ne!(packaged.status(), StatusCode::UNAUTHORIZED, "{path}"); + + let originless = router + .clone() + .oneshot(local_websocket_request(path, None, TEST_LOCAL_WS_PROTOCOL)) + .await + .unwrap(); + assert_ne!(originless.status(), StatusCode::FORBIDDEN, "{path}"); + assert_ne!(originless.status(), StatusCode::UNAUTHORIZED, "{path}"); + } + services.database.close().await; + } + + #[tokio::test] + async fn aionpro_ws_token_and_websocket_enforce_the_configured_origin() { + let (router, services, _temp_dir) = + router_for_identity_mode_with_origins(IdentityMode::AionPro, &["https://desktop.example"]).await; + let attacker_token = router + .clone() + .oneshot( + Request::builder() + .uri("/api/ws-token") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(attacker_token.status(), StatusCode::FORBIDDEN); + let allowed_token = router + .clone() + .oneshot( + Request::builder() + .uri("/api/ws-token") + .header(header::ORIGIN, "https://desktop.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(allowed_token.status(), StatusCode::FORBIDDEN); + + for path in ["/ws", "/api/stt/stream"] { + let attacker = router + .clone() + .oneshot(local_websocket_request( + path, + Some("https://attacker.example"), + "invalid-session-token", + )) + .await + .unwrap(); + assert_eq!(attacker.status(), StatusCode::FORBIDDEN, "{path}"); + + let allowed = router + .clone() + .oneshot(local_websocket_request( + path, + Some("https://desktop.example"), + "invalid-session-token", + )) + .await + .unwrap(); + assert_ne!(allowed.status(), StatusCode::FORBIDDEN, "{path}"); + } + services.database.close().await; + } } diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 0d0b2bec9..c97ea40dc 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -21,7 +21,7 @@ use aionui_db::{ SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantOverrideRepository, SqliteAssistantPreferenceRepository, SqliteAssistantRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, - SqliteRemoteAgentRepository, SqliteSettingsRepository, + SqliteRemoteAgentRepository, SqliteSettingsRepository, UserStatus, UserType, }; use aionui_extension::{ AssistantRuleDispatcher, ExtensionRegistry, ExtensionRouterState, ExtensionStateStore, ExternalPathsManager, @@ -187,6 +187,7 @@ pub struct ChannelOrchestratorComponents { pub manager: Arc, pub plugin_factory: Arc, pub owner_user_id: Option, + pub restore_owner_user_ids: Vec, } /// Build all default `ModuleStates` from application services. @@ -305,6 +306,7 @@ pub async fn build_module_states( agent: build_module_state_phase(&boot, "agent", || AgentRouterState { agent_registry: services.agent_registry.clone(), service: agent_service, + require_host_admin: !services.identity_mode.is_local(), }), connection_test: build_module_state_phase(&boot, "connection_test", build_connection_test_state), file: build_module_state_phase(&boot, "file", || build_file_state(services))?, @@ -396,7 +398,10 @@ pub fn build_assistant_state(services: &AppServices) -> AssistantRouterState { }, services.data_dir.clone(), )); - AssistantRouterState { service } + AssistantRouterState { + service, + require_host_admin: !services.identity_mode.is_local(), + } } /// Build the default `SystemRouterState` from application services. @@ -417,7 +422,8 @@ pub fn build_system_state(services: &AppServices) -> SystemRouterState { SystemRouterState { settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(pool.clone()))), client_pref_service, - provider_service: ProviderService::new(provider_repo.clone(), encryption_key), + provider_service: ProviderService::new(provider_repo.clone(), encryption_key) + .with_share_repo(services.share_repo.clone()), model_fetch_service: ModelFetchService::new(provider_repo, encryption_key, http_client.clone()), protocol_detection_service: ProtocolDetectionService::new(http_client.clone()), version_check_service: VersionCheckService::new(http_client, env!("CARGO_PKG_VERSION").to_owned()), @@ -455,6 +461,7 @@ pub fn build_remote_agent_state(services: &AppServices) -> RemoteAgentRouterStat let repo = Arc::new(SqliteRemoteAgentRepository::new(pool)); RemoteAgentRouterState { service: Arc::new(RemoteAgentService::new(repo, encryption_key)), + require_host_admin: !services.identity_mode.is_local(), } } @@ -469,7 +476,14 @@ pub fn build_connection_test_state() -> ConnectionTestRouterState { pub fn build_file_state(services: &AppServices) -> Result { let broadcaster = services.event_bus.clone(); let allowed_roots = default_allowed_roots(Some(services.work_dir.as_path())); - let file_service = Arc::new(FileService::new(broadcaster.clone(), allowed_roots.clone())); + let file_service = if services.identity_mode.is_local() { + Arc::new(FileService::new(broadcaster.clone(), allowed_roots.clone())) + } else { + Arc::new(FileService::new_user_session( + broadcaster.clone(), + allowed_roots.clone(), + )) + }; let snapshot_service = Arc::new(SnapshotService::new()); // Shell-backed capabilities for `/api/fs/reveal` (open enclosing folder) and // `/api/fs/open-system` (open with the default application): adapters over one @@ -601,6 +615,34 @@ async fn startup_channel_owner_user_id(services: &AppServices) -> Option Some(owner_user_id) } +async fn startup_channel_restore_owner_user_ids(services: &AppServices) -> Vec { + if services.identity_mode == IdentityMode::AionPro { + return Vec::new(); + } + + match services.user_repo.list_users().await { + Ok(users) => { + let mut owner_user_ids = users + .into_iter() + .filter(|user| user.user_type == UserType::Local && user.status == UserStatus::Active) + .map(|user| user.id) + .collect::>(); + owner_user_ids.sort(); + owner_user_ids.dedup(); + owner_user_ids + } + Err(error) => { + tracing::warn!( + code = "BOOTSTRAP_DEGRADED_CHANNEL_OWNER_DISCOVERY", + stage = "channel.restore.owners", + error = %error, + "failed to discover channel plugin owners" + ); + startup_channel_owner_user_id(services).await.into_iter().collect() + } + } +} + /// Build the default `ChannelRouterState` and orchestrator components. pub async fn build_channel_state( services: &AppServices, @@ -637,6 +679,7 @@ pub async fn build_channel_state( // Build channel settings service for per-plugin agent/model configuration. let channel_settings = build_channel_settings_service(services, generated_assistant_materializer); let startup_owner_user_id = startup_channel_owner_user_id(services).await; + let restore_owner_user_ids = startup_channel_restore_owner_user_ids(services).await; // Build orchestrator dependencies let action_executor = Arc::new(aionui_channel::action::ActionExecutor::new( @@ -663,6 +706,8 @@ pub async fn build_channel_state( plugin_factory: Arc::clone(&plugin_factory), settings_service: channel_settings, extension_registry, + #[cfg(feature = "weixin")] + weixin_login_coordinator: Arc::new(aionui_channel::plugins::weixin::WeixinLoginCoordinator::new()), }; let components = ChannelOrchestratorComponents { @@ -672,6 +717,7 @@ pub async fn build_channel_state( manager, plugin_factory, owner_user_id: startup_owner_user_id, + restore_owner_user_ids, }; (state, components) @@ -782,8 +828,9 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { services.skill_paths.clone(), services.skill_repo.clone(), )); + let conversation_workspace_root = services.conversation_workspace_root(); let conv_service = ConversationService::new( - services.work_dir.clone(), + conversation_workspace_root.clone(), services.event_bus.clone(), skill_resolver, services.worker_task_manager.clone(), @@ -811,7 +858,7 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { services.worker_task_manager.clone(), conv_repo, Arc::new(conv_service.clone()), - services.work_dir.clone(), + conversation_workspace_root, services.data_dir.clone(), services.event_bus.clone(), services.agent_registry.clone(), @@ -852,6 +899,7 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { CronRouterState { cron_service, conversation_service: conv_service, + allow_system_resume_http: services.identity_mode.is_local(), } } @@ -888,6 +936,7 @@ pub fn build_shell_state(services: &AppServices) -> ShellRouterState { ))), stt_service: Arc::new(aionui_shell::SttService::new(reqwest::Client::new())), client_pref_service, + require_host_admin: !services.identity_mode.is_local(), } } @@ -959,7 +1008,15 @@ pub fn build_ws_state(services: &AppServices, router: Arc) -> if identity_mode == IdentityMode::AionPro && user.user_type != aionui_db::UserType::Aionpro { return None; } - (payload.session_generation == user.session_generation).then_some(user.id) + if payload.session_generation != user.session_generation || user.must_change_password { + return None; + } + if let Some(session_id) = payload.session_id.as_deref() + && !user_repo.is_auth_session_active(session_id, &user.id).await.ok()? + { + return None; + } + Some(user.id) }) }); @@ -1184,6 +1241,51 @@ mod tests { services.database.close().await; } + #[tokio::test] + async fn channel_restore_discovers_every_active_local_owner() { + let db = aionui_db::init_database_memory().await.unwrap(); + let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let active = services + .user_repo + .create_user("active-channel-owner", "hash") + .await + .unwrap(); + let disabled = services + .user_repo + .create_user("disabled-channel-owner", "hash") + .await + .unwrap(); + services + .user_repo + .set_status(&disabled.id, UserStatus::Disabled) + .await + .unwrap(); + + let owner_user_ids = startup_channel_restore_owner_user_ids(&services).await; + + assert!(owner_user_ids.contains(&"system_default_user".to_string())); + assert!(owner_user_ids.contains(&active.id)); + assert!(!owner_user_ids.contains(&disabled.id)); + assert!(owner_user_ids.windows(2).all(|pair| pair[0] < pair[1])); + + services.database.close().await; + } + + #[tokio::test] + async fn channel_restore_is_disabled_for_aionpro_identity() { + let db = aionui_db::init_database_memory().await.unwrap(); + let config = AppConfig { + identity_mode: IdentityMode::AionPro, + bootstrap_secret: Some("bootstrap-secret".to_owned()), + ..AppConfig::default() + }; + let services = AppServices::from_config(db, &config).await.unwrap(); + + assert!(startup_channel_restore_owner_user_ids(&services).await.is_empty()); + + services.database.close().await; + } + #[tokio::test] async fn build_channel_message_service_uses_app_conversation_service_for_assistant_bindings() { let db = aionui_db::init_database_memory().await.unwrap(); @@ -1272,6 +1374,9 @@ mod tests { let config = AppConfig { data_dir: tmp.path().join("data"), work_dir: tmp.path().join("work"), + local: true, + identity_mode: IdentityMode::Local, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), ..Default::default() }; let db = aionui_db::init_database_memory().await.unwrap(); diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index c06c1b3b0..947be036b 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -2,20 +2,25 @@ use std::path::PathBuf; use std::sync::Arc; +use std::{fs::OpenOptions, io::Write}; use crate::config::{AppConfig, IdentityMode, derive_encryption_key}; use aionui_ai_agent::{ AcpSessionSyncService, AcpSkillManager, ActiveLeaseRegistry, AgentFactoryDeps, AgentRegistry, IWorkerTaskManager, RuntimeTokenService, WorkerTaskManagerImpl, build_agent_factory, }; -use aionui_auth::{CookieConfig, JwtService, QrTokenStore, resolve_jwt_secret}; +use aionui_auth::{ + CookieConfig, JwtService, QrTokenStore, generate_password, hash_password, resolve_jwt_secret, validate_password, + validate_username, verify_password, +}; use aionui_common::OnConversationDelete; use aionui_conversation::{ConversationService, runtime_state::ConversationRuntimeStateService}; use aionui_db::{ - Database, IAcpSessionRepository, IAgentMetadataRepository, IConversationRepository, IMcpServerRepository, - IProjectStore, ISkillRepository, IUserRepository, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, - SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, - SqliteConversationRepository, SqliteMcpServerRepository, SqliteProjectStore, SqliteProviderRepository, + Database, IAcpSessionRepository, IAdminUserRepository, IAgentMetadataRepository, IConversationRepository, + IMcpServerRepository, IProjectStore, IResourceShareRepository, ISkillRepository, IUserRepository, + SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, + SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, SqliteConversationRepository, + SqliteMcpServerRepository, SqliteProjectStore, SqliteProviderRepository, SqliteResourceShareRepository, SqliteSkillRepository, SqliteUserRepository, }; use aionui_project::ProjectService; @@ -25,6 +30,9 @@ pub struct AppServices { pub database: Database, pub jwt_service: Arc, pub user_repo: Arc, + pub admin_user_repo: Arc, + pub share_repo: Arc, + pub initial_admin_credentials_file: Option>, pub cookie_config: Arc, pub qr_token_store: Arc, pub ws_manager: Arc, @@ -54,6 +62,8 @@ pub struct AppServices { pub local: bool, pub identity_mode: IdentityMode, pub bootstrap_secret: Option>, + pub local_client_secret: Option>, + pub allowed_origins: Arc<[String]>, pub app_version: String, /// Resolved skill paths. Shared with the `ConversationService` for /// snapshot resolution at create time. @@ -75,14 +85,19 @@ impl AppServices { self.runtime_base_url.clone() } + pub(crate) fn conversation_workspace_root(&self) -> PathBuf { + conversation_workspace_root(self.identity_mode, &self.data_dir, &self.work_dir) + } + /// Replace the worker task manager after construction. /// /// Primarily used by tests to inject mock implementations. pub fn with_worker_task_manager(mut self, wtm: Arc) -> Self { self.worker_task_manager = wtm; + let workspace_root = self.conversation_workspace_root(); self.conversation_service = build_conversation_service(ConversationServiceDeps { database: &self.database, - work_dir: self.work_dir.clone(), + work_dir: workspace_root, event_bus: self.event_bus.clone(), skill_paths: self.skill_paths.clone(), skill_repo: self.skill_repo.clone(), @@ -103,9 +118,45 @@ impl AppServices { let work_dir = config.work_dir.clone(); let identity_mode = config.effective_identity_mode(); let local = identity_mode.is_local(); + if local { + let secret = config + .local_client_secret + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Local identity mode requires AIONCORE_LOCAL_CLIENT_SECRET"))?; + crate::config::validate_local_client_secret(secret) + .map_err(|error| anyhow::anyhow!("Invalid AIONCORE_LOCAL_CLIENT_SECRET: {error}"))?; + } + let bootstrap_workspace = if local { + None + } else { + config + .bootstrap_workspace + .as_deref() + .map(resolve_bootstrap_workspace) + .transpose()? + }; + let conversation_workspace_root = conversation_workspace_root(identity_mode, &data_dir, &work_dir); + let upload_root = data_dir.join("uploads"); + let (conversation_workspace_root, upload_root) = if local { + (conversation_workspace_root, upload_root) + } else { + ( + prepare_user_session_root(&conversation_workspace_root, bootstrap_workspace.as_deref())?, + prepare_user_session_root(&upload_root, bootstrap_workspace.as_deref())?, + ) + }; let dump_prompts = config.dump_prompts; let app_version = config.app_version.clone(); - let user_repo: Arc = Arc::new(SqliteUserRepository::new(database.pool().clone())); + let sqlite_user_repo = Arc::new(SqliteUserRepository::new(database.pool().clone())); + let user_repo: Arc = sqlite_user_repo.clone(); + let admin_user_repo: Arc = sqlite_user_repo; + let initial_admin_credentials_file = if identity_mode == IdentityMode::WebUi && config.bootstrap_initial_admin { + bootstrap_initial_webui_admin(user_repo.as_ref(), admin_user_repo.as_ref(), &data_dir) + .await? + .map(Arc::new) + } else { + None + }; // Resolve JWT secret: env var → system user db field → random generation let env_secret = std::env::var("JWT_SECRET").ok(); @@ -177,13 +228,24 @@ impl AppServices { let conversation_repo: Arc = Arc::new(SqliteConversationRepository::new(database.pool().clone())); let skill_repo: Arc = Arc::new(SqliteSkillRepository::new(database.pool().clone())); + let share_repo: Arc = + Arc::new(SqliteResourceShareRepository::new(database.pool().clone())); - // Project-bind service (side branch). temp_root mirrors the existing - // conversation temp-workspace root (`work_dir/conversations`) so - // `resolve_existing` classifies auto workspaces as temp and - // user-picked directories as standard. + // Project-bind temp_root mirrors the conversation service's effective + // workspace root. Browser sessions keep this under data_dir, never + // under the optional operator-mounted bootstrap workspace. let project_store: Arc = Arc::new(SqliteProjectStore::new(database.pool().clone())); - let project_service = ProjectService::new(project_store, work_dir.join("conversations")); + let project_service = if identity_mode.is_local() { + ProjectService::new(project_store, work_dir.join("conversations")).with_share_repo(share_repo.clone()) + } else { + ProjectService::new_user_session( + project_store, + conversation_workspace_root.join("conversations"), + upload_root, + bootstrap_workspace, + ) + .with_share_repo(share_repo.clone()) + }; // Skill paths need app resource dir (for builtin rules) + data dir // (for user skills + materialized views). AcpSkillManager uses these @@ -232,6 +294,7 @@ impl AppServices { let factory = build_agent_factory(AgentFactoryDeps { skill_manager: AcpSkillManager::new_with_repo(skill_paths.clone(), skill_repo.clone()), provider_repo, + user_repo: user_repo.clone(), encryption_key, agent_registry: agent_registry.clone(), acp_agent_service: acp_agent_service.clone(), @@ -240,6 +303,7 @@ impl AppServices { broadcaster: event_bus.clone(), backend_binary_path: backend_binary_path.clone(), mcp_server_repo: Some(mcp_server_repo), + restrict_member_host_tools: !identity_mode.is_local(), session_spawner, // agy cannot prompt for tool permission in headless mode, so AionUi // registers itself as its PreToolUse hook; the hook process calls @@ -262,7 +326,7 @@ impl AppServices { let conversation_runtime_state = Arc::new(ConversationRuntimeStateService::default()); let conversation_service = build_conversation_service(ConversationServiceDeps { database: &database, - work_dir: work_dir.clone(), + work_dir: conversation_workspace_root, event_bus: event_bus.clone(), skill_paths: skill_paths.clone(), skill_repo: skill_repo.clone(), @@ -281,6 +345,9 @@ impl AppServices { jwt_service: Arc::new(JwtService::new(secret.clone())), antigravity_hook_tokens, user_repo, + admin_user_repo, + share_repo, + initial_admin_credentials_file, cookie_config: Arc::new(CookieConfig::from_env()), qr_token_store: Arc::new(QrTokenStore::new()), ws_manager: Arc::new(WebSocketManager::new()), @@ -302,6 +369,8 @@ impl AppServices { local, identity_mode, bootstrap_secret: config.bootstrap_secret.clone().map(Arc::::from), + local_client_secret: config.local_client_secret.clone().map(Arc::::from), + allowed_origins: Arc::from(config.allowed_origins.clone()), app_version, skill_paths, skill_repo, @@ -311,6 +380,239 @@ impl AppServices { } } +async fn bootstrap_initial_webui_admin( + user_repo: &dyn IUserRepository, + admin_repo: &dyn IAdminUserRepository, + data_dir: &std::path::Path, +) -> anyhow::Result> { + let credentials_path = std::env::var_os("AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| data_dir.join("initial-admin-credentials.json")); + if user_repo + .has_usable_admin() + .await + .map_err(|error| anyhow::anyhow!("Failed to inspect initial administrator: {error}"))? + { + if credentials_path.exists() { + if initial_admin_credentials_match_pending_user(user_repo, &credentials_path).await? { + return Ok(Some(credentials_path)); + } + let _ = std::fs::remove_file(&credentials_path); + } + return Ok(None); + } + + let mut username = std::env::var("AIONUI_INITIAL_ADMIN_USERNAME") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "admin".to_string()); + validate_username(&username).map_err(|error| anyhow::anyhow!("Invalid initial administrator username: {error}"))?; + + let direct_password = std::env::var("AIONUI_INITIAL_ADMIN_PASSWORD") + .ok() + .filter(|value| !value.is_empty()); + let password_file = std::env::var_os("AIONUI_INITIAL_ADMIN_PASSWORD_FILE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + if direct_password.is_some() && password_file.is_some() { + anyhow::bail!("AIONUI_INITIAL_ADMIN_PASSWORD and AIONUI_INITIAL_ADMIN_PASSWORD_FILE are mutually exclusive"); + } + + let generated = direct_password.is_none() && password_file.is_none(); + let mut created_credentials_file = false; + let password = if let Some(password) = direct_password { + password + } else if let Some(path) = password_file { + let contents = std::fs::read_to_string(&path) + .map_err(|error| anyhow::anyhow!("Failed to read initial administrator password file: {error}"))?; + contents + .lines() + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Initial administrator password file is empty"))? + .to_owned() + } else if credentials_path.exists() { + let resumed = read_initial_admin_credentials(&credentials_path)?; + username = resumed.0; + resumed.1 + } else { + let password = generate_password(20); + write_initial_admin_credentials(&credentials_path, &username, &password)?; + created_credentials_file = true; + password + }; + validate_password(&password).map_err(|error| anyhow::anyhow!("Invalid initial administrator password: {error}"))?; + let password_for_hash = password.clone(); + let password_hash = tokio::task::spawn_blocking(move || hash_password(&password_for_hash)) + .await + .map_err(|error| anyhow::anyhow!("Initial administrator hash task failed: {error}"))??; + + let result = admin_repo + .bootstrap_initial_admin(&username, &password_hash) + .await + .map_err(|error| anyhow::anyhow!("Failed to bootstrap initial administrator: {error}")); + match result { + Ok(Some(user)) => { + if generated { + tracing::warn!( + username = %user.username.as_deref().unwrap_or(&username), + credentials_file = %credentials_path.display(), + "Initial administrator created; retrieve the one-time password from the protected credentials file" + ); + } else { + tracing::info!( + username = %user.username.as_deref().unwrap_or(&username), + "Initial administrator created from operator-supplied credentials" + ); + } + Ok(generated.then_some(credentials_path)) + } + Ok(None) => { + if generated && credentials_path.exists() { + if initial_admin_credentials_match_pending_user(user_repo, &credentials_path).await? { + return Ok(Some(credentials_path)); + } + let _ = std::fs::remove_file(&credentials_path); + } + if created_credentials_file { + let _ = std::fs::remove_file(&credentials_path); + } + Ok(None) + } + // Preserve a newly created write-ahead credential on database errors; + // the next startup validates and reuses it instead of locking the + // operator out after a crash/transient failure. + Err(error) => Err(error), + } +} + +async fn initial_admin_credentials_match_pending_user( + user_repo: &dyn IUserRepository, + path: &std::path::Path, +) -> anyhow::Result { + let (credential_username, credential_password) = read_initial_admin_credentials(path)?; + let Some(user) = user_repo.find_by_username(&credential_username).await? else { + return Ok(false); + }; + if user.site_role != aionui_db::SiteRole::Admin || !user.must_change_password { + return Ok(false); + } + let Some(password_hash) = user.password_hash else { + return Ok(false); + }; + tokio::task::spawn_blocking(move || verify_password(&credential_password, &password_hash)) + .await + .map_err(|error| anyhow::anyhow!("Initial administrator verification task failed: {error}"))? + .map_err(|error| anyhow::anyhow!("Initial administrator credential verification failed: {error}")) +} + +fn read_initial_admin_credentials(path: &std::path::Path) -> anyhow::Result<(String, String)> { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() { + anyhow::bail!("Initial administrator credentials path must be a regular file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + anyhow::bail!("Initial administrator credentials file must not be accessible by group or others"); + } + } + let value: serde_json::Value = serde_json::from_reader(std::fs::File::open(path)?)?; + let username = value + .get("username") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Initial administrator credentials file has no username"))? + .to_owned(); + let password = value + .get("temporary_password") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Initial administrator credentials file has no temporary password"))? + .to_owned(); + if value.get("must_change_password").and_then(serde_json::Value::as_bool) != Some(true) { + anyhow::bail!("Initial administrator credentials file is not a temporary credential"); + } + validate_username(&username)?; + validate_password(&password)?; + Ok((username, password)) +} + +fn write_initial_admin_credentials(path: &std::path::Path, username: &str, password: &str) -> anyhow::Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|error| { + anyhow::anyhow!( + "Refusing to overwrite initial administrator credentials file '{}': {error}", + path.display() + ) + })?; + let document = serde_json::json!({ + "username": username, + "temporary_password": password, + "created_at": aionui_common::now_ms(), + "must_change_password": true, + }); + serde_json::to_writer(&mut file, &document)?; + file.write_all(b"\n")?; + file.sync_all()?; + Ok(()) +} + +fn conversation_workspace_root( + identity_mode: IdentityMode, + data_dir: &std::path::Path, + work_dir: &std::path::Path, +) -> PathBuf { + if identity_mode.is_local() { + work_dir.to_path_buf() + } else { + data_dir.join("user-workspaces") + } +} + +fn prepare_user_session_root( + root: &std::path::Path, + bootstrap_workspace: Option<&std::path::Path>, +) -> anyhow::Result { + std::fs::create_dir_all(root) + .map_err(|error| anyhow::anyhow!("failed to initialize managed user filesystem root: {error}"))?; + let canonical = std::fs::canonicalize(root) + .map_err(|error| anyhow::anyhow!("managed user filesystem root is not accessible: {error}"))?; + if let Some(bootstrap) = bootstrap_workspace { + let bootstrap = std::fs::canonicalize(bootstrap) + .map_err(|error| anyhow::anyhow!("AIONUI_BOOTSTRAP_WORKSPACE is not accessible: {error}"))?; + if canonical.starts_with(&bootstrap) || bootstrap.starts_with(&canonical) { + anyhow::bail!("managed user filesystem roots and AIONUI_BOOTSTRAP_WORKSPACE must be disjoint"); + } + } + Ok(canonical) +} + +fn resolve_bootstrap_workspace(path: &std::path::Path) -> anyhow::Result { + if !path.is_absolute() { + anyhow::bail!("AIONUI_BOOTSTRAP_WORKSPACE must be an absolute path"); + } + let canonical = std::fs::canonicalize(path) + .map_err(|error| anyhow::anyhow!("AIONUI_BOOTSTRAP_WORKSPACE is not accessible: {error}"))?; + if !canonical.is_dir() { + anyhow::bail!("AIONUI_BOOTSTRAP_WORKSPACE must reference a directory"); + } + Ok(canonical) +} + struct ConversationServiceDeps<'a> { database: &'a Database, work_dir: PathBuf, @@ -365,10 +667,19 @@ fn build_conversation_service(deps: ConversationServiceDeps<'_>) -> Conversation mod tests { use super::*; + fn local_config() -> AppConfig { + AppConfig { + local: true, + identity_mode: IdentityMode::Local, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), + ..Default::default() + } + } + #[tokio::test] async fn test_app_services_from_memory_db() { let db = aionui_db::init_database_memory().await.unwrap(); - let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let services = AppServices::from_config(db, &local_config()).await.unwrap(); // JWT service should be functional let token = services.jwt_service.sign("test_user", "testuser").unwrap(); @@ -385,7 +696,7 @@ mod tests { #[tokio::test] async fn test_jwt_secret_persisted_to_db() { let db = aionui_db::init_database_memory().await.unwrap(); - let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let services = AppServices::from_config(db, &local_config()).await.unwrap(); // System user should now have a jwt_secret persisted let system_user = services.user_repo.get_system_user().await.unwrap(); @@ -401,7 +712,7 @@ mod tests { let db = aionui_db::init_database_memory().await.unwrap(); let config = AppConfig { app_version: "9.9.9".to_string(), - ..Default::default() + ..local_config() }; let services = AppServices::from_config(db, &config).await.unwrap(); @@ -409,4 +720,110 @@ mod tests { services.database.close().await; } + + #[test] + fn initial_admin_credentials_are_private_resumable_and_never_overwritten() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("initial-admin.json"); + write_initial_admin_credentials(&path, "admin", "StrongP@ssword1").unwrap(); + + let resumed = read_initial_admin_credentials(&path).unwrap(); + assert_eq!(resumed.0, "admin"); + assert_eq!(resumed.1, "StrongP@ssword1"); + assert!(write_initial_admin_credentials(&path, "other", "OtherP@ssword2").is_err()); + assert_eq!(read_initial_admin_credentials(&path).unwrap(), resumed); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + #[cfg(unix)] + #[test] + fn initial_admin_credentials_reject_loose_permissions_and_symlinks() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("initial-admin.json"); + write_initial_admin_credentials(&path, "admin", "StrongP@ssword1").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_initial_admin_credentials(&path).is_err()); + + let link = directory.path().join("credentials-link.json"); + symlink(&path, &link).unwrap(); + assert!(read_initial_admin_credentials(&link).is_err()); + } + + #[tokio::test] + async fn webui_bootstrap_resumes_write_ahead_credentials() { + let directory = tempfile::tempdir().unwrap(); + let credentials = directory.path().join("initial-admin-credentials.json"); + write_initial_admin_credentials(&credentials, "admin", "CrashSafeP@ss1").unwrap(); + let database = aionui_db::init_database_memory().await.unwrap(); + let config = AppConfig { + data_dir: directory.path().to_path_buf(), + work_dir: directory.path().to_path_buf(), + bootstrap_initial_admin: true, + ..Default::default() + }; + + let services = AppServices::from_config(database, &config).await.unwrap(); + let admin = services.user_repo.get_system_user().await.unwrap().unwrap(); + assert_eq!(admin.site_role, aionui_db::SiteRole::Admin); + assert!(admin.must_change_password); + assert!(aionui_auth::verify_password("CrashSafeP@ss1", admin.password_hash.as_deref().unwrap()).unwrap()); + assert_eq!( + services.initial_admin_credentials_file.as_deref().map(AsRef::as_ref), + Some(credentials.as_path()) + ); + + // A restart before the initial password change must retain the path + // so the successful self-change can consume the credential file. + let resumed_path = bootstrap_initial_webui_admin( + services.user_repo.as_ref(), + services.admin_user_repo.as_ref(), + directory.path(), + ) + .await + .unwrap(); + assert_eq!(resumed_path.as_deref(), Some(credentials.as_path())); + services.database.close().await; + } + + #[tokio::test] + async fn webui_bootstrap_discards_stale_credentials_after_password_reset() { + let directory = tempfile::tempdir().unwrap(); + let credentials = directory.path().join("initial-admin-credentials.json"); + write_initial_admin_credentials(&credentials, "admin", "StaleP@ssword1").unwrap(); + let database = aionui_db::init_database_memory().await.unwrap(); + let repository = SqliteUserRepository::new(database.pool().clone()); + let current_hash = hash_password("CurrentP@ssword2").unwrap(); + repository + .bootstrap_initial_admin("admin", ¤t_hash) + .await + .unwrap(); + + let result = bootstrap_initial_webui_admin(&repository, &repository, directory.path()) + .await + .unwrap(); + assert!(result.is_none()); + assert!(!credentials.exists()); + database.close().await; + } + + #[test] + fn managed_user_roots_and_bootstrap_workspace_must_be_disjoint() { + let directory = tempfile::tempdir().unwrap(); + let managed = directory.path().join("managed"); + let bootstrap_inside_managed = managed.join("bootstrap"); + std::fs::create_dir_all(&bootstrap_inside_managed).unwrap(); + assert!(prepare_user_session_root(&managed, Some(&bootstrap_inside_managed)).is_err()); + + let bootstrap = directory.path().join("operator-bootstrap"); + let managed_inside_bootstrap = bootstrap.join("managed"); + std::fs::create_dir_all(&managed_inside_bootstrap).unwrap(); + assert!(prepare_user_session_root(&managed_inside_bootstrap, Some(&bootstrap)).is_err()); + } } diff --git a/crates/aionui-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index 07b0b72dc..8ce5b54a5 100644 --- a/crates/aionui-app/tests/acp_e2e.rs +++ b/crates/aionui-app/tests/acp_e2e.rs @@ -31,7 +31,7 @@ async fn user_id_for_username(services: &aionui_app::AppServices, username: &str #[tokio::test] async fn management_list_returns_array() { let (mut app, services) = build_app().await; - let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "pass123").await; let req = get_with_token("/api/agents/management", &token); let resp = app.oneshot(req).await.unwrap(); @@ -57,7 +57,7 @@ async fn legacy_refresh_agents_endpoint_is_not_found() { #[tokio::test] async fn test_custom_agent_nonexistent_command() { let (mut app, services) = build_app().await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "pass123").await; // Endpoint was renamed from /api/agents/test to /api/agents/custom/try-connect // when the custom-agent CRUD routes were introduced. The new endpoint always @@ -80,8 +80,8 @@ async fn test_custom_agent_nonexistent_command() { #[tokio::test] async fn management_list_includes_missing_custom_agents() { let (mut app, services) = build_app().await; - let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let user_id = user_id_for_username(&services, "user1").await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "pass123").await; + let user_id = user_id_for_username(&services, "admin").await; let repo: std::sync::Arc = std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); @@ -135,8 +135,8 @@ async fn management_list_includes_missing_custom_agents() { #[tokio::test] async fn management_list_marks_rows_with_unavailable_snapshot() { let (mut app, services) = build_app().await; - let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let user_id = user_id_for_username(&services, "user1").await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "pass123").await; + let user_id = user_id_for_username(&services, "admin").await; let repo: std::sync::Arc = std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); @@ -216,8 +216,8 @@ async fn legacy_agents_endpoint_is_not_found() { #[tokio::test] async fn health_check_by_id_returns_missing_status_for_uninstalled_agent() { let (mut app, services) = build_app().await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let user_id = user_id_for_username(&services, "user1").await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "pass123").await; + let user_id = user_id_for_username(&services, "admin").await; let repo: std::sync::Arc = std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 139b95075..507d683b9 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -16,7 +16,7 @@ use aionui_api_types::{ AgentManagementRow, AgentManagementStatus, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, }; -use aionui_app::{AppConfig, AppServices, ModuleStates, build_module_states, create_router_with_states}; +use aionui_app::{AppConfig, AppServices, IdentityMode, ModuleStates, build_module_states, create_router_with_states}; use aionui_assistant::{AssistantAgentCatalogPort, AssistantRouterState, AssistantService, BuiltinAssistantRegistry}; use aionui_common::AgentType; use aionui_db::{ @@ -38,6 +38,7 @@ use tower::ServiceExt; use common::{body_json, delete_with_token, get_with_token, json_with_token, setup_and_login}; const DEFAULT_USER_ID: &str = "system_default_user"; +const LOCAL_CLIENT_SECRET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGH012345678"; // --------------------------------------------------------------------------- // Fixture — router + temp dirs + services @@ -151,6 +152,10 @@ fn assert_versioned_avatar_value(value: Option<&str>, expected_path: &str) { /// Also logs in `admin` and hands back the session + CSRF tokens so tests /// can issue authenticated mutating requests. async fn fixture() -> Fixture { + fixture_with_config(AppConfig::default()).await +} + +async fn fixture_with_config(config: AppConfig) -> Fixture { let user_tmp = TempDir::new().unwrap(); let builtin_tmp = TempDir::new().unwrap(); let ext_tmp = TempDir::new().unwrap(); @@ -215,7 +220,7 @@ async fn fixture() -> Fixture { // Bring up in-memory DB + services + default module states. let db = init_database_memory().await.unwrap(); - let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let services = AppServices::from_config(db, &config).await.unwrap(); let (mut states, _): (ModuleStates, _) = build_module_states(&services).await.expect("build module states"); for table in [ "assistant_preferences", @@ -337,6 +342,7 @@ async fn fixture() -> Fixture { service.bootstrap_assistant_storage().await.unwrap(); states.assistant = AssistantRouterState { service: service.clone(), + require_host_admin: !services.identity_mode.is_local(), }; // Rewire the skill-router dispatcher so assistant-rule / assistant-skill // endpoints route through the test-configured service. @@ -344,7 +350,11 @@ async fn fixture() -> Fixture { states.skill.assistant_dispatcher = Some(dispatcher); let mut app = create_router_with_states(&services, states); - let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let (token, csrf) = if services.identity_mode.is_local() { + (String::new(), String::new()) + } else { + setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await + }; Fixture { app, @@ -676,6 +686,191 @@ async fn create_allows_id_that_matches_extension_registry_assistant() { assert_eq!(resp.status(), StatusCode::CREATED); } +#[tokio::test] +async fn hosted_member_avatar_inputs_are_managed_only_across_create_update_and_import() { + let fx = fixture().await; + let mut app = fx.app.clone(); + let (member_token, member_csrf) = setup_and_login(&mut app, &fx.services, "avatar-member", "StrongP@ss2").await; + let member = fx + .services + .user_repo + .find_by_username("avatar-member") + .await + .unwrap() + .unwrap(); + let member_dir = aionui_common::user_dir_name(&member.id).unwrap(); + let source_avatar = fx.user_data_dir.join("host-only-avatar.png"); + std::fs::write(&source_avatar, b"host-only-bytes").unwrap(); + let file_uri = format!("file://{}", source_avatar.display()); + let absolute = source_avatar.to_string_lossy().into_owned(); + + for (index, avatar) in [ + absolute.as_str(), + file_uri.as_str(), + "relative/avatar.png", + "C:\\Users\\member\\avatar.png", + ] + .into_iter() + .enumerate() + { + let resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants", + json!({ + "id": format!("member-path-{index}"), + "name": "Rejected host path", + "avatar": avatar, + "agent_id": "632f31d2", + }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{avatar} must be rejected"); + } + + let inline_resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants", + json!({ "id": "member-inline", "name": "Inline", "avatar": "🤖", "agent_id": "632f31d2" }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(inline_resp.status(), StatusCode::CREATED); + + let managed_resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants", + json!({ + "id": "member-managed", + "name": "Managed", + "avatar": "/api/assistants/builtin-office/avatar", + "agent_id": "632f31d2", + }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(managed_resp.status(), StatusCode::CREATED); + + let preserve_resp = fx + .app + .clone() + .oneshot(json_with_token( + "PUT", + "/api/assistants/member-managed", + json!({ "avatar": "/api/assistants/member-managed/avatar" }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(preserve_resp.status(), StatusCode::OK); + + let rejected_update = fx + .app + .clone() + .oneshot(json_with_token( + "PUT", + "/api/assistants/member-managed", + json!({ "avatar": absolute }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(rejected_update.status(), StatusCode::BAD_REQUEST); + + let admin_avatar_resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants", + json!({ + "id": "admin-private-avatar", + "name": "Admin avatar", + "avatar": source_avatar.to_string_lossy(), + "agent_id": "632f31d2", + }), + &fx.token, + &fx.csrf, + )) + .await + .unwrap(); + assert_eq!(admin_avatar_resp.status(), StatusCode::CREATED); + + let foreign_route_resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants", + json!({ + "id": "member-foreign-copy", + "name": "Foreign copy", + "avatar": "/api/assistants/admin-private-avatar/avatar", + "agent_id": "632f31d2", + }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(foreign_route_resp.status(), StatusCode::BAD_REQUEST); + + let import_resp = fx + .app + .clone() + .oneshot(json_with_token( + "POST", + "/api/assistants/import", + json!({ + "assistants": [{ + "id": "member-import-path", + "name": "Rejected import", + "avatar": source_avatar.to_string_lossy(), + "agent_id": "632f31d2", + }], + }), + &member_token, + &member_csrf, + )) + .await + .unwrap(); + assert_eq!(import_resp.status(), StatusCode::OK); + let import_body = body_json(import_resp).await; + assert_eq!(import_body["data"]["imported"], 0); + assert_eq!(import_body["data"]["failed"], 1); + + let member_avatar_dir = fx.user_data_dir.join(format!("assistant-avatars/users/{member_dir}")); + assert!(member_avatar_dir.join("member-managed.png").is_file()); + for rejected in [ + "member-path-0.png", + "member-path-1.png", + "member-path-2.png", + "member-path-3.png", + "member-foreign-copy.png", + "member-import-path.png", + ] { + assert!(!member_avatar_dir.join(rejected).exists()); + } + assert_eq!(std::fs::read(&source_avatar).unwrap(), b"host-only-bytes"); +} + #[tokio::test] async fn create_user_avatar_from_local_file_is_served_via_assistant_avatar_route() { let fx = fixture().await; @@ -727,6 +922,46 @@ async fn create_user_avatar_from_local_file_is_served_via_assistant_avatar_route assert_eq!(&bytes[..], b"picked-avatar-bytes"); } +#[tokio::test] +async fn local_mode_retains_local_file_avatar_workflow() { + let fx = fixture_with_config(AppConfig { + local: true, + identity_mode: IdentityMode::Local, + local_client_secret: Some(LOCAL_CLIENT_SECRET.to_string()), + ..AppConfig::default() + }) + .await; + let source_avatar = fx.user_data_dir.join("local-mode-avatar.png"); + std::fs::write(&source_avatar, b"local-mode-avatar-bytes").unwrap(); + + let mut request = json_with_token( + "POST", + "/api/assistants", + json!({ + "id": "local-mode-avatar", + "name": "Local mode avatar", + "avatar": source_avatar.to_string_lossy(), + "agent_id": "632f31d2", + }), + &fx.token, + &fx.csrf, + ); + request.headers_mut().insert( + "x-aionui-local-secret", + axum::http::HeaderValue::from_static(LOCAL_CLIENT_SECRET), + ); + let response = fx.app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!( + std::fs::read( + fx.user_data_dir + .join("assistant-avatars/users/system_default_user/local-mode-avatar.png"), + ) + .unwrap(), + b"local-mode-avatar-bytes", + ); +} + #[tokio::test] async fn create_user_avatar_from_builtin_avatar_route_copies_builtin_asset() { let fx = fixture().await; @@ -1806,11 +2041,10 @@ fn find_id<'a>(list: &'a Value, id: &str) -> Option<&'a Value> { // Two-user filesystem isolation: avatars // --------------------------------------------------------------------------- -/// Two Core Users each upload an avatar. Each must land under its owner's +/// Two Core Users each materialize an avatar. Each must land under its owner's /// `assistant-avatars/users/{dir}/` (never a shared flat dir), keep its own -/// bytes, and be served per-user. (The service rejects reusing another user's -/// assistant id outright — asserted here too — so a same-name overwrite can't -/// even be attempted.) +/// bytes, and be served per-user. Both users may deliberately choose the same +/// assistant id without reserving or overwriting the other tenant's row/file. #[tokio::test] async fn avatars_of_two_users_are_physically_isolated() { let mut fx = fixture().await; @@ -1826,11 +2060,10 @@ async fn avatars_of_two_users_are_physically_isolated() { .expect("bob should exist"); let dir_b = aionui_common::user_dir_name(&user_b.id).unwrap(); - // Same-named source file, different bytes per user. + // The hosted admin may use the local file-picker flow. The member uses a + // built-in managed route and never receives host filesystem access. let src_a = fx.user_data_dir.join("picked-a.png"); - let src_b = fx.user_data_dir.join("picked-b.png"); std::fs::write(&src_a, b"avatar-bytes-A").unwrap(); - std::fs::write(&src_b, b"avatar-bytes-B").unwrap(); for (id_body, token, csrf) in [ ( @@ -1839,7 +2072,7 @@ async fn avatars_of_two_users_are_physically_isolated() { &fx.csrf, ), ( - json!({ "id": "av-iso-b", "name": "Iso B", "avatar": src_b.to_string_lossy(), "agent_id": "632f31d2" }), + json!({ "id": "av-iso-b", "name": "Iso B", "avatar": "/api/assistants/builtin-office/avatar", "agent_id": "632f31d2" }), &token_b, &csrf_b, ), @@ -1849,21 +2082,27 @@ async fn avatars_of_two_users_are_physically_isolated() { assert_eq!(resp.status(), StatusCode::CREATED); } - // Reusing another user's assistant id is rejected outright, so a - // same-name avatar overwrite cannot even be attempted. + // Reusing another user's assistant id creates an independent tenant-local + // assistant and must not overwrite the first user's avatar. let req = json_with_token( "POST", "/api/assistants", - json!({ "id": "av-iso-a", "name": "Steal", "avatar": src_b.to_string_lossy(), "agent_id": "632f31d2" }), + json!({ "id": "av-iso-a", "name": "Steal", "avatar": "/api/assistants/builtin-office/avatar", "agent_id": "632f31d2" }), &token_b, &csrf_b, ); let resp = fx.app.clone().oneshot(req).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::CONFLICT, - "cross-user assistant id reuse must be rejected" + assert_eq!(resp.status(), StatusCode::CREATED); + + let admin_only_req = json_with_token( + "POST", + "/api/assistants", + json!({ "id": "av-admin-only", "name": "Admin only", "avatar": src_a.to_string_lossy(), "agent_id": "632f31d2" }), + &fx.token, + &fx.csrf, ); + let admin_only_resp = fx.app.clone().oneshot(admin_only_req).await.unwrap(); + assert_eq!(admin_only_resp.status(), StatusCode::CREATED); // Physically distinct per-user files, each with its own bytes. let file_a = fx @@ -1879,7 +2118,15 @@ async fn avatars_of_two_users_are_physically_isolated() { b"avatar-bytes-A", "A's avatar bytes must be untouched" ); - assert_eq!(std::fs::read(&file_b).unwrap(), b"avatar-bytes-B"); + assert_eq!(std::fs::read(&file_b).unwrap(), b"not-a-real-png"); + assert_eq!( + std::fs::read( + fx.user_data_dir + .join(format!("assistant-avatars/users/{dir_b}/av-iso-a.png")), + ) + .unwrap(), + b"not-a-real-png", + ); // Nothing leaked into a shared flat root or the other user's dir. assert!(!fx.user_data_dir.join("assistant-avatars/av-iso-b.png").exists()); assert!( @@ -1891,7 +2138,8 @@ async fn avatars_of_two_users_are_physically_isolated() { // Serving is per-user: each token gets its own bytes. for (id, token, expected) in [ ("av-iso-a", &fx.token, &b"avatar-bytes-A"[..]), - ("av-iso-b", &token_b, &b"avatar-bytes-B"[..]), + ("av-iso-a", &token_b, &b"not-a-real-png"[..]), + ("av-iso-b", &token_b, &b"not-a-real-png"[..]), ] { let resp = fx .app @@ -1906,4 +2154,14 @@ async fn avatars_of_two_users_are_physically_isolated() { .to_bytes(); assert_eq!(&bytes[..], expected); } + + for (id, foreign_token) in [("av-admin-only", &token_b), ("av-iso-b", &fx.token)] { + let resp = fx + .app + .clone() + .oneshot(get_with_token(&format!("/api/assistants/{id}/avatar"), foreign_token)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } } diff --git a/crates/aionui-app/tests/auxiliary_e2e.rs b/crates/aionui-app/tests/auxiliary_e2e.rs index 02dc3e916..b8b60a740 100644 --- a/crates/aionui-app/tests/auxiliary_e2e.rs +++ b/crates/aionui-app/tests/auxiliary_e2e.rs @@ -9,7 +9,7 @@ use axum::http::StatusCode; use serde_json::json; use tower::ServiceExt; -use common::{body_json, get_with_token, json_with_token, setup_and_login}; +use common::{body_json, get_with_token, json_with_token, managed_workspace_root, setup_and_login}; // ── Helpers ───────────────────────────────────────────────────── @@ -93,11 +93,11 @@ async fn workspace_browse_no_active_task() { let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; // Seed a real workspace on disk so the handler can canonicalize it. - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join("src")).unwrap(); - std::fs::write(tmp.path().join("src/lib.rs"), b"// hi").unwrap(); + let workspace = managed_workspace_root(&services, "user1").await; + std::fs::create_dir_all(workspace.join("src")).unwrap(); + std::fs::write(workspace.join("src/lib.rs"), b"// hi").unwrap(); - let ws = tmp.path().to_string_lossy().into_owned(); + let ws = workspace.to_string_lossy().into_owned(); let conv_id = create_conversation_with_workspace(&mut app, &token, &csrf, "Test Conv", "acp", &ws).await; let req = get_with_token(&format!("/api/conversations/{conv_id}/workspace?path=/src"), &token); @@ -132,15 +132,43 @@ async fn workspace_browse_empty_path() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn hosted_workspace_browse_rejects_legacy_outside_workspace_rows() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + let managed = managed_workspace_root(&services, "user1").await; + let managed_string = managed.to_string_lossy().into_owned(); + let conversation_id = + create_conversation_with_workspace(&mut app, &token, &csrf, "Legacy Conv", "acp", &managed_string).await; + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"not visible").unwrap(); + let extra = serde_json::json!({ "workspace": outside.path() }).to_string(); + sqlx::query("UPDATE conversations SET extra = ? WHERE id = ?") + .bind(extra) + .bind(&conversation_id) + .execute(services.database.pool()) + .await + .unwrap(); + + let request = get_with_token( + &format!("/api/conversations/{conversation_id}/workspace?path=/"), + &token, + ); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); +} + #[cfg(unix)] #[tokio::test] -async fn workspace_browse_treats_symlinked_skill_dir_as_directory() { +async fn hosted_workspace_browse_does_not_follow_symlink_outside_root() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let tmp = tempfile::tempdir().unwrap(); - let workspace = tmp.path().join("workspace"); - let builtin = tmp.path().join("builtin-skills/auto-inject/aionui-skills"); + let workspace = managed_workspace_root(&services, "user1").await; + let outside = tempfile::tempdir().unwrap(); + let builtin = outside.path().join("builtin-skills/auto-inject/aionui-skills"); std::fs::create_dir_all(workspace.join(".claude/skills")).unwrap(); std::fs::create_dir_all(&builtin).unwrap(); std::fs::write(builtin.join("SKILL.md"), b"---\ndescription: test\n---\nbody").unwrap(); @@ -171,16 +199,8 @@ async fn workspace_browse_treats_symlinked_skill_dir_as_directory() { &token, ); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - let json = body_json(resp).await; - let entries = json["data"].as_array().unwrap(); - assert!( - entries - .iter() - .any(|entry| entry["name"] == "SKILL.md" && entry["type"] == "file"), - "symlinked skill dir should remain browsable: {entries:?}" - ); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(resp).await["code"], "BAD_REQUEST"); } // ── 9.2 Side question ─────────────────────────────────────────── diff --git a/crates/aionui-app/tests/common/mod.rs b/crates/aionui-app/tests/common/mod.rs index 0455b0aef..ada7b7788 100644 --- a/crates/aionui-app/tests/common/mod.rs +++ b/crates/aionui-app/tests/common/mod.rs @@ -20,6 +20,53 @@ pub async fn build_app() -> (axum::Router, AppServices) { (router, services) } +/// Build a WebUi-authenticated app whose file routes retain the embedded +/// desktop host-path policy. Existing file behavior tests use this explicitly; +/// multi-user isolation tests use the production user-session policy. +pub async fn build_app_with_legacy_file_access() -> (axum::Router, AppServices) { + let db = aionui_db::init_database_memory().await.unwrap(); + let mut services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let (mut states, _) = build_module_states(&services).await.expect("build module states"); + states.file.file_service = std::sync::Arc::new(FileService::new( + services.event_bus.clone(), + states.file.allowed_roots.clone(), + )); + let store = std::sync::Arc::new(aionui_db::SqliteProjectStore::new(services.database.pool().clone())); + let project_service = aionui_project::ProjectService::new(store, services.work_dir.join("conversations")); + services.project_service = project_service.clone(); + states.file.project = std::sync::Arc::new(project_service); + let router = create_router_with_states(&services, states); + (router, services) +} + +pub async fn build_app_at(root: &std::path::Path) -> (axum::Router, AppServices) { + let db = aionui_db::init_database_memory().await.unwrap(); + let config = AppConfig { + data_dir: root.to_path_buf(), + work_dir: root.to_path_buf(), + ..Default::default() + }; + let services = AppServices::from_config(db, &config).await.unwrap(); + let router = create_router(&services).await.expect("build router"); + (router, services) +} + +pub async fn build_app_at_with_bootstrap_workspace( + root: &std::path::Path, + bootstrap_workspace: &std::path::Path, +) -> (axum::Router, AppServices) { + let db = aionui_db::init_database_memory().await.unwrap(); + let config = AppConfig { + data_dir: root.to_path_buf(), + work_dir: bootstrap_workspace.to_path_buf(), + bootstrap_workspace: Some(bootstrap_workspace.to_path_buf()), + ..Default::default() + }; + let services = AppServices::from_config(db, &config).await.unwrap(); + let router = create_router(&services).await.expect("build router"); + (router, services) +} + /// Build an app whose skill router uses the given temp directories. /// /// Use for HTTP integration tests that need deterministic on-disk layouts @@ -105,9 +152,13 @@ pub async fn build_app_with_noop_opener() -> (axum::Router, AppServices) { pub async fn build_app_with_file_roots(allowed_roots: Vec) -> (axum::Router, AppServices) { let db = aionui_db::init_database_memory().await.unwrap(); - let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); + let mut services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let (mut states, _) = build_module_states(&services).await.expect("build module states"); states.file.file_service = std::sync::Arc::new(FileService::new(services.event_bus.clone(), allowed_roots)); + let store = std::sync::Arc::new(aionui_db::SqliteProjectStore::new(services.database.pool().clone())); + let project_service = aionui_project::ProjectService::new(store, services.work_dir.join("conversations")); + services.project_service = project_service.clone(); + states.file.project = std::sync::Arc::new(project_service); let router = create_router_with_states(&services, states); (router, services) } @@ -342,6 +393,20 @@ pub async fn setup_and_login( (token, csrf) } +/// Create and return the server-derived private workspace root for a test +/// account. Call this after [`setup_and_login`] has created the identity. +pub async fn managed_workspace_root(services: &AppServices, username: &str) -> std::path::PathBuf { + let user = services + .user_repo + .find_by_username(username) + .await + .unwrap() + .unwrap_or_else(|| panic!("user '{username}' must exist")); + let root = services.project_service.user_workspace_root(&user.id).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + root +} + /// Log in an account that already exists — for a second app instance brought up /// over the same database, where creating the user again would panic. pub async fn login_existing(app: &mut axum::Router, username: &str, password: &str) -> (String, String) { diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index fcd3d1d15..fcf087712 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -14,7 +14,7 @@ use tower::ServiceExt; use common::{ body_json, build_app, build_app_with_mock_agents, delete_with_token, get_request, get_with_token, json_with_token, - setup_and_login, + managed_workspace_root, setup_and_login, }; // ── Helpers ─────────────────────────────────────────────────────────── @@ -338,8 +338,10 @@ async fn t1_5b_create_accepts_workspace_paths_with_whitespace_segments() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - let temp = tempfile::tempdir().unwrap(); - let workspace = temp.path().join("my project").join("repo"); + let workspace = managed_workspace_root(&services, "admin") + .await + .join("my project") + .join("repo"); std::fs::create_dir_all(&workspace).unwrap(); let body = json!({ @@ -364,8 +366,9 @@ async fn t1_5c_create_rejects_missing_workspace_path() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - let missing_workspace = - std::env::temp_dir().join(format!("aionui-conv-missing-{}", aionui_common::generate_short_id())); + let missing_workspace = managed_workspace_root(&services, "admin") + .await + .join(format!("aionui-conv-missing-{}", aionui_common::generate_short_id())); let body = json!({ "type": "acp", @@ -394,6 +397,43 @@ async fn t1_5c_create_rejects_missing_workspace_path() { ); } +#[tokio::test] +async fn t1_5d_hosted_user_cannot_select_an_outside_or_foreign_workspace() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + + let outside = tempfile::tempdir().unwrap(); + let req = json_with_token( + "POST", + "/api/conversations", + create_body_with_extra("Outside", json!({ "workspace": outside.path() })), + &token, + &csrf, + ); + let response = app.clone().oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); + + let member_hash = aionui_auth::hash_password("MemberP@ss1").unwrap(); + let member = services + .user_repo + .create_user("workspace-member", &member_hash) + .await + .unwrap(); + let foreign = services.project_service.user_workspace_root(&member.id).unwrap(); + std::fs::create_dir_all(&foreign).unwrap(); + let req = json_with_token( + "POST", + "/api/conversations", + create_body_with_extra("Foreign", json!({ "workspace": foreign })), + &token, + &csrf, + ); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); +} + #[tokio::test] async fn t1_6_create_requires_auth() { let (app, _services) = build_app().await; @@ -689,9 +729,9 @@ async fn t4_2_update_pin_and_unpin() { async fn t4_3_update_extra_merge() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - let temp = tempfile::tempdir().unwrap(); - let old_workspace = temp.path().join("old"); - let new_workspace = temp.path().join("new"); + let root = managed_workspace_root(&services, "admin").await; + let old_workspace = root.join("old"); + let new_workspace = root.join("new"); std::fs::create_dir_all(&old_workspace).unwrap(); std::fs::create_dir_all(&new_workspace).unwrap(); @@ -721,6 +761,33 @@ async fn t4_3_update_extra_merge() { assert_eq!(json["data"]["extra"]["context_file_name"], "ctx.md"); } +#[tokio::test] +async fn t4_3b_update_rejects_workspace_outside_the_callers_root() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let req = json_with_token( + "POST", + "/api/conversations", + create_body("Workspace Guard"), + &token, + &csrf, + ); + let response = app.clone().oneshot(req).await.unwrap(); + let id = body_json(response).await["data"]["id"].as_str().unwrap().to_owned(); + let outside = tempfile::tempdir().unwrap(); + + let req = json_with_token( + "PATCH", + &format!("/api/conversations/{id}"), + json!({ "extra": { "workspace": outside.path() } }), + &token, + &csrf, + ); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); +} + #[tokio::test] async fn t4_4_update_model() { let (mut app, services) = build_app().await; diff --git a/crates/aionui-app/tests/custom_agent_e2e.rs b/crates/aionui-app/tests/custom_agent_e2e.rs index f105237c0..2ab619bb5 100644 --- a/crates/aionui-app/tests/custom_agent_e2e.rs +++ b/crates/aionui-app/tests/custom_agent_e2e.rs @@ -363,3 +363,60 @@ async fn test_on_save_cli_not_found_blocks_upsert() { "rejected create must not leave rows behind" ); } + +#[tokio::test] +async fn hosted_member_cannot_manage_or_probe_host_agents() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "member", "StrongP@ss2").await; + + let requests = [ + get_with_token("/api/agents/management", &token), + get_with_token("/api/agents/2d23ff1c/overrides", &token), + json_with_token("POST", "/api/agents/2d23ff1c/health-check", json!({}), &token, &csrf), + json_with_token( + "PATCH", + "/api/agents/2d23ff1c/enabled", + json!({ "enabled": true }), + &token, + &csrf, + ), + json_with_token( + "PUT", + "/api/agents/2d23ff1c/overrides", + json!({ "command_override": "/bin/sh" }), + &token, + &csrf, + ), + json_with_token( + "POST", + "/api/agents/custom/try-connect", + json!({ "command": "/bin/sh", "acp_args": [], "env": [] }), + &token, + &csrf, + ), + json_with_token( + "POST", + "/api/agents/custom", + json!({ "name": "blocked", "command": "/bin/sh" }), + &token, + &csrf, + ), + json_with_token( + "PUT", + "/api/agents/custom/missing", + json!({ "name": "blocked", "command": "/bin/sh" }), + &token, + &csrf, + ), + json_with_token("DELETE", "/api/agents/custom/missing", json!(null), &token, &csrf), + ]; + + for request in requests { + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "ADMIN_REQUIRED"); + } + + let response = app.oneshot(get_with_token("/api/agents/logos", &token)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/crates/aionui-app/tests/extension_e2e.rs b/crates/aionui-app/tests/extension_e2e.rs index 63ee5c00a..b117ef137 100644 --- a/crates/aionui-app/tests/extension_e2e.rs +++ b/crates/aionui-app/tests/extension_e2e.rs @@ -16,6 +16,39 @@ use common::{ sync_skill_catalog_for_test, }; +async fn setup_admin_and_login( + app: &mut axum::Router, + services: &AppServices, + username: &str, + password: &str, +) -> (String, String) { + let auth = setup_and_login(app, services, username, password).await; + sqlx::query("UPDATE users SET site_role = 'admin' WHERE username = ?") + .bind(username) + .execute(services.database.pool()) + .await + .unwrap(); + auth +} + +async fn managed_skill_upload_root( + services: &AppServices, + paths: &aionui_extension::SkillPaths, + username: &str, +) -> std::path::PathBuf { + let user = services + .user_repo + .find_by_username(username) + .await + .unwrap() + .expect("test user should exist"); + paths + .data_dir + .join("uploads") + .join("users") + .join(aionui_common::user_dir_name(&user.id).unwrap()) +} + fn write_legacy_extension_fixture(tmp: &TempDir) -> std::path::PathBuf { let ext_root = tmp.path().join("extensions"); let ext_dir = ext_root.join("legacy-suite"); @@ -1013,7 +1046,7 @@ async fn sm11_get_skill_paths() { #[tokio::test] async fn sm9_detect_paths() { let (mut app, services) = build_app().await; - let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; + let (token, _csrf) = setup_admin_and_login(&mut app, &services, "user1", "pass1").await; let resp = app .oneshot(get_with_token("/api/skills/detect-paths", &token)) @@ -1033,7 +1066,7 @@ async fn sm9_detect_paths() { #[tokio::test] async fn cp1_get_external_paths_empty() { let (mut app, services) = build_app().await; - let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; + let (token, _csrf) = setup_admin_and_login(&mut app, &services, "user1", "pass1").await; let resp = app .oneshot(get_with_token("/api/skills/external-paths", &token)) @@ -1236,10 +1269,12 @@ async fn sk3_read_builtin_skill_rejects_path_traversal() { #[tokio::test] async fn si1_read_skill_info_from_directory_path() { let tmp = TempDir::new().unwrap(); - let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; + let (mut app, services, paths) = build_app_with_skill_paths(tmp.path()).await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; - let skill_dir = tmp.path().join("my-skill"); + let skill_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("my-skill"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), @@ -1268,10 +1303,12 @@ async fn si1_read_skill_info_from_directory_path() { #[tokio::test] async fn si2_read_skill_info_falls_back_to_directory_name_when_name_empty() { let tmp = TempDir::new().unwrap(); - let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; + let (mut app, services, paths) = build_app_with_skill_paths(tmp.path()).await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; - let skill_dir = tmp.path().join("fallback-dir"); + let skill_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("fallback-dir"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), @@ -1300,10 +1337,12 @@ async fn si2_read_skill_info_falls_back_to_directory_name_when_name_empty() { #[tokio::test] async fn si3_read_skill_info_returns_not_found_for_missing_path() { let tmp = TempDir::new().unwrap(); - let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; + let (mut app, services, paths) = build_app_with_skill_paths(tmp.path()).await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; - let missing = tmp.path().join("no-such-skill"); + let upload_root = managed_skill_upload_root(&services, &paths, "user1").await; + std::fs::create_dir_all(&upload_root).unwrap(); + let missing = upload_root.join("no-such-skill"); let resp = app .oneshot(json_with_token( @@ -1324,10 +1363,12 @@ async fn si3_read_skill_info_returns_not_found_for_missing_path() { #[tokio::test] async fn skill_import_returns_specific_code_for_oversized_file() { let tmp = TempDir::new().unwrap(); - let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; + let (mut app, services, paths) = build_app_with_skill_paths(tmp.path()).await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; - let skill_dir = tmp.path().join("huge-skill"); + let skill_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("huge-skill"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), @@ -1371,7 +1412,9 @@ async fn skill_batch_import_reports_partial_failures_without_rolling_back_succes let (mut app, services, paths) = build_app_with_skill_paths(tmp.path()).await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; - let parent_dir = tmp.path().join("parent-pack"); + let parent_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("parent-pack"); let alpha_dir = parent_dir.join("alpha-skill"); let beta_dir = parent_dir.join("beta-skill"); std::fs::create_dir_all(&alpha_dir).unwrap(); @@ -1487,7 +1530,9 @@ async fn sl1_list_skills_tags_builtin_and_custom_with_source_field() { // Real users get custom skills through the import API, which stores // files under the user's scoped storage (never the legacy shared root). - let source_dir = tmp.path().join("import-src"); + let source_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("import-src"); write_skill(&source_dir, "my-skill", "A user-imported skill"); let resp = app .clone() @@ -1543,7 +1588,9 @@ async fn sl2_list_skills_user_custom_overrides_builtin() { // A user-imported skill with the same name shadows the builtin row in // the user's listing. The source dir name differs; the skill NAME in // the frontmatter is what collides. - let source_dir = tmp.path().join("import-src"); + let source_dir = managed_skill_upload_root(&services, &paths, "user1") + .await + .join("import-src"); let override_dir = source_dir.join("review-override"); std::fs::create_dir_all(&override_dir).unwrap(); std::fs::write( @@ -1671,7 +1718,7 @@ async fn de1_detect_external_populates_custom_source_slug() { // in `tests/e2e/features/settings/skills/edge-cases.e2e.ts`. let tmp = TempDir::new().unwrap(); let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; + let (token, csrf) = setup_admin_and_login(&mut app, &services, "user1", "pass1").await; let ext_dir = tmp.path().join("external-skills"); let skill_dir = ext_dir.join("my-ext-skill"); @@ -1723,7 +1770,7 @@ async fn de1_detect_external_populates_custom_source_slug() { async fn de2_detect_external_source_slugs_are_unique() { let tmp = TempDir::new().unwrap(); let (mut app, services, _paths) = build_app_with_skill_paths(tmp.path()).await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; + let (token, csrf) = setup_admin_and_login(&mut app, &services, "user1", "pass1").await; let mk = |p: &std::path::Path, skill: &str| { let dir = p.join(skill); diff --git a/crates/aionui-app/tests/file_e2e.rs b/crates/aionui-app/tests/file_e2e.rs index 4156a57bc..8ad5d4f3d 100644 --- a/crates/aionui-app/tests/file_e2e.rs +++ b/crates/aionui-app/tests/file_e2e.rs @@ -7,7 +7,11 @@ use axum::http::{Request, StatusCode}; use serde_json::json; use tower::ServiceExt; -use common::{body_json, build_app, build_app_with_file_roots, json_with_token, setup_and_login}; +use common::{ + body_json, build_app as build_secure_app, build_app_at, build_app_at_with_bootstrap_workspace, + build_app_with_file_roots, build_app_with_legacy_file_access as build_app, json_with_token, login_existing, + setup_and_login, +}; // =========================================================================== // Auth guard @@ -15,7 +19,7 @@ use common::{body_json, build_app, build_app_with_file_roots, json_with_token, s #[tokio::test] async fn fs_endpoints_require_auth() { - let (app, _services) = build_app().await; + let (app, _services) = build_secure_app().await; let endpoints = [ "/api/fs/content", "/api/fs/content/metadata", @@ -58,6 +62,347 @@ async fn fs_endpoints_require_auth() { } } +#[tokio::test] +async fn bootstrap_workspace_is_available_only_to_the_seeded_system_user() { + let root = tempfile::tempdir().unwrap(); + let bootstrap = tempfile::tempdir().unwrap(); + let secret = bootstrap.path().join("project").join("secret.txt"); + std::fs::create_dir_all(secret.parent().unwrap()).unwrap(); + std::fs::write(&secret, "bootstrap workspace").unwrap(); + + let (mut app, services) = build_app_at_with_bootstrap_workspace(root.path(), bootstrap.path()).await; + let (admin_token, admin_csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let _ = setup_and_login(&mut app, &services, "bob", "StrongP@ss2").await; + let member = services.user_repo.find_by_username("bob").await.unwrap().unwrap(); + aionui_db::IAdminUserRepository::update_managed_role( + &aionui_db::SqliteUserRepository::new(services.database.pool().clone()), + &member.id, + aionui_db::SiteRole::Admin, + &aionui_db::AuditActor::system(), + ) + .await + .unwrap(); + let (member_token, member_csrf) = login_existing(&mut app, "bob", "StrongP@ss2").await; + + let admin_request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": secret, "workspace": bootstrap.path() }), + &admin_token, + &admin_csrf, + ); + let response = app.clone().oneshot(admin_request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["data"], "bootstrap workspace"); + + let member_request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": secret, "workspace": bootstrap.path() }), + &member_token, + &member_csrf, + ); + let response = app.clone().oneshot(member_request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "user_filesystem_denied"); + + let admin = services.user_repo.find_by_username("admin").await.unwrap().unwrap(); + services + .project_service + .resolve_existing( + &admin.id, + aionui_project::canonical::to_file_uri(bootstrap.path()).unwrap(), + ) + .await + .unwrap(); + let member_result = services + .project_service + .resolve_existing( + &member.id, + aionui_project::canonical::to_file_uri(bootstrap.path()).unwrap(), + ) + .await; + assert!(matches!( + member_result, + Err(aionui_project::ProjectError::UserFilesystemDenied) + )); + + let member_workspace = services + .project_service + .user_workspace_root(&member.id) + .unwrap() + .join("private"); + std::fs::create_dir_all(&member_workspace).unwrap(); + let member_secret = member_workspace.join("member.txt"); + std::fs::write(&member_secret, "other admin private data").unwrap(); + assert!(!member_workspace.starts_with(bootstrap.path())); + + let request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": member_secret, "workspace": member_workspace }), + &admin_token, + &admin_csrf, + ); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "user_filesystem_denied"); +} + +#[tokio::test] +async fn bootstrap_workspace_rejects_nested_managed_user_roots() { + let bootstrap = tempfile::tempdir().unwrap(); + let data_dir = bootstrap.path().join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + let database = aionui_db::init_database_memory().await.unwrap(); + let config = aionui_app::AppConfig { + data_dir, + work_dir: bootstrap.path().to_path_buf(), + bootstrap_workspace: Some(bootstrap.path().to_path_buf()), + ..Default::default() + }; + + let error = match aionui_app::AppServices::from_config(database, &config).await { + Ok(_) => panic!("managed user roots below the bootstrap workspace must fail closed"), + Err(error) => error, + }; + assert!(error.to_string().contains("managed user filesystem roots")); +} + +// =========================================================================== +// WebUi multi-user filesystem boundary +// =========================================================================== + +#[tokio::test] +async fn webui_raw_paths_local_refs_and_snapshots_are_user_isolated() { + let root = tempfile::tempdir().unwrap(); + let (mut app, services) = build_app_at(root.path()).await; + let (token_a, csrf_a) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let (token_b, _csrf_b) = setup_and_login(&mut app, &services, "bob", "StrongP@ss2").await; + let user_a = services.user_repo.find_by_username("admin").await.unwrap().unwrap(); + let user_b = services.user_repo.find_by_username("bob").await.unwrap().unwrap(); + + let workspace_a = services + .project_service + .user_workspace_root(&user_a.id) + .unwrap() + .join("workspace"); + let workspace_b = services + .project_service + .user_workspace_root(&user_b.id) + .unwrap() + .join("workspace"); + std::fs::create_dir_all(&workspace_a).unwrap(); + std::fs::create_dir_all(&workspace_b).unwrap(); + let file_a = workspace_a.join("a.txt"); + let file_b = workspace_b.join("secret.txt"); + std::fs::write(&file_a, "owned by a").unwrap(); + std::fs::write(&file_b, "owned by b").unwrap(); + + let unmanaged = tempfile::tempdir().unwrap(); + let unmanaged_result = services + .project_service + .resolve_existing( + &user_a.id, + aionui_project::canonical::to_file_uri(unmanaged.path()).unwrap(), + ) + .await; + assert!(matches!( + unmanaged_result, + Err(aionui_project::ProjectError::UserFilesystemDenied) + )); + + let own = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": file_a.to_str().unwrap(), "workspace": workspace_a.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(own).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["data"], "owned by a"); + + let foreign = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": file_b.to_str().unwrap(), "workspace": workspace_b.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(foreign).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "user_filesystem_denied"); + + let users_root = workspace_a.parent().unwrap().parent().unwrap(); + let traversal = workspace_a + .join("..") + .join("..") + .join(workspace_b.strip_prefix(users_root).unwrap()); + let traversal = traversal.join("secret.txt"); + let request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": traversal.to_str().unwrap(), "workspace": workspace_a.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + #[cfg(unix)] + { + let link = workspace_a.join("escape-dir"); + std::os::unix::fs::symlink(&workspace_b, &link).unwrap(); + let request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": link.to_str().unwrap(), "workspace": workspace_a.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let request = json_with_token( + "POST", + "/api/fs/dir", + json!({ "dir": workspace_a.to_str().unwrap(), "root": workspace_a.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let listing = body_json(response).await; + let link_entry = listing["data"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["name"] == "escape-dir") + .unwrap(); + assert_eq!(link_entry["is_dir"], false); + assert!(link_entry.get("children").is_none_or(serde_json::Value::is_null)); + assert!(!listing.to_string().contains("owned by b")); + + let foreign_target = workspace_b.join("created-through-link.txt"); + let dangling_link = workspace_a.join("dangling-write.txt"); + std::os::unix::fs::symlink(&foreign_target, &dangling_link).unwrap(); + let request = json_with_token( + "POST", + "/api/fs/write", + json!({ + "path": dangling_link.to_str().unwrap(), + "workspace": workspace_a.to_str().unwrap(), + "data": "must not escape" + }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(!foreign_target.exists()); + } + + let local = json_with_token( + "POST", + "/api/fs/content", + json!({ "file": local_ref(&file_b), "encoding": "utf8" }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(local).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let snapshot = json_with_token( + "POST", + "/api/fs/snapshot/init", + json!({ "workspace": workspace_b.to_str().unwrap() }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(snapshot).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let project_b = services + .project_service + .resolve_existing( + &user_b.id, + aionui_project::canonical::to_file_uri(&workspace_b).unwrap(), + ) + .await + .unwrap(); + let project_ref = json!({ + "kind": "project", + "pe_id": project_b.project_explorer.pe_id, + "relative_path": "secret.txt" + }); + let request = json_with_token( + "POST", + "/api/fs/content", + json!({ "file": project_ref, "encoding": "utf8" }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // A second user's token is valid, proving the denials above are ownership + // failures rather than an authentication setup error. + let request = json_with_token( + "POST", + "/api/fs/read", + json!({ "path": file_b.to_str().unwrap(), "workspace": workspace_b.to_str().unwrap() }), + &token_b, + &_csrf_b, + ); + assert_eq!(app.oneshot(request).await.unwrap().status(), StatusCode::OK); +} + +#[tokio::test] +async fn webui_uploads_are_written_to_and_resolved_from_the_callers_root() { + let root = tempfile::tempdir().unwrap(); + let (mut app, services) = build_app_at(root.path()).await; + let (token_a, csrf_a) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let (token_b, csrf_b) = setup_and_login(&mut app, &services, "bob", "StrongP@ss2").await; + let user_a = services.user_repo.find_by_username("admin").await.unwrap().unwrap(); + + let (content_type, body) = UploadMultipart::new() + .add_file("file", "private.txt", "text/plain", b"private upload") + .add_text("conversation_id", "conversation-a") + .build(); + let response = app + .clone() + .oneshot(upload_request(&content_type, body, &token_a, &csrf_a)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let upload_path = body_json(response).await["data"].as_str().unwrap().to_owned(); + let expected_root = services.project_service.user_upload_root(&user_a.id).unwrap(); + assert!(std::path::Path::new(&upload_path).starts_with(expected_root)); + + let upload_ref = json!({ "kind": "upload", "path": upload_path }); + let own = json_with_token( + "POST", + "/api/fs/content", + json!({ "file": upload_ref.clone(), "encoding": "utf8" }), + &token_a, + &csrf_a, + ); + let response = app.clone().oneshot(own).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["data"], "private upload"); + + let foreign = json_with_token( + "POST", + "/api/fs/content", + json!({ "file": upload_ref, "encoding": "utf8" }), + &token_b, + &csrf_b, + ); + let response = app.oneshot(foreign).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + // =========================================================================== // Directory browsing // =========================================================================== diff --git a/crates/aionui-app/tests/local_mode.rs b/crates/aionui-app/tests/local_mode.rs index cbdf69f1d..432f15abc 100644 --- a/crates/aionui-app/tests/local_mode.rs +++ b/crates/aionui-app/tests/local_mode.rs @@ -8,6 +8,7 @@ async fn test_local_mode_skips_auth() { let db = aionui_db::init_database_memory().await.unwrap(); let config = aionui_app::AppConfig { local: true, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), ..Default::default() }; let services = aionui_app::AppServices::from_config(db, &config).await.unwrap(); @@ -24,7 +25,13 @@ async fn test_local_mode_skips_auth() { // An authenticated endpoint should work WITHOUT a token in local mode let response = router - .oneshot(Request::builder().uri("/api/settings").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("x-aionui-local-secret", "abcdefghijklmnopqrstuvwxyzABCDEFGH012345678") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_ne!(response.status(), StatusCode::FORBIDDEN); diff --git a/crates/aionui-app/tests/office_e2e.rs b/crates/aionui-app/tests/office_e2e.rs index 08b6e9e91..d26b34c3d 100644 --- a/crates/aionui-app/tests/office_e2e.rs +++ b/crates/aionui-app/tests/office_e2e.rs @@ -106,6 +106,18 @@ fn build_test_office_state( } } +async fn managed_user_root(services: &AppServices, username: &str) -> std::path::PathBuf { + let user = services + .user_repo + .find_by_username(username) + .await + .unwrap() + .unwrap_or_else(|| panic!("user '{username}' must exist")); + let root = services.project_service.user_workspace_root(&user.id).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + root +} + // ── AU-1/AU-2: Unauthenticated requests ───────────────────────────── #[tokio::test] @@ -151,10 +163,10 @@ async fn au2_unauthenticated_all_office_endpoints() { #[tokio::test] async fn wp4_word_preview_officecli_not_available() { - let (mut app, services, tmp) = build_office_app().await; + let (mut app, services, _tmp) = build_office_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let file_path = tmp.path().join("test.docx"); + let file_path = managed_user_root(&services, "user1").await.join("test.docx"); std::fs::write(&file_path, b"docx").unwrap(); let body = json!({"file_path": file_path.to_str().unwrap()}); @@ -170,7 +182,7 @@ async fn wp4_word_preview_officecli_not_available() { } #[tokio::test] -async fn wp5_word_preview_with_workspace_accepts_non_sandbox_path() { +async fn wp5_word_preview_rejects_client_claimed_workspace() { let sandbox = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let file_path = outside.path().join("demo.docx"); @@ -186,10 +198,9 @@ async fn wp5_word_preview_with_workspace_accepts_non_sandbox_path() { let req = json_with_token("POST", "/api/word-preview/start", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); let json = body_json(resp).await; - assert_eq!(json["success"], true); - assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED"); + assert_eq!(json["code"], "user_filesystem_denied"); } #[tokio::test] @@ -210,11 +221,11 @@ async fn wp6_word_preview_without_workspace_rejects_non_sandbox_path() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let json = body_json(resp).await; - assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX"); + assert_eq!(json["code"], "user_filesystem_denied"); } #[tokio::test] -async fn ep1_excel_preview_with_workspace_accepts_non_sandbox_path() { +async fn ep1_excel_preview_rejects_client_claimed_workspace() { let sandbox = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let file_path = outside.path().join("demo.xlsx"); @@ -230,14 +241,13 @@ async fn ep1_excel_preview_with_workspace_accepts_non_sandbox_path() { let req = json_with_token("POST", "/api/excel-preview/start", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); let json = body_json(resp).await; - assert_eq!(json["success"], true); - assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED"); + assert_eq!(json["code"], "user_filesystem_denied"); } #[tokio::test] -async fn pp1_ppt_preview_with_workspace_accepts_non_sandbox_path() { +async fn pp1_ppt_preview_rejects_client_claimed_workspace() { let sandbox = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let file_path = outside.path().join("demo.pptx"); @@ -253,10 +263,9 @@ async fn pp1_ppt_preview_with_workspace_accepts_non_sandbox_path() { let req = json_with_token("POST", "/api/ppt-preview/start", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); let json = body_json(resp).await; - assert_eq!(json["success"], true); - assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED"); + assert_eq!(json["code"], "user_filesystem_denied"); } // ── SO-1: Star Office detect route removed ─────────────────────────── @@ -291,10 +300,10 @@ async fn so2_detect_route_removed_with_preferred_url() { #[tokio::test] async fn dc1_excel_to_json() { - let (mut app, services, tmp) = build_office_app().await; + let (mut app, services, _tmp) = build_office_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let xlsx_path = tmp.path().join("test.xlsx"); + let xlsx_path = managed_user_root(&services, "user1").await.join("test.xlsx"); create_test_xlsx(&xlsx_path); let body = json!({ @@ -322,17 +331,24 @@ async fn dc1_excel_to_json() { async fn dc4_excel_file_not_found() { let (mut app, services, _tmp) = build_office_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + let missing_path = managed_user_root(&services, "user1").await.join("missing.xlsx"); let body = json!({ - "file_path": "/nonexistent/file.xlsx", + "file_path": missing_path.to_str().unwrap(), "to": "excel-json" }); let req = json_with_token("POST", "/api/document/convert", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; - assert_eq!(json["code"], "BAD_REQUEST"); + assert_eq!(json["success"], true); + assert_eq!(json["data"]["result"]["success"], false); + assert!( + json["data"]["result"]["error"] + .as_str() + .is_some_and(|error| error.contains("file not found")) + ); } #[tokio::test] @@ -354,7 +370,7 @@ async fn dc5_document_convert_rejects_outside_sandbox() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let json = body_json(resp).await; - assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX"); + assert_eq!(json["code"], "user_filesystem_denied"); } // ── DC-9: Invalid conversion target ───────────────────────────────── diff --git a/crates/aionui-app/tests/remote_agent_e2e.rs b/crates/aionui-app/tests/remote_agent_e2e.rs index fa27bf96a..921de899b 100644 --- a/crates/aionui-app/tests/remote_agent_e2e.rs +++ b/crates/aionui-app/tests/remote_agent_e2e.rs @@ -372,3 +372,42 @@ async fn t8_full_crud_lifecycle() { let json = body_json(resp).await; assert!(json["data"].as_array().unwrap().is_empty()); } + +#[tokio::test] +async fn hosted_member_can_read_personal_catalog_but_cannot_manage_connections() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "member", "StrongP@ss2").await; + + let response = app + .clone() + .oneshot(get_with_token("/api/remote-agents", &token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let requests = [ + json_with_token("POST", "/api/remote-agents", bearer_agent_body(), &token, &csrf), + json_with_token( + "PUT", + "/api/remote-agents/missing", + json!({ "url": "wss://remote.example.com" }), + &token, + &csrf, + ), + delete_with_token("/api/remote-agents/missing", &token, &csrf), + json_with_token( + "POST", + "/api/remote-agents/test-connection", + json!({ "url": "ws://127.0.0.1:9" }), + &token, + &csrf, + ), + json_with_token("POST", "/api/remote-agents/missing/handshake", json!({}), &token, &csrf), + ]; + + for request in requests { + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "ADMIN_REQUIRED"); + } +} diff --git a/crates/aionui-app/tests/skills_builtin_e2e.rs b/crates/aionui-app/tests/skills_builtin_e2e.rs index 5b21786e3..d6a524949 100644 --- a/crates/aionui-app/tests/skills_builtin_e2e.rs +++ b/crates/aionui-app/tests/skills_builtin_e2e.rs @@ -28,6 +28,7 @@ struct Fixture { token: String, csrf: String, data_dir: std::path::PathBuf, + managed_upload_root: std::path::PathBuf, _tmp: TempDir, } @@ -86,12 +87,27 @@ async fn fixture_embedded() -> Fixture { let mut app = create_router_with_states(&services, states); let (token, csrf) = setup_and_login(&mut app, &services, "builtin-e2e", "StrongP@ss1").await; + sqlx::query("UPDATE users SET site_role = 'admin' WHERE username = ?") + .bind("builtin-e2e") + .execute(services.database.pool()) + .await + .unwrap(); + let user = services + .user_repo + .find_by_username("builtin-e2e") + .await + .unwrap() + .expect("test user should exist"); + let managed_upload_root = data_dir + .join("uploads/users") + .join(aionui_common::user_dir_name(&user.id).unwrap()); Fixture { app, token, csrf, data_dir, + managed_upload_root, _tmp: tmp, } } @@ -253,7 +269,7 @@ async fn list_skills_builtin_entries_carry_relative_location() { let fx = fixture_embedded().await; // Seed one user skill so the merge is non-trivial. - let source_dir = fx.data_dir.join("import-source").join("my-custom"); + let source_dir = fx.managed_upload_root.join("import-source").join("my-custom"); std::fs::create_dir_all(&source_dir).unwrap(); std::fs::write( source_dir.join("SKILL.md"), diff --git a/crates/aionui-app/tests/team_e2e.rs b/crates/aionui-app/tests/team_e2e.rs index b07f9123c..7a63b6188 100644 --- a/crates/aionui-app/tests/team_e2e.rs +++ b/crates/aionui-app/tests/team_e2e.rs @@ -10,7 +10,7 @@ use aionui_api_types::TeamMcpStdioConfig; use aionui_team::mcp::protocol::{read_frame, write_frame}; use common::{ body_json, build_app, build_app_with_mock_agents, delete_with_token, get_request, get_with_token, json_with_token, - setup_and_login, + managed_workspace_root, setup_and_login, }; const DEFAULT_TEAM_ASSISTANT_ID: &str = "team-e2e-assistant"; @@ -371,8 +371,7 @@ async fn tc6b_workspace_with_whitespace_segment_is_accepted() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; ensure_default_team_assistant(&mut app, &services, &token, &csrf).await; - let temp = tempfile::tempdir().unwrap(); - let workspace = temp.path().join("Archive "); + let workspace = managed_workspace_root(&services, "admin").await.join("Archive "); std::fs::create_dir_all(&workspace).unwrap(); let body = json!({ @@ -393,8 +392,9 @@ async fn tc6c_create_team_rejects_missing_workspace_path() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; ensure_default_team_assistant(&mut app, &services, &token, &csrf).await; - let missing_workspace = - std::env::temp_dir().join(format!("aionui-team-missing-{}", aionui_common::generate_short_id())); + let missing_workspace = managed_workspace_root(&services, "admin") + .await + .join(format!("aionui-team-missing-{}", aionui_common::generate_short_id())); let body = json!({ "name": "Alpha", @@ -423,6 +423,26 @@ async fn tc6c_create_team_rejects_missing_workspace_path() { ); } +#[tokio::test] +async fn tc6d_hosted_user_cannot_select_an_outside_team_workspace() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_team_assistant(&mut app, &services, &token, &csrf).await; + let outside = tempfile::tempdir().unwrap(); + let body = json!({ + "name": "Outside", + "workspace": outside.path(), + "agents": [team_agent("Lead", "lead")] + }); + + let response = app + .oneshot(json_with_token("POST", "/api/teams", body, &token, &csrf)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); +} + // TC-7: Unauthenticated returns 401 #[tokio::test] async fn tc7_unauthenticated_returns_401() { @@ -847,6 +867,41 @@ async fn aa1_add_agent_to_team() { assert!(json["data"]["conversation_id"].is_string()); } +#[tokio::test] +async fn hosted_add_agent_rejects_a_legacy_outside_team_workspace() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let data = create_team(&mut app, &services, &token, &csrf).await; + let team_id = data["id"].as_str().unwrap(); + let outside = tempfile::tempdir().unwrap(); + sqlx::query("UPDATE teams SET workspace = ? WHERE id = ?") + .bind(outside.path().to_string_lossy().as_ref()) + .bind(team_id) + .execute(services.database.pool()) + .await + .unwrap(); + let body = json!({ + "name": "Blocked Agent", + "role": "teammate", + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + }); + + let response = app + .oneshot(json_with_token( + "POST", + &format!("/api/teams/{team_id}/agents"), + body, + &token, + &csrf, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "FORBIDDEN"); +} + // AA-2: After adding, agent count increases #[tokio::test] async fn aa2_add_agent_increases_count() { diff --git a/crates/aionui-app/tests/work_dir_e2e.rs b/crates/aionui-app/tests/work_dir_e2e.rs index 2434aa5af..dbba6b3ae 100644 --- a/crates/aionui-app/tests/work_dir_e2e.rs +++ b/crates/aionui-app/tests/work_dir_e2e.rs @@ -14,6 +14,7 @@ async fn conversation_workspace_uses_work_dir() { data_dir: data_dir.path().to_path_buf(), work_dir: work_dir.path().to_path_buf(), local: true, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), ..Default::default() }; let services = AppServices::from_config(db, &config).await.unwrap(); @@ -52,6 +53,7 @@ async fn user_specified_workspace_is_not_overridden() { data_dir: data_dir.path().to_path_buf(), work_dir: work_dir.path().to_path_buf(), local: true, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), ..Default::default() }; let services = AppServices::from_config(db, &config).await.unwrap(); @@ -86,6 +88,7 @@ async fn workspace_defaults_to_data_dir_when_work_dir_equals_data_dir() { data_dir: data_dir.path().to_path_buf(), work_dir: data_dir.path().to_path_buf(), local: true, + local_client_secret: Some("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678".to_string()), ..Default::default() }; let services = AppServices::from_config(db, &config).await.unwrap(); diff --git a/crates/aionui-assistant/src/lib.rs b/crates/aionui-assistant/src/lib.rs index 7b84d6fe3..770bb5439 100644 --- a/crates/aionui-assistant/src/lib.rs +++ b/crates/aionui-assistant/src/lib.rs @@ -17,4 +17,4 @@ pub use agent_catalog::AssistantAgentCatalogPort; pub use builtin::{AvatarAsset, BuiltinAssistant, BuiltinAssistantRegistry}; pub use error::AssistantError; pub use routes::{AssistantRouterState, assistant_routes}; -pub use service::AssistantService; +pub use service::{AssistantAvatarPolicy, AssistantService}; diff --git a/crates/aionui-assistant/src/routes.rs b/crates/aionui-assistant/src/routes.rs index e151ca446..7cecc069d 100644 --- a/crates/aionui-assistant/src/routes.rs +++ b/crates/aionui-assistant/src/routes.rs @@ -16,8 +16,10 @@ use aionui_api_types::{ }; use aionui_auth::CurrentUser; use aionui_common::ApiError; +use aionui_db::SiteRole; use crate::error::AssistantError; +use crate::service::AssistantAvatarPolicy; pub use crate::state::AssistantRouterState; /// Build the router for `/api/assistants/*`. @@ -66,7 +68,14 @@ async fn create( body: Result, JsonRejection>, ) -> Result<(StatusCode, Json>), ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let created = state.service.create_for_user(¤t_user.id, req).await?; + let created = state + .service + .create_for_user_with_avatar_policy( + ¤t_user.id, + req, + avatar_policy(state.require_host_admin, ¤t_user), + ) + .await?; Ok((StatusCode::CREATED, Json(ApiResponse::ok(created)))) } @@ -90,7 +99,15 @@ async fn update( body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let updated = state.service.update_for_user(¤t_user.id, &id, req).await?; + let updated = state + .service + .update_for_user_with_avatar_policy( + ¤t_user.id, + &id, + req, + avatar_policy(state.require_host_admin, ¤t_user), + ) + .await?; Ok(Json(ApiResponse::ok(updated))) } @@ -120,10 +137,25 @@ async fn import( body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let result = state.service.import_for_user(¤t_user.id, req).await?; + let result = state + .service + .import_for_user_with_avatar_policy( + ¤t_user.id, + req, + avatar_policy(state.require_host_admin, ¤t_user), + ) + .await?; Ok(Json(ApiResponse::ok(result))) } +fn avatar_policy(require_host_admin: bool, user: &CurrentUser) -> AssistantAvatarPolicy { + if !require_host_admin || user.site_role == SiteRole::Admin { + AssistantAvatarPolicy::AllowHostPaths + } else { + AssistantAvatarPolicy::ManagedOnly + } +} + /// Serve the raw avatar bytes for an assistant. Content-Type inferred from the /// file extension (png/jpg/svg default). Extensions return 404 — the frontend /// serves those via `aion-asset://`. @@ -159,3 +191,47 @@ fn content_type_for_extension(ext: Option<&str>) -> HeaderValue { }; HeaderValue::from_static(mime) } + +#[cfg(test)] +mod tests { + use aionui_db::{SiteRole, UserStatus, UserType}; + + use super::*; + + fn current_user(user_type: UserType, site_role: SiteRole) -> CurrentUser { + CurrentUser { + id: "user-1".into(), + username: "user-1".into(), + user_type, + status: UserStatus::Active, + site_role, + must_change_password: false, + } + } + + #[test] + fn hosted_members_are_managed_only_in_webui_and_aionpro() { + for user_type in [UserType::Local, UserType::Aionpro] { + assert_eq!( + avatar_policy(true, ¤t_user(user_type, SiteRole::Member)), + AssistantAvatarPolicy::ManagedOnly, + ); + } + } + + #[test] + fn hosted_admins_and_local_mode_retain_legacy_avatar_paths() { + assert_eq!( + avatar_policy(true, ¤t_user(UserType::Local, SiteRole::Admin)), + AssistantAvatarPolicy::AllowHostPaths, + ); + assert_eq!( + avatar_policy(true, ¤t_user(UserType::Aionpro, SiteRole::Admin)), + AssistantAvatarPolicy::AllowHostPaths, + ); + assert_eq!( + avatar_policy(false, ¤t_user(UserType::Local, SiteRole::Member)), + AssistantAvatarPolicy::AllowHostPaths, + ); + } +} diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 96e67bed3..7133a4f34 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -37,6 +37,15 @@ const BOOTSTRAP_RETRY_MAX_ATTEMPTS: u32 = 5; const BOOTSTRAP_RETRY_BACKOFF_MS: [u64; 4] = [50, 100, 200, 400]; const DEFAULT_USER_ID: &str = "system_default_user"; +/// Controls whether a caller may import an assistant avatar from an arbitrary +/// host filesystem path. Hosted members use [`Self::ManagedOnly`]; +/// local mode and trusted operators retain the legacy file-picker workflow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssistantAvatarPolicy { + AllowHostPaths, + ManagedOnly, +} + /// Whether an assistant error is transient SQLite busy/locked contention. Repos /// convert `DbError` into `AssistantError::Internal(other.to_string())`, so the /// service can only classify by text — reusing the same markers as @@ -1061,6 +1070,16 @@ impl AssistantService { &self, user_id: &str, req: CreateAssistantRequest, + ) -> Result { + self.create_for_user_with_avatar_policy(user_id, req, AssistantAvatarPolicy::AllowHostPaths) + .await + } + + pub async fn create_for_user_with_avatar_policy( + &self, + user_id: &str, + req: CreateAssistantRequest, + avatar_policy: AssistantAvatarPolicy, ) -> Result { let name = req.name.trim().to_string(); if name.is_empty() { @@ -1091,7 +1110,7 @@ impl AssistantService { }; self.resolve_runtime_backend_for_agent_id(user_id, &resolved_agent_id) .await?; - let avatar = self.normalize_user_avatar_input(user_id, &id, req.avatar.as_deref())?; + let avatar = self.normalize_user_avatar_input(user_id, &id, req.avatar.as_deref(), avatar_policy)?; let params = CreateAssistantParams { id: &id, name: &name, @@ -1132,6 +1151,17 @@ impl AssistantService { user_id: &str, id: &str, req: UpdateAssistantRequest, + ) -> Result { + self.update_for_user_with_avatar_policy(user_id, id, req, AssistantAvatarPolicy::AllowHostPaths) + .await + } + + pub async fn update_for_user_with_avatar_policy( + &self, + user_id: &str, + id: &str, + req: UpdateAssistantRequest, + avatar_policy: AssistantAvatarPolicy, ) -> Result { match self.classify_source_for_user(user_id, id).await { AssistantSource::Builtin => { @@ -1305,7 +1335,7 @@ impl AssistantService { .as_deref() .is_some_and(|agent_id| agent_id != current_definition.agent_id); let normalized_avatar = if req.avatar.is_some() { - Some(self.normalize_user_avatar_input(user_id, id, req.avatar.as_deref())?) + Some(self.normalize_user_avatar_input(user_id, id, req.avatar.as_deref(), avatar_policy)?) } else { None }; @@ -1642,6 +1672,16 @@ impl AssistantService { &self, user_id: &str, req: ImportAssistantsRequest, + ) -> Result { + self.import_for_user_with_avatar_policy(user_id, req, AssistantAvatarPolicy::AllowHostPaths) + .await + } + + pub async fn import_for_user_with_avatar_policy( + &self, + user_id: &str, + req: ImportAssistantsRequest, + avatar_policy: AssistantAvatarPolicy, ) -> Result { let mut result = ImportAssistantsResult::default(); @@ -1741,7 +1781,7 @@ impl AssistantService { continue; } - let avatar = match self.normalize_user_avatar_input(user_id, &id, entry.avatar.as_deref()) { + let avatar = match self.normalize_user_avatar_input(user_id, &id, entry.avatar.as_deref(), avatar_policy) { Ok(value) => value, Err(e) => { result.failed += 1; @@ -2025,7 +2065,7 @@ impl AssistantService { .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - && let Some(asset) = self.read_user_avatar_asset_by_filename(user_id, value) + && let Some(asset) = self.read_user_avatar_asset_by_filename(user_id, id, value) { return Some(asset); } @@ -2044,8 +2084,9 @@ impl AssistantService { } fn user_rules_dir_for_user(&self, user_id: &str) -> PathBuf { - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); - self.user_rules_root_dir().join("users").join(dir) + self.user_rules_root_dir() + .join("users") + .join(aionui_common::user_dir_name_or_fingerprint(user_id)) } fn user_skills_root_dir(&self) -> PathBuf { @@ -2053,8 +2094,9 @@ impl AssistantService { } fn user_skills_dir_for_user(&self, user_id: &str) -> PathBuf { - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); - self.user_skills_root_dir().join("users").join(dir) + self.user_skills_root_dir() + .join("users") + .join(aionui_common::user_dir_name_or_fingerprint(user_id)) } fn user_avatars_dir(&self) -> PathBuf { @@ -2062,8 +2104,9 @@ impl AssistantService { } fn user_avatars_dir_for_user(&self, user_id: &str) -> PathBuf { - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); - self.user_avatars_dir().join("users").join(dir) + self.user_avatars_dir() + .join("users") + .join(aionui_common::user_dir_name_or_fingerprint(user_id)) } fn normalize_legacy_user_avatar_input( @@ -2166,18 +2209,23 @@ impl AssistantService { user_id: &str, id: &str, avatar: Option<&str>, + policy: AssistantAvatarPolicy, ) -> Result, AssistantError> { let Some(value) = avatar.map(str::trim).filter(|value| !value.is_empty()) else { remove_assistant_avatar_files(&self.user_avatars_dir_for_user(user_id), id); return Ok(None); }; - if !looks_like_avatar_asset(value) { + if !looks_like_avatar_asset(value) && !is_unsupported_direct_avatar_reference(value) { remove_assistant_avatar_files(&self.user_avatars_dir_for_user(user_id), id); return Ok(Some(value.to_string())); } - if let Some(source_assistant_id) = parse_assistant_avatar_route(value) { + let source_assistant_id = match policy { + AssistantAvatarPolicy::AllowHostPaths => parse_assistant_avatar_route(value), + AssistantAvatarPolicy::ManagedOnly => parse_exact_managed_assistant_avatar_route(value), + }; + if let Some(source_assistant_id) = source_assistant_id { if let Some(existing_avatar_path) = self.find_existing_user_avatar_file(user_id, &source_assistant_id) { if source_assistant_id == id { return managed_user_avatar_value_from_path(&existing_avatar_path).map(Some); @@ -2191,9 +2239,26 @@ impl AssistantService { .persist_user_avatar_bytes(user_id, id, &builtin_avatar.bytes, builtin_avatar.extension.as_deref()) .map(Some); } + if policy == AssistantAvatarPolicy::ManagedOnly { + return Err(AssistantError::BadRequest( + "assistant avatar route must reference an owned or built-in avatar".into(), + )); + } return Ok(Some(value.to_string())); } + if policy == AssistantAvatarPolicy::ManagedOnly && is_unsupported_direct_avatar_reference(value) { + return Err(AssistantError::BadRequest( + "assistant avatar must be inline text or an owned or built-in avatar route".into(), + )); + } + + if policy == AssistantAvatarPolicy::ManagedOnly && looks_like_host_avatar_reference(value) { + return Err(AssistantError::BadRequest( + "host filesystem avatar paths are available only to site administrators".into(), + )); + } + if is_unsupported_direct_avatar_reference(value) { remove_assistant_avatar_files(&self.user_avatars_dir_for_user(user_id), id); return Err(AssistantError::BadRequest( @@ -2225,7 +2290,7 @@ impl AssistantService { let destination_dir = self.user_avatars_dir_for_user(user_id); std::fs::create_dir_all(&destination_dir) .map_err(|e| AssistantError::Internal(format!("create assistant avatar directory: {e}")))?; - let destination = destination_dir.join(format!("{id}.{extension}")); + let destination = destination_dir.join(assistant_avatar_filename(id, &extension)); if paths_refer_to_same_file(source_path, &destination) { return managed_user_avatar_value_from_path(&destination); } @@ -2264,7 +2329,7 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("create assistant avatar directory: {e}")))?; remove_assistant_avatar_files(&destination_dir, id); - let destination = destination_dir.join(format!("{id}.{extension}")); + let destination = destination_dir.join(assistant_avatar_filename(id, &extension)); std::fs::write(&destination, bytes).map_err(|e| { AssistantError::Internal(format!("write assistant avatar to '{}': {e}", destination.display())) })?; @@ -2274,22 +2339,33 @@ impl AssistantService { fn find_existing_user_avatar_file(&self, user_id: &str, id: &str) -> Option { let entries = std::fs::read_dir(self.user_avatars_dir_for_user(user_id)).ok()?; + let encoded_id = encode_filename_component(id); + let mut legacy_match = None; for entry in entries.flatten() { let path = entry.path(); + if !entry.file_type().is_ok_and(|file_type| file_type.is_file()) { + continue; + } + let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else { + continue; + }; + if !is_supported_avatar_extension(&extension.to_ascii_lowercase()) { + continue; + } let file_stem = path.file_stem().and_then(|stem| stem.to_str()); - if file_stem == Some(id) { + if file_stem == Some(encoded_id.as_str()) { return Some(path); } + if legacy_filename_component_is_safe(id) && file_stem == Some(id) { + legacy_match = Some(path); + } } - None + legacy_match } - fn read_user_avatar_asset_by_filename(&self, user_id: &str, value: &str) -> Option { - let value = value.trim(); - if value.is_empty() || value.contains('/') || value.contains('\\') { - return None; - } - read_user_avatar_asset_from_path(&self.user_avatars_dir_for_user(user_id).join(value)) + fn read_user_avatar_asset_by_filename(&self, user_id: &str, id: &str, value: &str) -> Option { + let filename = validated_assistant_avatar_filename(id, value)?; + read_user_avatar_asset_from_path(&self.user_avatars_dir_for_user(user_id).join(filename)) } fn user_asset_avatar_value_is_renderable(&self, user_id: &str, definition: &AssistantDefinitionRow) -> bool { @@ -2304,11 +2380,8 @@ impl AssistantService { if is_local_avatar_value(value) || value.contains('/') || value.contains('\\') { return false; } - let path = Path::new(value); - if path.file_stem().and_then(|stem| stem.to_str()) != Some(definition.assistant_id.as_str()) { - return false; - } - self.read_user_avatar_asset_by_filename(user_id, value).is_some() + self.read_user_avatar_asset_by_filename(user_id, &definition.assistant_id, value) + .is_some() } fn user_rule_path_for_user(&self, user_id: &str, id: &str, locale: Option<&str>) -> PathBuf { @@ -2674,7 +2747,19 @@ fn serialize_avatar(source: &str, avatar: Option<&str>) -> (String, Option bool { - value.contains('/') || (std::path::Path::new(value).extension().is_some() && !value.starts_with('.')) + value.contains('/') + || value.contains('\\') + || has_windows_drive_prefix(value) + || (std::path::Path::new(value).extension().is_some() && !value.starts_with('.')) +} + +fn looks_like_host_avatar_reference(value: &str) -> bool { + looks_like_avatar_asset(value) || value.to_ascii_lowercase().starts_with("file:") +} + +fn has_windows_drive_prefix(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } fn managed_user_avatar_value_from_path(path: &Path) -> Result { @@ -2686,6 +2771,9 @@ fn managed_user_avatar_value_from_path(path: &Path) -> Result Option { + if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() { + return None; + } let bytes = std::fs::read(path).ok()?; let extension = path .extension() @@ -2729,6 +2817,26 @@ fn parse_assistant_avatar_route(value: &str) -> Option { (!id.is_empty()).then(|| id.to_string()) } +fn parse_exact_managed_assistant_avatar_route(value: &str) -> Option { + let (path, query) = match value.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (value, None), + }; + if query.is_some_and(|query| { + !query + .strip_prefix("v=") + .is_some_and(|version| !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit())) + }) { + return None; + } + + let id = path.strip_prefix("/api/assistants/")?.strip_suffix("/avatar")?; + if id.is_empty() || id.contains('/') || id.contains('\\') { + return None; + } + Some(id.to_string()) +} + #[derive(Debug, Clone)] struct AssistantRuntimeProjection { agent_id: String, @@ -3219,6 +3327,33 @@ fn encode_filename_component(value: &str) -> String { encoded } +fn assistant_avatar_filename(id: &str, extension: &str) -> String { + format!("{}.{extension}", encode_filename_component(id)) +} + +fn validated_assistant_avatar_filename(id: &str, value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.contains('/') || value.contains('\\') { + return None; + } + let extension = Path::new(value).extension()?.to_str()?; + if !is_supported_avatar_extension(&extension.to_ascii_lowercase()) { + return None; + } + + let encoded = assistant_avatar_filename(id, extension); + if value == encoded { + return Some(encoded); + } + if legacy_filename_component_is_safe(id) { + let legacy = format!("{id}.{extension}"); + if value == legacy { + return Some(legacy); + } + } + None +} + fn read_file_or_empty(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } @@ -3356,17 +3491,25 @@ fn remove_assistant_avatar_files(dir: &Path, id: &str) -> bool { return false; }; let mut deleted = false; - let prefix = format!("{id}."); + let encoded_id = encode_filename_component(id); + let allow_legacy = legacy_filename_component_is_safe(id); for entry in entries.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with(&prefix) { - if let Err(e) = std::fs::remove_file(entry.path()) { - warn!("failed to remove {}: {e}", entry.path().display()); - continue; - } - deleted = true; + let path = entry.path(); + let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else { + continue; + }; + if !is_supported_avatar_extension(&extension.to_ascii_lowercase()) { + continue; + } + let stem = path.file_stem().and_then(|stem| stem.to_str()); + if stem != Some(encoded_id.as_str()) && !(allow_legacy && stem == Some(id)) { + continue; + } + if let Err(e) = std::fs::remove_file(&path) { + warn!("failed to remove {}: {e}", path.display()); + continue; } + deleted = true; } deleted } @@ -3454,6 +3597,23 @@ mod tests { ); } + #[tokio::test] + async fn unsafe_user_ids_cannot_escape_managed_roots() { + let svc = test_service_with_data_dir(std::path::Path::new("/data")).await; + for path in [ + svc.user_rules_dir_for_user("../other"), + svc.user_skills_dir_for_user("../other"), + svc.user_avatars_dir_for_user("../other"), + ] { + assert!(path.starts_with("/data")); + assert!(!path.to_string_lossy().contains("../other")); + assert!( + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("invalid-")) + ); + } + } + #[tokio::test] async fn retry_step_succeeds_after_transient_busy() { let calls = AtomicUsize::new(0); @@ -5291,6 +5451,329 @@ mod tests { } } + #[tokio::test] + async fn managed_only_create_rejects_host_path_forms_but_allows_inline_text() { + let fx = fixture().await; + let source_avatar = fx._tmp.path().join("member-source.png"); + std::fs::write(&source_avatar, b"host-secret").unwrap(); + let file_uri = format!("file://{}", source_avatar.display()); + let absolute = source_avatar.to_string_lossy().into_owned(); + + for (index, avatar) in [ + absolute.as_str(), + file_uri.as_str(), + "relative/avatar.png", + "..\\relative\\avatar.png", + "C:\\Users\\member\\avatar.png", + ] + .into_iter() + .enumerate() + { + let id = format!("managed-path-{index}"); + let error = fx + .service + .create_for_user_with_avatar_policy( + DEFAULT_USER_ID, + CreateAssistantRequest { + id: Some(id.clone()), + name: "Managed member".into(), + avatar: Some(avatar.into()), + ..req_default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap_err(); + assert!( + matches!(error, AssistantError::BadRequest(_)), + "{avatar} must be rejected" + ); + assert!(fx.repo.get_for_user(DEFAULT_USER_ID, &id).await.unwrap().is_none()); + } + + let created = fx + .service + .create_for_user_with_avatar_policy( + DEFAULT_USER_ID, + CreateAssistantRequest { + id: Some("managed-inline".into()), + name: "Managed inline".into(), + avatar: Some("🤖".into()), + ..req_default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap(); + assert_eq!(created.avatar.as_deref(), Some("🤖")); + assert_eq!(std::fs::read(&source_avatar).unwrap(), b"host-secret"); + } + + #[tokio::test] + async fn managed_only_accepts_owned_and_builtin_routes_but_rejects_foreign_routes() { + let fx = fixture_with_builtins(vec![mk_builtin_with_avatar( + "builtin-avatar-source", + "Builtin Avatar Source", + "avatars/builtin-source.png", + )]) + .await; + let source_avatar = fx._tmp.path().join("owned-source.png"); + std::fs::write(&source_avatar, b"owned-avatar").unwrap(); + fx.service + .create(CreateAssistantRequest { + id: Some("owned-source".into()), + name: "Owned source".into(), + avatar: Some(source_avatar.to_string_lossy().into_owned()), + ..req_default() + }) + .await + .unwrap(); + + for (id, route) in [ + ("owned-copy", "/api/assistants/owned-source/avatar"), + ("owned-versioned-copy", "/api/assistants/owned-source/avatar?v=123"), + ("builtin-copy", "/api/assistants/builtin-avatar-source/avatar"), + ] { + fx.service + .create_for_user_with_avatar_policy( + DEFAULT_USER_ID, + CreateAssistantRequest { + id: Some(id.into()), + name: id.into(), + avatar: Some(route.into()), + ..req_default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap(); + } + + for (id, route) in [ + ( + "absolute-route-copy", + "https://example.com/api/assistants/builtin-avatar-source/avatar", + ), + ("file-route-copy", "file:///api/assistants/builtin-avatar-source/avatar"), + ] { + let error = fx + .service + .create_for_user_with_avatar_policy( + DEFAULT_USER_ID, + CreateAssistantRequest { + id: Some(id.into()), + name: id.into(), + avatar: Some(route.into()), + ..req_default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap_err(); + assert!(matches!(error, AssistantError::BadRequest(_))); + } + + let error = fx + .service + .create_for_user_with_avatar_policy( + DEFAULT_USER_ID, + CreateAssistantRequest { + id: Some("foreign-copy".into()), + name: "Foreign copy".into(), + avatar: Some("/api/assistants/other-users-avatar/avatar".into()), + ..req_default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap_err(); + assert!(matches!(error, AssistantError::BadRequest(_))); + } + + #[tokio::test] + async fn managed_only_update_and_import_reject_paths_without_removing_existing_avatar() { + let fx = fixture().await; + fx.service + .create(CreateAssistantRequest { + id: Some("managed-update".into()), + name: "Managed update".into(), + avatar: Some("🙂".into()), + ..req_default() + }) + .await + .unwrap(); + let source_avatar = fx._tmp.path().join("update-source.jpg"); + std::fs::write(&source_avatar, b"update-secret").unwrap(); + + let update_error = fx + .service + .update_for_user_with_avatar_policy( + DEFAULT_USER_ID, + "managed-update", + UpdateAssistantRequest { + avatar: Some(source_avatar.to_string_lossy().into_owned()), + ..Default::default() + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap_err(); + assert!(matches!(update_error, AssistantError::BadRequest(_))); + assert_eq!( + fx.service.get("managed-update").await.unwrap().avatar.as_deref(), + Some("🙂"), + ); + + let result = fx + .service + .import_for_user_with_avatar_policy( + DEFAULT_USER_ID, + ImportAssistantsRequest { + assistants: vec![ + CreateAssistantRequest { + id: Some("managed-import-path".into()), + name: "Path import".into(), + avatar: Some(source_avatar.to_string_lossy().into_owned()), + ..req_default() + }, + CreateAssistantRequest { + id: Some("managed-import-inline".into()), + name: "Inline import".into(), + avatar: Some("✨".into()), + ..req_default() + }, + ], + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap(); + assert_eq!(result.imported, 1); + assert_eq!(result.failed, 1); + assert!( + fx.repo + .get_for_user(DEFAULT_USER_ID, "managed-import-path") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn encoded_avatar_destinations_cannot_escape_for_file_or_byte_copies() { + let fx = fixture_with_builtins(vec![mk_builtin_with_avatar( + "builtin-avatar-source", + "Builtin Avatar Source", + "avatars/builtin-source.png", + )]) + .await; + let source_avatar = fx._tmp.path().join("traversal-source.png"); + std::fs::write(&source_avatar, b"copied-from-path").unwrap(); + let avatar_root = fx._tmp.path().join("assistant-avatars"); + std::fs::create_dir_all(&avatar_root).unwrap(); + let path_escape = avatar_root.join("outside-path.png"); + let byte_escape = avatar_root.join("outside-bytes.png"); + let import_escape = avatar_root.join("outside-import.png"); + std::fs::write(&path_escape, b"path-sentinel").unwrap(); + std::fs::write(&byte_escape, b"bytes-sentinel").unwrap(); + std::fs::write(&import_escape, b"import-sentinel").unwrap(); + + let path_value = fx + .service + .persist_user_avatar_file(DEFAULT_USER_ID, "../../outside-path", &source_avatar) + .unwrap(); + let byte_value = fx + .service + .persist_user_avatar_bytes( + DEFAULT_USER_ID, + "../../outside-bytes", + b"copied-from-bytes", + Some("png"), + ) + .unwrap(); + + assert_eq!(path_value, "%2E%2E%2F%2E%2E%2Foutside-path.png"); + assert_eq!(byte_value, "%2E%2E%2F%2E%2E%2Foutside-bytes.png"); + assert_eq!(std::fs::read(&path_escape).unwrap(), b"path-sentinel"); + assert_eq!(std::fs::read(&byte_escape).unwrap(), b"bytes-sentinel"); + let managed_dir = fx._tmp.path().join("assistant-avatars/users/system_default_user"); + assert_eq!( + std::fs::read(managed_dir.join(path_value)).unwrap(), + b"copied-from-path" + ); + assert_eq!( + std::fs::read(managed_dir.join(byte_value)).unwrap(), + b"copied-from-bytes" + ); + + let import = fx + .service + .import_for_user_with_avatar_policy( + DEFAULT_USER_ID, + ImportAssistantsRequest { + assistants: vec![CreateAssistantRequest { + id: Some("../../outside-import".into()), + name: "Traversal import".into(), + avatar: Some("/api/assistants/builtin-avatar-source/avatar".into()), + ..req_default() + }], + }, + AssistantAvatarPolicy::ManagedOnly, + ) + .await + .unwrap(); + assert_eq!(import.imported, 1); + assert_eq!(std::fs::read(&import_escape).unwrap(), b"import-sentinel"); + assert_eq!( + std::fs::read(managed_dir.join("%2E%2E%2F%2E%2E%2Foutside-import.png")).unwrap(), + b"builtin-avatar-bytes", + ); + + assert_eq!( + fx.service + .find_existing_user_avatar_file(DEFAULT_USER_ID, "../../outside-path"), + Some(managed_dir.join("%2E%2E%2F%2E%2E%2Foutside-path.png")), + ); + assert!(remove_assistant_avatar_files(&managed_dir, "../../outside-path")); + assert_eq!(std::fs::read(&path_escape).unwrap(), b"path-sentinel"); + } + + #[test] + fn avatar_cleanup_does_not_delete_a_longer_legacy_assistant_id() { + let tmp = TempDir::new().unwrap(); + let avatar_dir = tmp.path(); + std::fs::write(avatar_dir.join("foo.png"), b"foo").unwrap(); + std::fs::write(avatar_dir.join("foo.bar.png"), b"foo-bar").unwrap(); + + assert!(remove_assistant_avatar_files(avatar_dir, "foo")); + assert!(!avatar_dir.join("foo.png").exists()); + assert_eq!(std::fs::read(avatar_dir.join("foo.bar.png")).unwrap(), b"foo-bar"); + } + + #[cfg(unix)] + #[tokio::test] + async fn managed_avatar_lookup_and_read_ignore_symlinks() { + use std::os::unix::fs::symlink; + + let fx = fixture().await; + let outside = fx._tmp.path().join("outside.png"); + std::fs::write(&outside, b"outside-secret").unwrap(); + let avatar_dir = fx._tmp.path().join("assistant-avatars/users/system_default_user"); + std::fs::create_dir_all(&avatar_dir).unwrap(); + symlink(&outside, avatar_dir.join("symlink-avatar.png")).unwrap(); + + assert!( + fx.service + .find_existing_user_avatar_file(DEFAULT_USER_ID, "symlink-avatar") + .is_none() + ); + assert!( + fx.service + .read_user_avatar_asset_by_filename(DEFAULT_USER_ID, "symlink-avatar", "symlink-avatar.png") + .is_none() + ); + } + #[tokio::test] async fn update_user_accepts_absolute_backend_builtin_avatar_route() { let fx = fixture_with_builtins(vec![mk_builtin_with_avatar( diff --git a/crates/aionui-assistant/src/state.rs b/crates/aionui-assistant/src/state.rs index c56eb1393..7e71fa590 100644 --- a/crates/aionui-assistant/src/state.rs +++ b/crates/aionui-assistant/src/state.rs @@ -8,4 +8,7 @@ use crate::service::AssistantService; #[derive(Clone)] pub struct AssistantRouterState { pub service: Arc, + /// In hosted identity modes, only a live site administrator may materialize + /// avatar bytes from an arbitrary path on the server host. + pub require_host_admin: bool, } diff --git a/crates/aionui-auth/Cargo.toml b/crates/aionui-auth/Cargo.toml index ec788e1c8..3bce98c0a 100644 --- a/crates/aionui-auth/Cargo.toml +++ b/crates/aionui-auth/Cargo.toml @@ -15,6 +15,7 @@ tower-http.workspace = true dashmap.workspace = true axum.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true @@ -23,6 +24,6 @@ base64.workspace = true sha2.workspace = true [dev-dependencies] -serde_json.workspace = true http-body-util.workspace = true +tempfile.workspace = true tower = { workspace = true, features = ["util"] } diff --git a/crates/aionui-auth/src/admin_service.rs b/crates/aionui-auth/src/admin_service.rs new file mode 100644 index 000000000..3c0b379d7 --- /dev/null +++ b/crates/aionui-auth/src/admin_service.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use aionui_api_types::{ + AccountStatus, AdminAuditEntry, AdminAuditListResponse, AdminUser, AdminUserListResponse, AdminUserType, + TemporaryPasswordResponse, UserRole, +}; +use aionui_db::{ + AdminUserRepositoryError, AuditActor, IAdminUserRepository, SiteRole, UserStatus, UserType, models::User, +}; + +use crate::{AuthError, generate_password, hash_password, validate_username}; + +const TEMPORARY_PASSWORD_LENGTH: usize = 20; + +#[derive(Debug, thiserror::Error)] +pub enum AdminUserServiceError { + #[error("validation error: {0}")] + Validation(#[from] AuthError), + #[error("repository error: {0}")] + Repository(#[from] AdminUserRepositoryError), + #[error("database error: {0}")] + Database(#[from] aionui_db::DbError), + #[error("password hashing task failed: {0}")] + HashTask(String), +} + +#[derive(Clone)] +pub struct AdminUserService { + repo: Arc, +} + +impl AdminUserService { + pub fn new(repo: Arc) -> Self { + Self { repo } + } + + pub async fn list_users(&self, limit: u32, offset: u32) -> Result { + let users = self + .repo + .list_managed_users(i64::from(limit), i64::from(offset)) + .await?; + let total = self.repo.count_managed_users().await?; + Ok(AdminUserListResponse { + items: users.into_iter().map(admin_user).collect(), + total: total.try_into().unwrap_or(0), + }) + } + + pub async fn create_user( + &self, + username: &str, + role: UserRole, + actor: &AuditActor, + ) -> Result { + let username = validated_username(username)?; + let temporary_password = generate_password(TEMPORARY_PASSWORD_LENGTH); + let password_hash = hash_on_blocking_pool(temporary_password.clone()).await?; + let user = self + .repo + .create_managed_user(&username, &password_hash, db_role(role), actor) + .await?; + Ok(TemporaryPasswordResponse { + user: admin_user(user), + temporary_password, + }) + } + + pub async fn update_username( + &self, + user_id: &str, + username: &str, + actor: &AuditActor, + ) -> Result { + let username = validated_username(username)?; + Ok(admin_user( + self.repo.update_managed_username(user_id, &username, actor).await?, + )) + } + + pub async fn update_role( + &self, + user_id: &str, + role: UserRole, + actor: &AuditActor, + ) -> Result { + Ok(admin_user( + self.repo.update_managed_role(user_id, db_role(role), actor).await?, + )) + } + + pub async fn update_status( + &self, + user_id: &str, + status: AccountStatus, + actor: &AuditActor, + ) -> Result { + Ok(admin_user( + self.repo + .update_managed_status(user_id, db_status(status), actor) + .await?, + )) + } + + pub async fn reset_password( + &self, + user_id: &str, + actor: &AuditActor, + ) -> Result { + let temporary_password = generate_password(TEMPORARY_PASSWORD_LENGTH); + let password_hash = hash_on_blocking_pool(temporary_password.clone()).await?; + let user = self.repo.reset_managed_password(user_id, &password_hash, actor).await?; + Ok(TemporaryPasswordResponse { + user: admin_user(user), + temporary_password, + }) + } + + pub async fn revoke_sessions(&self, user_id: &str, actor: &AuditActor) -> Result { + Ok(admin_user(self.repo.revoke_managed_sessions(user_id, actor).await?)) + } + + pub async fn list_audit( + &self, + cursor: Option<&str>, + limit: u32, + ) -> Result { + let mut records = self.repo.list_admin_audit(cursor, i64::from(limit) + 1).await?; + let next_cursor = if records.len() > limit as usize { + records.pop(); + records.last().map(|record| record.id.clone()) + } else { + None + }; + Ok(AdminAuditListResponse { + items: records + .into_iter() + .map(|record| AdminAuditEntry { + id: record.id, + occurred_at: record.occurred_at, + actor_user_id: record.actor_user_id, + actor_username: record.actor_username, + action: record.action, + target_user_id: record.target_user_id, + target_username: record.target_username, + details: serde_json::from_str(&record.details).unwrap_or_else(|_| serde_json::json!({})), + }) + .collect(), + next_cursor, + }) + } +} + +pub fn audit_actor(user_id: &str, username: &str) -> AuditActor { + AuditActor { + user_id: Some(user_id.to_owned()), + username: Some(username.to_owned()), + } +} + +pub fn admin_user(user: User) -> AdminUser { + AdminUser { + id: user.id, + username: user.username.unwrap_or_else(|| "local_user".to_string()), + user_type: match user.user_type { + UserType::Local => AdminUserType::Local, + UserType::Aionpro => AdminUserType::Aionpro, + }, + role: match user.site_role { + SiteRole::Admin => UserRole::Admin, + SiteRole::Member => UserRole::Member, + }, + status: match user.status { + UserStatus::Active => AccountStatus::Active, + UserStatus::Disabled => AccountStatus::Disabled, + }, + must_change_password: user.must_change_password, + created_at: user.created_at, + updated_at: user.updated_at, + last_login: user.last_login, + } +} + +fn validated_username(username: &str) -> Result { + let username = username.trim().to_owned(); + validate_username(&username)?; + Ok(username) +} + +fn db_role(role: UserRole) -> SiteRole { + match role { + UserRole::Admin => SiteRole::Admin, + UserRole::Member => SiteRole::Member, + } +} + +fn db_status(status: AccountStatus) -> UserStatus { + match status { + AccountStatus::Active => UserStatus::Active, + AccountStatus::Disabled => UserStatus::Disabled, + } +} + +async fn hash_on_blocking_pool(password: String) -> Result { + tokio::task::spawn_blocking(move || hash_password(&password)) + .await + .map_err(|error| AdminUserServiceError::HashTask(error.to_string()))? + .map_err(AdminUserServiceError::Validation) +} diff --git a/crates/aionui-auth/src/jwt.rs b/crates/aionui-auth/src/jwt.rs index 4ab821571..c3e731262 100644 --- a/crates/aionui-auth/src/jwt.rs +++ b/crates/aionui-auth/src/jwt.rs @@ -12,6 +12,7 @@ use crate::error::AuthError; /// JWT token lifetime: 24 hours. const TOKEN_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60); +pub const TOKEN_EXPIRY_MS: i64 = 24 * 60 * 60 * 1000; /// JWT issuer claim value. const JWT_ISSUER: &str = "aionui"; @@ -37,6 +38,9 @@ pub struct TokenPayload { /// User session generation at token issuance time. #[serde(default)] pub session_generation: i64, + /// Persistent server-side session ID. Legacy upgrade tokens omit it. + #[serde(default)] + pub session_id: Option, } /// JWT service for signing, verification, and token blacklisting. @@ -71,6 +75,26 @@ impl JwtService { user_id: &str, username: &str, session_generation: i64, + ) -> Result { + self.sign_with_persistent_session(user_id, username, session_generation, None) + } + + pub fn sign_with_session_id( + &self, + user_id: &str, + username: &str, + session_generation: i64, + session_id: &str, + ) -> Result { + self.sign_with_persistent_session(user_id, username, session_generation, Some(session_id)) + } + + fn sign_with_persistent_session( + &self, + user_id: &str, + username: &str, + session_generation: i64, + session_id: Option<&str>, ) -> Result { let now = now_secs()?; let exp = now + TOKEN_EXPIRY.as_secs(); @@ -83,6 +107,7 @@ impl JwtService { iss: JWT_ISSUER.to_owned(), aud: JWT_AUDIENCE.to_owned(), session_generation, + session_id: session_id.map(str::to_owned), }; let secret = self @@ -286,6 +311,7 @@ mod tests { iss: JWT_ISSUER.into(), aud: JWT_AUDIENCE.into(), session_generation: 0, + session_id: None, }; let token = encode( &Header::default(), @@ -368,6 +394,7 @@ mod tests { iss: JWT_ISSUER.into(), aud: JWT_AUDIENCE.into(), session_generation: 0, + session_id: None, }; let token = encode( &Header::default(), diff --git a/crates/aionui-auth/src/lib.rs b/crates/aionui-auth/src/lib.rs index 045911425..7e6ba5633 100644 --- a/crates/aionui-auth/src/lib.rs +++ b/crates/aionui-auth/src/lib.rs @@ -1,6 +1,7 @@ #![warn(clippy::disallowed_types)] //! JWT authentication, password hashing, CSRF protection, rate limiting, and auth middleware. +mod admin_service; mod cookie; mod csrf; mod error; @@ -13,10 +14,13 @@ mod rate_limit; mod routes; mod security; mod service; +mod share_service; mod validation; // Error type +pub use admin_service::{AdminUserService, AdminUserServiceError, admin_user, audit_actor}; pub use error::AuthError; +pub use share_service::{ShareService, ShareServiceError}; // JWT service pub use jwt::{JwtService, TokenPayload, generate_random_secret_string, resolve_jwt_secret}; @@ -53,7 +57,7 @@ pub use csrf::csrf_middleware; // Auth middleware pub use middleware::{ AuthIdentityMode, AuthState, CurrentUser, IRuntimeTokenVerifier, RUNTIME_CONVERSATION_ID_HEADER, - RUNTIME_TOKEN_HEADER, RUNTIME_USER_ID_HEADER, auth_middleware, local_auth_middleware, + RUNTIME_TOKEN_HEADER, RUNTIME_USER_ID_HEADER, admin_required_middleware, auth_middleware, local_auth_middleware, }; // QR token store diff --git a/crates/aionui-auth/src/middleware.rs b/crates/aionui-auth/src/middleware.rs index 63d57329e..89b0f0ae5 100644 --- a/crates/aionui-auth/src/middleware.rs +++ b/crates/aionui-auth/src/middleware.rs @@ -8,7 +8,7 @@ use axum::middleware::Next; use axum::response::Response; use aionui_common::ApiError; -use aionui_db::{IUserRepository, UserStatus, UserType}; +use aionui_db::{IUserRepository, SiteRole, UserStatus, UserType}; use crate::JwtService; use crate::extract::extract_token_from_headers; @@ -52,6 +52,10 @@ pub struct CurrentUser { pub user_type: UserType, /// Current account status. Authenticated requests only receive active users. pub status: UserStatus, + /// Live site-wide authorization role loaded from the database. + pub site_role: SiteRole, + /// Whether this account must replace its temporary password. + pub must_change_password: bool, } impl CurrentUser { @@ -61,6 +65,8 @@ impl CurrentUser { username: "system_default_user".to_string(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, } } } @@ -133,13 +139,36 @@ pub async fn auth_middleware( return Err(ApiError::Unauthorized("Invalid authentication session".into())); } + if let Some(session_id) = payload.session_id.as_deref() { + let active = state + .user_repo + .is_auth_session_active(session_id, &user.id) + .await + .map_err(|e| { + tracing::error!(error = %e, "persistent session lookup failed"); + ApiError::Internal("Authentication service unavailable".into()) + })?; + if !active { + return Err(ApiError::Unauthorized("Invalid authentication session".into())); + } + if let Err(error) = state.user_repo.touch_auth_session(session_id, &user.id).await { + tracing::warn!(user_id = %user.id, error = %error, "failed to update auth session activity"); + } + } + + let must_change_password = user.must_change_password; + request.extensions_mut().insert(CurrentUser { id: user.id, username: user.username.unwrap_or_else(|| "external_user".to_string()), user_type: user.user_type, status: user.status, + site_role: user.site_role, + must_change_password, }); + enforce_password_change(&request, must_change_password)?; + Ok(next.run(request).await) } @@ -192,13 +221,51 @@ async fn runtime_token_channel(state: &AuthState, mut request: Request, next: Ne )); } + let must_change_password = user.must_change_password; request.extensions_mut().insert(CurrentUser { id: user.id, username: user.username.unwrap_or_else(|| "external_user".to_string()), user_type: user.user_type, status: user.status, + site_role: user.site_role, + must_change_password, }); + enforce_password_change(&request, must_change_password)?; + + Ok(next.run(request).await) +} + +fn enforce_password_change(request: &Request, must_change_password: bool) -> Result<(), ApiError> { + if !must_change_password { + return Ok(()); + } + match request.uri().path() { + "/logout" | "/api/auth/user" | "/api/auth/change-password" => Ok(()), + _ => Err(ApiError::coded( + StatusCode::FORBIDDEN, + "PASSWORD_CHANGE_REQUIRED", + "Password change required.", + None, + )), + } +} + +/// Authorization guard for site administrator routes. It must run after +/// [`auth_middleware`] so the role is always the live database value. +pub async fn admin_required_middleware(request: Request, next: Next) -> Result { + let is_admin = request + .extensions() + .get::() + .is_some_and(|user| user.user_type == UserType::Local && user.site_role == SiteRole::Admin); + if !is_admin { + return Err(ApiError::coded( + StatusCode::FORBIDDEN, + "ADMIN_REQUIRED", + "Administrator access required.", + None, + )); + } Ok(next.run(request).await) } diff --git a/crates/aionui-auth/src/routes.rs b/crates/aionui-auth/src/routes.rs index 72e162a2b..c45463608 100644 --- a/crates/aionui-auth/src/routes.rs +++ b/crates/aionui-auth/src/routes.rs @@ -1,38 +1,47 @@ #![allow(clippy::disallowed_types)] +use std::path::{Path as FsPath, PathBuf}; use std::sync::Arc; use std::time::Duration; use axum::extract::rejection::JsonRejection; -use axum::extract::{Json, Path, State}; +use axum::extract::{Json, Path, Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::middleware::from_fn_with_state; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::{get, post, put}; +use axum::routing::{delete, get, patch, post, put}; use axum::{Extension, Router}; use serde::{Deserialize, Serialize}; use aionui_api_types::{ - ApiResponse, AuthStatusResponse, ChangePasswordRequest, EnsureExternalSessionRequest, EnsureExternalUserRequest, - EnsureExternalUserResponse, LoginRequest, LoginResponse, PublicUser, QrLoginRequest, RefreshResponse, - RefreshTokenRequest, RevokeExternalSessionRequest, RevokeExternalSessionResponse, UserInfoResponse, + AccountStatus, AdminAuditListResponse, AdminUser, AdminUserListResponse, ApiResponse, AuthStatusResponse, + ChangePasswordRequest, CreateAdminUserRequest, CreateShareRequest, EnsureExternalSessionRequest, + EnsureExternalUserRequest, EnsureExternalUserResponse, ListAdminAuditQuery, ListAdminUsersQuery, ListSharesQuery, + LoginRequest, LoginResponse, PublicUser, QrLoginRequest, RefreshResponse, RefreshTokenRequest, ResourceShare, + RevokeExternalSessionRequest, RevokeExternalSessionResponse, ShareListResponse, UpdateAdminRoleRequest, + UpdateAdminStatusRequest, UpdateAdminUsernameRequest, UserDirectoryResponse, UserInfoResponse, UserRole, WebuiChangePasswordRequest, WebuiChangeUsernameRequest, WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; use aionui_common::ApiError; use aionui_common::constants::COOKIE_MAX_AGE_DAYS; -use aionui_db::{DbError, IUserRepository, UserStatus, UserType, models::User}; +use aionui_db::{ + AdminUserRepositoryError, DbError, IAdminUserRepository, IResourceShareRepository, IUserRepository, UserStatus, + UserType, models::User, +}; use crate::error::AuthError; use crate::extract::extract_token_from_headers; -use crate::middleware::{AuthIdentityMode, AuthState, CurrentUser, auth_middleware}; +use crate::middleware::{AuthIdentityMode, AuthState, CurrentUser, admin_required_middleware, auth_middleware}; use crate::password::{dummy_password_hash, generate_password, hash_password, verify_password_timed}; use crate::qr_token::QrTokenStore; use crate::rate_limit::{ RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware, authenticated_action_rate_limit_middleware, }; use crate::service::{AuthProvisionService, ProvisionError}; +use crate::share_service::{ShareService, ShareServiceError}; use crate::validation::{validate_password, validate_username}; +use crate::{AdminUserService, AdminUserServiceError, audit_actor}; use crate::{CookieConfig, JwtService}; const BOOTSTRAP_SECRET_HEADER: &str = "x-aioncore-bootstrap-secret"; @@ -69,6 +78,8 @@ fn db_error_to_api_error(err: DbError) -> ApiError { pub struct AuthRouterState { pub jwt_service: Arc, pub user_repo: Arc, + pub admin_user_repo: Arc, + pub share_repo: Arc, /// Optional on-disk adoption side-effect (AionUi → AionPro upgrade). pub fs_adopter: Option>, pub cookie_config: Arc, @@ -76,6 +87,9 @@ pub struct AuthRouterState { pub identity_mode: AuthIdentityMode, pub bootstrap_secret: Option>, pub session_revoked_hook: Option>, + /// One-time bootstrap credential file removed after the initial admin + /// successfully changes their temporary password. + pub initial_admin_credentials_file: Option>, pub local: bool, pub aionpro_mode: bool, } @@ -249,11 +263,7 @@ pub fn auth_routes(state: AuthRouterState) -> Router { let auth_state = AuthState { jwt_service: state.jwt_service.clone(), user_repo: state.user_repo.clone(), - identity_mode: if state.aionpro_mode { - AuthIdentityMode::AionPro - } else { - AuthIdentityMode::UserSession - }, + identity_mode: state.identity_mode, // Auth endpoints manage sessions themselves; the helper CLI never // calls them, so the runtime-token channel stays disabled here. runtime_token_verifier: None, @@ -326,14 +336,52 @@ pub fn auth_routes(state: AuthRouterState) -> Router { .route("/api/auth/user", get(user_handler)) .route("/api/auth/change-password", post(change_password_handler)) .route("/api/ws-token", get(ws_token_handler)) + .route( + "/api/shares", + get(list_resource_shares_handler).post(create_share_handler), + ) + .route("/api/shares/received", get(list_received_shares_handler)) + .route("/api/shares/granted", get(list_granted_shares_handler)) + .route("/api/shares/{id}", delete(revoke_share_handler)) + .route("/api/users/directory", get(list_user_directory_handler)) .route_layer(from_fn_with_state( action_limiter.clone(), authenticated_action_rate_limit_middleware, )) - .route_layer(from_fn_with_state(auth_state, auth_middleware)) + .route_layer(from_fn_with_state(auth_state.clone(), auth_middleware)) .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) .with_state(state.clone()); + let admin = if state.identity_mode == AuthIdentityMode::UserSession && !state.aionpro_mode { + Router::new() + .route( + "/api/admin/users", + get(list_admin_users_handler).post(create_admin_user_handler), + ) + .route("/api/admin/users/{id}/username", patch(update_admin_username_handler)) + .route("/api/admin/users/{id}/role", patch(update_admin_role_handler)) + .route("/api/admin/users/{id}/status", patch(update_admin_status_handler)) + .route( + "/api/admin/users/{id}/reset-password", + post(reset_admin_password_handler), + ) + .route( + "/api/admin/users/{id}/sessions/revoke", + post(revoke_admin_sessions_handler), + ) + .route("/api/admin/audit", get(list_admin_audit_handler)) + .route_layer(from_fn_with_state( + action_limiter.clone(), + authenticated_action_rate_limit_middleware, + )) + .route_layer(axum::middleware::from_fn(admin_required_middleware)) + .route_layer(from_fn_with_state(auth_state, auth_middleware)) + .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) + .with_state(state.clone()) + } else { + Router::new() + }; + // API + action limited routes (token in body, no auth middleware) let api_action_limited = Router::new() .route("/api/auth/refresh", post(refresh_handler)) @@ -351,10 +399,242 @@ pub fn auth_routes(state: AuthRouterState) -> Router { .merge(auth_rate_limited) .merge(api_public) .merge(authenticated) + .merge(admin) .merge(api_action_limited) .merge(static_routes) } +async fn list_admin_users_handler( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let limit = query.limit.unwrap_or(100).clamp(1, 100); + let offset = query.offset.unwrap_or(0); + let response = AdminUserService::new(state.admin_user_repo) + .list_users(limit, offset) + .await + .map_err(admin_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +fn share_service(state: &AuthRouterState) -> ShareService { + ShareService::new(state.share_repo.clone(), state.user_repo.clone()) +} + +fn share_service_error_to_api_error(err: ShareServiceError) -> ApiError { + match err { + ShareServiceError::Database(db) => db_error_to_api_error(db), + ShareServiceError::NotFound(msg) => ApiError::NotFound(msg), + ShareServiceError::Forbidden(msg) => ApiError::Forbidden(msg), + ShareServiceError::Conflict(msg) => ApiError::Conflict(msg), + ShareServiceError::BadRequest(msg) => ApiError::BadRequest(msg), + } +} + +async fn create_share_handler( + State(state): State, + Extension(user): Extension, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(req) = body.map_err(ApiError::from)?; + let share = share_service(&state) + .grant(&user.id, req) + .await + .map_err(share_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(share))) +} + +async fn revoke_share_handler( + State(state): State, + Extension(user): Extension, + Path(share_id): Path, +) -> Result { + share_service(&state) + .revoke(&user.id, &share_id) + .await + .map_err(share_service_error_to_api_error)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_resource_shares_handler( + State(state): State, + Extension(user): Extension, + Query(query): Query, +) -> Result>, ApiError> { + let response = share_service(&state) + .list_for_resource(&user.id, query.resource_type, &query.resource_id) + .await + .map_err(share_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +async fn list_received_shares_handler( + State(state): State, + Extension(user): Extension, +) -> Result>, ApiError> { + let response = share_service(&state) + .list_received_by(&user.id) + .await + .map_err(share_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +async fn list_granted_shares_handler( + State(state): State, + Extension(user): Extension, +) -> Result>, ApiError> { + let response = share_service(&state) + .list_granted_by(&user.id) + .await + .map_err(share_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +async fn list_user_directory_handler( + State(state): State, + Extension(user): Extension, +) -> Result>, ApiError> { + let response = share_service(&state) + .list_directory(&user.id) + .await + .map_err(share_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +async fn create_admin_user_handler( + State(state): State, + Extension(actor): Extension, + body: Result, JsonRejection>, +) -> Result { + let Json(request) = body.map_err(ApiError::from)?; + let response = AdminUserService::new(state.admin_user_repo) + .create_user( + &request.username, + request.role, + &audit_actor(&actor.id, &actor.username), + ) + .await + .map_err(admin_service_error_to_api_error)?; + Ok(no_store_json(StatusCode::CREATED, ApiResponse::ok(response))) +} + +async fn update_admin_username_handler( + State(state): State, + Extension(actor): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(request) = body.map_err(ApiError::from)?; + let user = AdminUserService::new(state.admin_user_repo.clone()) + .update_username(&id, &request.username, &audit_actor(&actor.id, &actor.username)) + .await + .map_err(admin_service_error_to_api_error)?; + notify_session_revoked(&state, &id); + Ok(Json(ApiResponse::ok(user))) +} + +async fn update_admin_role_handler( + State(state): State, + Extension(actor): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(request) = body.map_err(ApiError::from)?; + let user = AdminUserService::new(state.admin_user_repo.clone()) + .update_role(&id, request.role, &audit_actor(&actor.id, &actor.username)) + .await + .map_err(admin_service_error_to_api_error)?; + notify_session_revoked(&state, &id); + Ok(Json(ApiResponse::ok(user))) +} + +async fn update_admin_status_handler( + State(state): State, + Extension(actor): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(request) = body.map_err(ApiError::from)?; + let user = AdminUserService::new(state.admin_user_repo.clone()) + .update_status(&id, request.status, &audit_actor(&actor.id, &actor.username)) + .await + .map_err(admin_service_error_to_api_error)?; + notify_session_revoked(&state, &id); + Ok(Json(ApiResponse::ok(user))) +} + +async fn reset_admin_password_handler( + State(state): State, + Extension(actor): Extension, + Path(id): Path, +) -> Result { + let response = AdminUserService::new(state.admin_user_repo.clone()) + .reset_password(&id, &audit_actor(&actor.id, &actor.username)) + .await + .map_err(admin_service_error_to_api_error)?; + notify_session_revoked(&state, &id); + Ok(no_store_json(StatusCode::OK, ApiResponse::ok(response))) +} + +async fn revoke_admin_sessions_handler( + State(state): State, + Extension(actor): Extension, + Path(id): Path, +) -> Result>, ApiError> { + let user = AdminUserService::new(state.admin_user_repo.clone()) + .revoke_sessions(&id, &audit_actor(&actor.id, &actor.username)) + .await + .map_err(admin_service_error_to_api_error)?; + notify_session_revoked(&state, &id); + Ok(Json(ApiResponse::ok(user))) +} + +async fn list_admin_audit_handler( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let response = AdminUserService::new(state.admin_user_repo) + .list_audit(query.cursor.as_deref(), limit) + .await + .map_err(admin_service_error_to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +fn notify_session_revoked(state: &AuthRouterState, user_id: &str) { + if let Some(hook) = &state.session_revoked_hook { + hook(user_id); + } +} + +fn no_store_json(status: StatusCode, body: ApiResponse) -> Response { + (status, [(header::CACHE_CONTROL, "no-store")], Json(body)).into_response() +} + +fn admin_service_error_to_api_error(error: AdminUserServiceError) -> ApiError { + match error { + AdminUserServiceError::Repository(AdminUserRepositoryError::LastActiveAdmin) => ApiError::coded( + StatusCode::CONFLICT, + "LAST_ACTIVE_ADMIN", + "The last active administrator cannot be changed.", + None, + ), + AdminUserServiceError::Repository(AdminUserRepositoryError::UnsupportedIdentity) => { + ApiError::coded(StatusCode::NOT_FOUND, "USER_NOT_FOUND", "User not found.", None) + } + AdminUserServiceError::Repository(AdminUserRepositoryError::Database(error)) + | AdminUserServiceError::Database(error) => match error { + DbError::NotFound(_) => ApiError::coded(StatusCode::NOT_FOUND, "USER_NOT_FOUND", "User not found.", None), + DbError::Conflict(_) => { + ApiError::coded(StatusCode::CONFLICT, "USERNAME_TAKEN", "Username already exists.", None) + } + other => db_error_to_api_error(other), + }, + AdminUserServiceError::Validation(error) => ApiError::from(error), + AdminUserServiceError::HashTask(error) => ApiError::Internal(format!("Password hashing failed: {error}")), + } +} + // --------------------------------------------------------------------------- // PUT /api/auth/internal/external-users/{external_user_id} // --------------------------------------------------------------------------- @@ -464,6 +744,10 @@ async fn login_handler( .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; let (found_user, password_valid) = match user { + Some(u) if u.user_type != UserType::Local || u.status != UserStatus::Active => { + let _ = verify_password_timed(&req.password, dummy_password_hash()).await; + (None, false) + } Some(u) if u.password_hash.as_deref().unwrap_or_default().trim().is_empty() => { // Seeded user with no password yet (first-run local mode). // Treat as invalid credentials; run dummy verify for timing symmetry @@ -492,28 +776,14 @@ async fn login_handler( let user = found_user.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?; - let token = state - .jwt_service - .sign_with_session_generation( - &user.id, - user.username.as_deref().unwrap_or("external_user"), - user.session_generation, - ) - .map_err(|e| ApiError::Internal(format!("Token signing error: {e}")))?; + let (token, cookie) = issue_persistent_session(&state, &user).await?; // Update last login (best-effort) if let Err(e) = state.user_repo.update_last_login(&user.id).await { tracing::warn!("Failed to update last login for {}: {e}", user.id); } - let cookie = state.cookie_config.build_session_cookie(&token); - let resp = LoginResponse::new( - PublicUser { - id: user.id, - username: user.username.unwrap_or_else(|| "external_user".to_string()), - }, - token, - ); + let resp = LoginResponse::new(public_user(user), token); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } @@ -524,6 +794,15 @@ async fn login_handler( async fn logout_handler(State(state): State, headers: HeaderMap) -> Result { if let Some(token) = extract_token_from_headers(&headers) { + if let Ok(payload) = state.jwt_service.verify(&token) + && let Some(session_id) = payload.session_id.as_deref() + { + state + .user_repo + .revoke_auth_session(session_id, &payload.user_id, "logout") + .await + .map_err(db_error_to_api_error)?; + } state.jwt_service.blacklist_token(&token); } @@ -543,20 +822,37 @@ async fn status_handler( ) -> Result, ApiError> { let has_users = state .user_repo - .has_users() + .has_usable_admin() .await .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; let user_count = state - .user_repo - .count_users() + .admin_user_repo + .count_managed_users() .await .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; // Check authentication without requiring it - let is_authenticated = extract_token_from_headers(&headers) - .and_then(|token| state.jwt_service.verify(&token).ok()) - .is_some(); + let is_authenticated = if let Some(payload) = + extract_token_from_headers(&headers).and_then(|token| state.jwt_service.verify(&token).ok()) + { + match state.user_repo.find_active_by_id(&payload.user_id).await { + Ok(Some(user)) if user.session_generation == payload.session_generation => { + if let Some(session_id) = payload.session_id.as_deref() { + state + .user_repo + .is_auth_session_active(session_id, &user.id) + .await + .unwrap_or(false) + } else { + true + } + } + _ => false, + } + } else { + false + }; Ok(Json(AuthStatusResponse { success: true, @@ -706,6 +1002,9 @@ async fn user_handler(Extension(user): Extension) -> Json, Extension(current_user): Extension, body: Result, JsonRejection>, -) -> Result>, ApiError> { +) -> Result { let Json(req) = body.map_err(ApiError::from)?; // Validate new password strength - validate_password(&req.new_password)?; + validate_password_for_api(&req.new_password)?; // Fetch user record let user = state @@ -738,7 +1037,20 @@ async fn change_password_handler( }; let valid = verify_password_timed(&req.current_password, password_hash).await?; if !valid { - return Err(ApiError::Unauthorized("Current password is incorrect".into())); + return Err(ApiError::coded( + StatusCode::BAD_REQUEST, + "INVALID_CURRENT_PASSWORD", + "Current password is incorrect.", + None, + )); + } + if verify_password_timed(&req.new_password, password_hash).await? { + return Err(ApiError::coded( + StatusCode::BAD_REQUEST, + "PASSWORD_REUSED", + "New password must differ from the current password.", + None, + )); } // Hash new password on blocking thread @@ -748,26 +1060,46 @@ async fn change_password_handler( .map_err(|e| ApiError::Internal(format!("Task join error: {e}")))??; // Persist new password hash - state - .user_repo - .update_password(¤t_user.id, &new_hash) - .await - .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; - - // Rotate JWT secret to invalidate all sessions - let new_secret = state - .jwt_service - .rotate_secret() - .map_err(|e| ApiError::Internal(format!("Secret rotation error: {e}")))?; - - // Persist new secret to database - state - .user_repo - .update_jwt_secret(¤t_user.id, &new_secret) + let updated = state + .admin_user_repo + .change_own_password( + ¤t_user.id, + &new_hash, + &audit_actor(¤t_user.id, ¤t_user.username), + ) .await - .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; + .map_err(|error| admin_service_error_to_api_error(AdminUserServiceError::Repository(error)))?; + notify_session_revoked(&state, ¤t_user.id); + let (_token, cookie) = issue_persistent_session(&state, &updated).await?; + if let Some(path) = &state.initial_admin_credentials_file { + match initial_admin_credentials_belong_to(path, ¤t_user.username) { + Ok(true) => { + if let Err(error) = std::fs::remove_file(path.as_ref()) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(path = %path.display(), error = %error, "failed to remove consumed initial admin credentials"); + } + } + Ok(false) => {} + Err(error) => { + tracing::warn!(path = %path.display(), error = %error, "failed to validate consumed initial admin credentials"); + } + } + } + Ok(( + [ + (header::SET_COOKIE, cookie), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + Json(ApiResponse::ok(public_user(updated))), + ) + .into_response()) +} - Ok(Json(ApiResponse::message("Password changed successfully"))) +fn initial_admin_credentials_belong_to(path: &FsPath, username: &str) -> Result { + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let value: serde_json::Value = serde_json::from_reader(file).map_err(|error| error.to_string())?; + Ok(value.get("username").and_then(serde_json::Value::as_str) == Some(username)) } // --------------------------------------------------------------------------- @@ -803,12 +1135,37 @@ async fn refresh_handler( return Err(ApiError::Unauthorized("Invalid authentication session".into())); } + if let Some(session_id) = payload.session_id.as_deref() + && !state + .user_repo + .is_auth_session_active(session_id, &user.id) + .await + .map_err(db_error_to_api_error)? + { + return Err(ApiError::Unauthorized("Invalid authentication session".into())); + } + + let session_id = if let Some(session_id) = payload.session_id { + state + .user_repo + .touch_auth_session(&session_id, &user.id) + .await + .map_err(db_error_to_api_error)?; + session_id + } else { + state + .user_repo + .create_auth_session(&user.id, aionui_common::now_ms() + crate::jwt::TOKEN_EXPIRY_MS) + .await + .map_err(db_error_to_api_error)? + }; let new_token = state .jwt_service - .sign_with_session_generation( + .sign_with_session_id( &user.id, user.username.as_deref().unwrap_or("external_user"), user.session_generation, + &session_id, ) .map_err(|e| ApiError::Internal(format!("Token signing error: {e}")))?; @@ -872,33 +1229,49 @@ async fn qr_login_handler( .await .map_err(|e| ApiError::Internal(format!("Database error: {e}")))? .ok_or_else(|| ApiError::Internal("No primary user configured".into()))?; + if user.user_type != UserType::Local + || user.status != UserStatus::Active + || user.password_hash.as_deref().unwrap_or_default().is_empty() + { + return Err(ApiError::Unauthorized("No active primary user configured".into())); + } - let token = state - .jwt_service - .sign_with_session_generation( - &user.id, - user.username.as_deref().unwrap_or("external_user"), - user.session_generation, - ) - .map_err(|e| ApiError::Internal(format!("Token signing error: {e}")))?; + let (token, cookie) = issue_persistent_session(&state, &user).await?; // Update last login (best-effort) if let Err(e) = state.user_repo.update_last_login(&user.id).await { tracing::warn!("Failed to update last login for {}: {e}", user.id); } - let cookie = state.cookie_config.build_session_cookie(&token); - let resp = LoginResponse::new( - PublicUser { - id: user.id, - username: user.username.unwrap_or_else(|| "external_user".to_string()), - }, - token, - ); + let resp = LoginResponse::new(public_user(user), token); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } +fn public_user(user: User) -> PublicUser { + PublicUser { + id: user.id, + username: user.username.unwrap_or_else(|| "external_user".to_string()), + role: map_site_role(user.site_role), + status: map_account_status(user.status), + must_change_password: user.must_change_password, + } +} + +fn map_site_role(role: aionui_db::SiteRole) -> UserRole { + match role { + aionui_db::SiteRole::Admin => UserRole::Admin, + aionui_db::SiteRole::Member => UserRole::Member, + } +} + +fn map_account_status(status: UserStatus) -> AccountStatus { + match status { + UserStatus::Active => AccountStatus::Active, + UserStatus::Disabled => AccountStatus::Disabled, + } +} + // --------------------------------------------------------------------------- // GET /qr-login (static HTML page) // --------------------------------------------------------------------------- @@ -1000,10 +1373,11 @@ async fn webui_change_password_handler( .map_err(|e| ApiError::Internal(format!("Task join error: {e}")))??; state - .user_repo - .update_password(&user.id, &new_hash) + .admin_user_repo + .change_own_password(&user.id, &new_hash, &aionui_db::AuditActor::system()) .await - .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; + .map_err(|error| admin_service_error_to_api_error(AdminUserServiceError::Repository(error)))?; + notify_session_revoked(&state, &user.id); Ok(Json(ApiResponse::message("Password changed successfully"))) } @@ -1026,10 +1400,11 @@ async fn webui_change_username_handler( if user.username.as_deref() != Some(trimmed.as_str()) { state - .user_repo - .update_username(&user.id, &trimmed) + .admin_user_repo + .update_managed_username(&user.id, &trimmed, &aionui_db::AuditActor::system()) .await - .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; + .map_err(|error| admin_service_error_to_api_error(AdminUserServiceError::Repository(error)))?; + notify_session_revoked(&state, &user.id); } Ok(Json(ApiResponse::ok(WebuiChangeUsernameResponse { username: trimmed }))) @@ -1053,14 +1428,49 @@ async fn webui_reset_password_handler( .map_err(|e| ApiError::Internal(format!("Task join error: {e}")))??; state - .user_repo - .update_password(&user.id, &new_hash) + .admin_user_repo + .reset_managed_password(&user.id, &new_hash, &aionui_db::AuditActor::system()) .await - .map_err(|e| ApiError::Internal(format!("Database error: {e}")))?; + .map_err(|error| admin_service_error_to_api_error(AdminUserServiceError::Repository(error)))?; + notify_session_revoked(&state, &user.id); Ok(Json(ApiResponse::ok(WebuiResetPasswordResponse { new_password }))) } +async fn issue_persistent_session(state: &AuthRouterState, user: &User) -> Result<(String, String), ApiError> { + let expires_at = aionui_common::now_ms() + crate::jwt::TOKEN_EXPIRY_MS; + let session_id = state + .user_repo + .create_auth_session(&user.id, expires_at) + .await + .map_err(db_error_to_api_error)?; + let token = state + .jwt_service + .sign_with_session_id( + &user.id, + user.username.as_deref().unwrap_or("external_user"), + user.session_generation, + &session_id, + ) + .map_err(|error| ApiError::Internal(format!("Token signing error: {error}")))?; + let cookie = state.cookie_config.build_session_cookie(&token); + Ok((token, cookie)) +} + +fn validate_password_for_api(password: &str) -> Result<(), ApiError> { + validate_password(password).map_err(|error| { + let message = error.to_string(); + let code = if message.contains("at least") { + "PASSWORD_TOO_SHORT" + } else if message.contains("exceed") { + "PASSWORD_TOO_LONG" + } else { + "PASSWORD_TOO_COMMON" + }; + ApiError::coded(StatusCode::BAD_REQUEST, code, message, None) + }) +} + // --------------------------------------------------------------------------- // POST /api/webui/generate-qr-token // --------------------------------------------------------------------------- @@ -1130,4 +1540,13 @@ mod error_mapping_tests { let api_err = ApiError::from(AuthError::HashError("failed".into())); assert_eq!(api_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR); } + + #[test] + fn bootstrap_credentials_are_only_consumed_by_the_matching_user() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("credentials.json"); + std::fs::write(&path, r#"{"username":"initial-admin","temporary_password":"secret"}"#).unwrap(); + assert!(initial_admin_credentials_belong_to(&path, "initial-admin").unwrap()); + assert!(!initial_admin_credentials_belong_to(&path, "other-admin").unwrap()); + } } diff --git a/crates/aionui-auth/src/service.rs b/crates/aionui-auth/src/service.rs index f9d2b2b68..dbb137602 100644 --- a/crates/aionui-auth/src/service.rs +++ b/crates/aionui-auth/src/service.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use aionui_api_types::{ - EnsureExternalSessionRequest, EnsureExternalSessionResponse, EnsureExternalUserRequest, EnsureExternalUserResponse, - ExternalUserType, PublicUser, RevokeExternalSessionRequest, RevokeExternalSessionResponse, + AccountStatus, EnsureExternalSessionRequest, EnsureExternalSessionResponse, EnsureExternalUserRequest, + EnsureExternalUserResponse, ExternalUserType, PublicUser, RevokeExternalSessionRequest, + RevokeExternalSessionResponse, UserRole, }; use aionui_db::{ExternalUserProjection, IUserRepository, UserStatus, UserType, models::User}; @@ -124,14 +125,30 @@ impl AuthProvisionService { } let username = user.username.clone().unwrap_or_else(|| "external_user".to_string()); + let session_id = self + .user_repo + .create_auth_session(&user.id, aionui_common::now_ms() + crate::jwt::TOKEN_EXPIRY_MS) + .await?; let token = self .jwt_service - .sign_with_session_generation(&user.id, &username, user.session_generation)?; + .sign_with_session_id(&user.id, &username, user.session_generation, &session_id)?; self.user_repo.update_last_login(&user.id).await?; Ok(ExternalSessionExchange { response: EnsureExternalSessionResponse { - user: PublicUser { id: user.id, username }, + user: PublicUser { + id: user.id, + username, + role: match user.site_role { + aionui_db::SiteRole::Admin => UserRole::Admin, + aionui_db::SiteRole::Member => UserRole::Member, + }, + status: match user.status { + UserStatus::Active => AccountStatus::Active, + UserStatus::Disabled => AccountStatus::Disabled, + }, + must_change_password: user.must_change_password, + }, session_generation: user.session_generation, }, token, diff --git a/crates/aionui-auth/src/share_service.rs b/crates/aionui-auth/src/share_service.rs new file mode 100644 index 000000000..faad7557b --- /dev/null +++ b/crates/aionui-auth/src/share_service.rs @@ -0,0 +1,243 @@ +//! Explicit multi-user resource sharing (Phase 2 collaboration). + +use std::sync::Arc; + +use aionui_api_types::{ + CreateShareRequest, DirectoryUser, ResourceShare, ShareListResponse, SharePermission as ApiSharePermission, + ShareResourceType as ApiShareResourceType, UserDirectoryResponse, +}; +use aionui_db::{ + DbError, GrantShareParams, IResourceShareRepository, IUserRepository, ResourceShareRow, SharePermission, + ShareResourceType, UserStatus, UserType, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ShareServiceError { + #[error("database error: {0}")] + Database(#[from] DbError), + #[error("not found: {0}")] + NotFound(String), + #[error("forbidden: {0}")] + Forbidden(String), + #[error("conflict: {0}")] + Conflict(String), + #[error("bad request: {0}")] + BadRequest(String), +} + +#[derive(Clone)] +pub struct ShareService { + share_repo: Arc, + user_repo: Arc, +} + +impl ShareService { + pub fn new(share_repo: Arc, user_repo: Arc) -> Self { + Self { share_repo, user_repo } + } + + pub async fn grant( + &self, + actor_user_id: &str, + req: CreateShareRequest, + ) -> Result { + let resource_type = to_db_resource_type(req.resource_type); + let permission = to_db_permission(req.permission); + let resource_id = req.resource_id.trim(); + if resource_id.is_empty() { + return Err(ShareServiceError::BadRequest("resource_id must not be empty".into())); + } + let grantee_username = req.grantee_username.trim(); + if grantee_username.is_empty() { + return Err(ShareServiceError::BadRequest( + "grantee_username must not be empty".into(), + )); + } + + let owner_id = self + .share_repo + .resource_owner(resource_type, resource_id) + .await? + .ok_or_else(|| ShareServiceError::NotFound(format!("Resource '{resource_id}' not found")))?; + + if owner_id != actor_user_id { + return Err(ShareServiceError::Forbidden( + "Only the resource owner can grant shares".into(), + )); + } + + let grantee = self + .user_repo + .find_by_username(grantee_username) + .await? + .ok_or_else(|| ShareServiceError::NotFound(format!("User '{grantee_username}' not found")))?; + + if grantee.user_type != UserType::Local || grantee.status != UserStatus::Active { + return Err(ShareServiceError::NotFound(format!( + "User '{grantee_username}' not found" + ))); + } + + if grantee.id == owner_id { + return Err(ShareServiceError::Conflict( + "Cannot share a resource with its owner".into(), + )); + } + + let row = self + .share_repo + .grant(GrantShareParams { + resource_type, + resource_id, + owner_user_id: &owner_id, + grantee_user_id: &grantee.id, + permission, + created_by: actor_user_id, + }) + .await?; + + Ok(to_api_share( + row, + Some(grantee.username.unwrap_or_else(|| grantee_username.to_owned())), + )) + } + + pub async fn revoke(&self, actor_user_id: &str, share_id: &str) -> Result<(), ShareServiceError> { + let share = self + .share_repo + .find_by_id(share_id) + .await? + .ok_or_else(|| ShareServiceError::NotFound(format!("Share '{share_id}' not found")))?; + + if share.owner_user_id != actor_user_id { + return Err(ShareServiceError::Forbidden( + "Only the resource owner can revoke shares".into(), + )); + } + + self.share_repo.revoke(share_id).await?; + Ok(()) + } + + pub async fn list_for_resource( + &self, + actor_user_id: &str, + resource_type: ApiShareResourceType, + resource_id: &str, + ) -> Result { + let resource_type = to_db_resource_type(resource_type); + let owner_id = self + .share_repo + .resource_owner(resource_type, resource_id) + .await? + .ok_or_else(|| ShareServiceError::NotFound(format!("Resource '{resource_id}' not found")))?; + + if owner_id != actor_user_id { + return Err(ShareServiceError::Forbidden( + "Only the resource owner can list shares for a resource".into(), + )); + } + + let rows = self.share_repo.list_for_resource(resource_type, resource_id).await?; + Ok(ShareListResponse { + items: self.enrich_rows(rows).await?, + }) + } + + pub async fn list_granted_by(&self, owner_user_id: &str) -> Result { + let rows = self.share_repo.list_granted_by(owner_user_id).await?; + Ok(ShareListResponse { + items: self.enrich_rows(rows).await?, + }) + } + + pub async fn list_received_by(&self, grantee_user_id: &str) -> Result { + let rows = self.share_repo.list_received_by(grantee_user_id).await?; + Ok(ShareListResponse { + items: self.enrich_rows(rows).await?, + }) + } + + /// Active local usernames + ids for the share picker (no secrets). + pub async fn list_directory(&self, actor_user_id: &str) -> Result { + let users = self.user_repo.list_users().await?; + let items = users + .into_iter() + .filter(|u| { + u.id != actor_user_id + && u.user_type == UserType::Local + && u.status == UserStatus::Active + && u.username.as_ref().is_some_and(|name| !name.is_empty()) + }) + .map(|u| DirectoryUser { + id: u.id, + username: u.username.unwrap_or_default(), + }) + .collect(); + Ok(UserDirectoryResponse { items }) + } + + async fn enrich_rows(&self, rows: Vec) -> Result, ShareServiceError> { + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let username = match self.user_repo.find_by_id(&row.grantee_user_id).await? { + Some(user) => user.username, + None => None, + }; + items.push(to_api_share(row, username)); + } + Ok(items) + } +} + +fn to_db_resource_type(value: ApiShareResourceType) -> ShareResourceType { + match value { + ApiShareResourceType::Conversation => ShareResourceType::Conversation, + ApiShareResourceType::Project => ShareResourceType::Project, + ApiShareResourceType::Provider => ShareResourceType::Provider, + } +} + +fn to_db_permission(value: ApiSharePermission) -> SharePermission { + match value { + ApiSharePermission::View => SharePermission::View, + ApiSharePermission::Edit => SharePermission::Edit, + } +} + +fn to_api_resource_type(value: ShareResourceType) -> ApiShareResourceType { + match value { + ShareResourceType::Conversation => ApiShareResourceType::Conversation, + ShareResourceType::Project => ApiShareResourceType::Project, + ShareResourceType::Provider => ApiShareResourceType::Provider, + } +} + +fn to_api_permission(value: SharePermission) -> ApiSharePermission { + match value { + SharePermission::View => ApiSharePermission::View, + SharePermission::Edit => ApiSharePermission::Edit, + } +} + +fn to_api_share(row: ResourceShareRow, grantee_username: Option) -> ResourceShare { + let resource_type = row + .resource_type() + .map(to_api_resource_type) + .unwrap_or(ApiShareResourceType::Conversation); + let permission = row + .permission() + .map(to_api_permission) + .unwrap_or(ApiSharePermission::View); + ResourceShare { + id: row.id, + resource_type, + resource_id: row.resource_id, + owner_user_id: row.owner_user_id, + grantee_user_id: row.grantee_user_id, + grantee_username, + permission, + created_at: row.created_at, + created_by: row.created_by, + } +} diff --git a/crates/aionui-auth/tests/middleware_tests.rs b/crates/aionui-auth/tests/middleware_tests.rs index 021f722ad..4979ff8a6 100644 --- a/crates/aionui-auth/tests/middleware_tests.rs +++ b/crates/aionui-auth/tests/middleware_tests.rs @@ -504,6 +504,8 @@ async fn authenticated_action_limit_uses_user_id_key() { username: "admin".into(), user_type: UserType::Local, status: UserStatus::Active, + site_role: aionui_db::SiteRole::Admin, + must_change_password: false, }); Ok::<_, std::convert::Infallible>(next.run(request).await) }, diff --git a/crates/aionui-auth/tests/route_tests.rs b/crates/aionui-auth/tests/route_tests.rs index 5dee18f14..0d3851f6b 100644 --- a/crates/aionui-auth/tests/route_tests.rs +++ b/crates/aionui-auth/tests/route_tests.rs @@ -16,7 +16,9 @@ use aionui_auth::{ AuthIdentityMode, AuthRouterState, CookieConfig, JwtService, QrTokenStore, SessionRevokedHook, auth_routes, hash_password, }; -use aionui_db::{IUserRepository, SqliteUserRepository, UserStatus, init_database_memory}; +use aionui_db::{ + IUserRepository, SqliteResourceShareRepository, SqliteUserRepository, UserStatus, init_database_memory, +}; // --------------------------------------------------------------------------- // Test helpers @@ -42,7 +44,8 @@ async fn test_app_with_options_and_hook( session_revoked_hook: Option>, ) -> (Router, TestContext) { let db = init_database_memory().await.unwrap(); - let user_repo = Arc::new(SqliteUserRepository::new(db.pool().clone())) as Arc; + let sqlite_user_repo = Arc::new(SqliteUserRepository::new(db.pool().clone())); + let user_repo = sqlite_user_repo.clone() as Arc; let jwt_service = Arc::new(JwtService::new("test_secret_for_routes".into())); let cookie_config = Arc::new(CookieConfig { secure: false, @@ -50,9 +53,13 @@ async fn test_app_with_options_and_hook( }); let qr_token_store = Arc::new(QrTokenStore::new()); + let share_repo = Arc::new(SqliteResourceShareRepository::new(db.pool().clone())); let state = AuthRouterState { jwt_service: jwt_service.clone(), user_repo: user_repo.clone(), + admin_user_repo: sqlite_user_repo, + share_repo, + initial_admin_credentials_file: None, fs_adopter: None, cookie_config, qr_token_store: qr_token_store.clone(), @@ -155,6 +162,16 @@ fn json_post_with_token(uri: &str, body: &str, token: &str) -> Request { .unwrap() } +fn json_patch_with_token(uri: &str, body: &str, token: &str) -> Request { + Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_owned())) + .unwrap() +} + /// Helper: perform a GET request with auth token. fn get_with_token(uri: &str, token: &str) -> Request { Request::builder() @@ -245,7 +262,7 @@ async fn t4_2_login_nonexistent_user() { let (app, _ctx) = test_app().await; let req = json_post("/login", r#"{"username":"ghost","password":"whatever"}"#); - let resp = app.oneshot(req).await.unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let json = body_json(resp).await; @@ -349,6 +366,8 @@ async fn t5_1_logout_success() { let (mut app, ctx) = test_app().await; create_test_user(&ctx, "admin", "StrongP@ss1").await; let (token, _) = login(&mut app, "admin", "StrongP@ss1").await; + let payload = ctx.jwt_service.verify(&token).unwrap(); + let session_id = payload.session_id.clone().expect("persistent session id"); let req = json_post_with_token("/logout", "", &token); let resp = app.clone().oneshot(req).await.unwrap(); @@ -362,6 +381,12 @@ async fn t5_1_logout_success() { let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["message"], "Logged out successfully"); + assert!( + !ctx.user_repo + .is_auth_session_active(&session_id, &payload.user_id) + .await + .unwrap() + ); } #[tokio::test] @@ -516,9 +541,12 @@ async fn t8_1_change_password_success() { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let replacement = extract_session_token(&resp).expect("replacement session cookie"); + assert_ne!(replacement, token); let json = body_json(resp).await; assert_eq!(json["success"], true); - assert_eq!(json["message"], "Password changed successfully"); + assert_eq!(json["data"]["username"], "admin"); + assert_eq!(json["data"]["must_change_password"], false); } #[tokio::test] @@ -535,13 +563,18 @@ async fn t8_2_change_password_old_token_invalidated() { ); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let replacement = extract_session_token(&resp).expect("replacement session cookie"); // Old token should be invalid (JWT secret rotated) let req = get_with_token("/api/auth/user", &token); - let resp = app.oneshot(req).await.unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let json = body_json(resp).await; assert_eq!(json["code"], "UNAUTHORIZED"); + + let req = get_with_token("/api/auth/user", &replacement); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] @@ -557,9 +590,9 @@ async fn t8_3_change_password_wrong_current() { ); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; - assert_eq!(json["code"], "UNAUTHORIZED"); + assert_eq!(json["code"], "INVALID_CURRENT_PASSWORD"); } #[tokio::test] @@ -640,7 +673,9 @@ async fn t9_1_refresh_token_success() { // New token should be valid let new_token = json["token"].as_str().unwrap(); - assert!(ctx.jwt_service.verify(new_token).is_ok()); + let old_payload = ctx.jwt_service.verify(&token).unwrap(); + let new_payload = ctx.jwt_service.verify(new_token).unwrap(); + assert_eq!(new_payload.session_id, old_payload.session_id); } #[tokio::test] @@ -828,6 +863,92 @@ async fn qr_login_page_returns_html() { assert!(content_type.contains("text/html")); } +#[tokio::test] +async fn admin_created_member_must_change_password_then_cannot_access_admin_api() { + let (mut app, ctx) = test_app().await; + create_test_user(&ctx, "admin", "AdminP@ssword1").await; + let (admin_token, _) = login(&mut app, "admin", "AdminP@ssword1").await; + + let response = app + .clone() + .oneshot(json_post_with_token( + "/api/admin/users", + r#"{"username":"member-one","role":"member"}"#, + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let json = body_json(response).await; + let temporary_password = json["data"]["temporary_password"].as_str().unwrap(); + let (member_token, _) = login(&mut app, "member-one", temporary_password).await; + + let response = app + .clone() + .oneshot(get_with_token("/api/admin/users", &member_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "PASSWORD_CHANGE_REQUIRED"); + + let response = app + .clone() + .oneshot(json_post_with_token( + "/api/auth/change-password", + &format!(r#"{{"current_password":"{temporary_password}","new_password":"MemberP@ssword2"}}"#), + &member_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let replacement = extract_session_token(&response).unwrap(); + + let response = app + .oneshot(get_with_token("/api/admin/users", &replacement)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "ADMIN_REQUIRED"); +} + +#[tokio::test] +async fn last_active_admin_role_change_is_rejected_and_audited_mutations_are_listed() { + let (mut app, ctx) = test_app().await; + create_test_user(&ctx, "admin", "AdminP@ssword1").await; + let (token, _) = login(&mut app, "admin", "AdminP@ssword1").await; + + let response = app + .clone() + .oneshot(json_patch_with_token( + "/api/admin/users/system_default_user/role", + r#"{"role":"member"}"#, + &token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(body_json(response).await["code"], "LAST_ACTIVE_ADMIN"); + + let response = app + .oneshot(get_with_token("/api/admin/audit?limit=50", &token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert!(json["data"]["items"].is_array()); +} + +#[tokio::test] +async fn admin_routes_are_not_registered_in_local_mode() { + let (app, _ctx) = test_app_with_local(true).await; + let response = app + .oneshot(Request::builder().uri("/api/admin/users").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + // =========================================================================== // Internal external user provision and session exchange // =========================================================================== diff --git a/crates/aionui-auth/tests/share_route_tests.rs b/crates/aionui-auth/tests/share_route_tests.rs new file mode 100644 index 000000000..d592a07b5 --- /dev/null +++ b/crates/aionui-auth/tests/share_route_tests.rs @@ -0,0 +1,235 @@ +//! Route-level tests for resource sharing endpoints. + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use aionui_auth::{ + AuthIdentityMode, AuthRouterState, CookieConfig, JwtService, QrTokenStore, auth_routes, hash_password, +}; +use aionui_db::{ + IConversationRepository, IUserRepository, SqliteConversationRepository, SqliteResourceShareRepository, + SqliteUserRepository, init_database_memory, models::ConversationRow, +}; + +struct TestCtx { + app: Router, + conv_id: String, + _db: aionui_db::Database, +} + +async fn setup() -> TestCtx { + let db = init_database_memory().await.unwrap(); + let sqlite_user = Arc::new(SqliteUserRepository::new(db.pool().clone())); + let users: Arc = sqlite_user.clone(); + let share_repo = Arc::new(SqliteResourceShareRepository::new(db.pool().clone())); + let jwt = Arc::new(JwtService::new("share-route-test-secret".into())); + + let owner_hash = hash_password("OwnerPass1!").unwrap(); + let grantee_hash = hash_password("GranteePass1!").unwrap(); + let owner = users.create_user("owner_share", &owner_hash).await.unwrap(); + let _grantee = users.create_user("grantee_share", &grantee_hash).await.unwrap(); + + let conv_repo = SqliteConversationRepository::new(db.pool().clone()); + let now = aionui_common::now_ms(); + let conv = ConversationRow { + id: aionui_common::generate_prefixed_id("conv"), + user_id: owner.id.clone(), + name: "owner chat".into(), + r#type: "gemini".into(), + extra: "{}".into(), + model: None, + status: Some("pending".into()), + source: Some("aionui".into()), + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: now, + updated_at: now, + project_id: None, + folder_id: None, + name_source: None, + }; + conv_repo.create(&conv).await.unwrap(); + + let state = AuthRouterState { + jwt_service: jwt, + user_repo: users, + admin_user_repo: sqlite_user, + share_repo, + initial_admin_credentials_file: None, + fs_adopter: None, + cookie_config: Arc::new(CookieConfig { + secure: false, + same_site: "Lax", + }), + qr_token_store: Arc::new(QrTokenStore::new()), + identity_mode: AuthIdentityMode::UserSession, + bootstrap_secret: None, + session_revoked_hook: None, + local: false, + aionpro_mode: false, + }; + + TestCtx { + app: auth_routes(state), + conv_id: conv.id, + _db: db, + } +} + +async fn login(app: &Router, username: &str, password: &str) -> String { + let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "login failed for {username}"); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + json["token"].as_str().unwrap().to_owned() +} + +async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn authed(method: &str, uri: &str, token: &str, body: Option<&str>) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + if body.is_some() { + builder = builder.header("content-type", "application/json"); + } + builder.body(Body::from(body.unwrap_or("").to_owned())).unwrap() +} + +#[tokio::test] +async fn owner_can_grant_list_and_revoke_share() { + let ctx = setup().await; + let token = login(&ctx.app, "owner_share", "OwnerPass1!").await; + + let create_body = format!( + r#"{{"resource_type":"conversation","resource_id":"{}","grantee_username":"grantee_share","permission":"view"}}"#, + ctx.conv_id + ); + let create = ctx + .app + .clone() + .oneshot(authed("POST", "/api/shares", &token, Some(&create_body))) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::OK); + let created = body_json(create).await; + let share_id = created["data"]["id"].as_str().unwrap().to_owned(); + assert_eq!(created["data"]["permission"], "view"); + + let list = ctx + .app + .clone() + .oneshot(authed( + "GET", + &format!("/api/shares?resource_type=conversation&resource_id={}", ctx.conv_id), + &token, + None, + )) + .await + .unwrap(); + assert_eq!(list.status(), StatusCode::OK); + let listed = body_json(list).await; + assert_eq!(listed["data"]["items"].as_array().unwrap().len(), 1); + + let grantee_token = login(&ctx.app, "grantee_share", "GranteePass1!").await; + let received = ctx + .app + .clone() + .oneshot(authed("GET", "/api/shares/received", &grantee_token, None)) + .await + .unwrap(); + assert_eq!(received.status(), StatusCode::OK); + let received_json = body_json(received).await; + assert_eq!(received_json["data"]["items"].as_array().unwrap().len(), 1); + + let directory = ctx + .app + .clone() + .oneshot(authed("GET", "/api/users/directory", &token, None)) + .await + .unwrap(); + assert_eq!(directory.status(), StatusCode::OK); + let dir = body_json(directory).await; + let names: Vec<&str> = dir["data"]["items"] + .as_array() + .unwrap() + .iter() + .filter_map(|u| u["username"].as_str()) + .collect(); + assert!(names.contains(&"grantee_share")); + assert!(!names.contains(&"owner_share")); + + let revoke = ctx + .app + .clone() + .oneshot(authed("DELETE", &format!("/api/shares/{share_id}"), &token, None)) + .await + .unwrap(); + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn grantee_cannot_revoke_owner_share() { + let ctx = setup().await; + let owner = login(&ctx.app, "owner_share", "OwnerPass1!").await; + let create_body = format!( + r#"{{"resource_type":"conversation","resource_id":"{}","grantee_username":"grantee_share","permission":"view"}}"#, + ctx.conv_id + ); + let create = ctx + .app + .clone() + .oneshot(authed("POST", "/api/shares", &owner, Some(&create_body))) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::OK); + let share_id = body_json(create).await["data"]["id"].as_str().unwrap().to_owned(); + + let grantee = login(&ctx.app, "grantee_share", "GranteePass1!").await; + let revoke = ctx + .app + .clone() + .oneshot(authed("DELETE", &format!("/api/shares/{share_id}"), &grantee, None)) + .await + .unwrap(); + assert_eq!(revoke.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn non_owner_cannot_grant_share() { + let ctx = setup().await; + let token = login(&ctx.app, "grantee_share", "GranteePass1!").await; + let body = format!( + r#"{{"resource_type":"conversation","resource_id":"{}","grantee_username":"owner_share","permission":"view"}}"#, + ctx.conv_id + ); + let resp = ctx + .app + .clone() + .oneshot(authed("POST", "/api/shares", &token, Some(&body))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/aionui-channel/src/manager.rs b/crates/aionui-channel/src/manager.rs index 53670cdb2..33228f65a 100644 --- a/crates/aionui-channel/src/manager.rs +++ b/crates/aionui-channel/src/manager.rs @@ -283,11 +283,21 @@ impl ChannelManager { confirm_tx, }; - plugin.initialize(config, callbacks).await?; + if let Err(error) = plugin.initialize(config, callbacks).await { + if let Err(cleanup_error) = plugin.stop().await { + warn!( + plugin_id = %plugin_id, + error = %cleanup_error, + "failed to clean up plugin after credential test initialization error" + ); + } + return Err(error); + } let bot_username = plugin.bot_info().and_then(|b| b.username.clone()); // Clean up — don't leave a started connection + plugin.stop().await?; debug!(plugin_id = %plugin_id, "plugin credential test successful"); Ok(bot_username) } diff --git a/crates/aionui-channel/src/plugins/weixin/login.rs b/crates/aionui-channel/src/plugins/weixin/login.rs index bb44bf832..e4e54b12b 100644 --- a/crates/aionui-channel/src/plugins/weixin/login.rs +++ b/crates/aionui-channel/src/plugins/weixin/login.rs @@ -1,11 +1,15 @@ -use std::time::Duration; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, Instant}; use reqwest::Client; use tokio::sync::mpsc; use tracing::{debug, error, info}; +use crate::error::ChannelError; + use super::api::WeixinApi; -use super::types::{SseDoneEvent, SseErrorEvent, SseQrEvent}; +use super::types::{QrCodeData, QrCodeStatusData, SseDoneEvent, SseErrorEvent, SseQrEvent}; /// Default base URL for the iLink Bot login API. const LOGIN_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; @@ -16,6 +20,151 @@ const QR_POLL_INTERVAL: Duration = Duration::from_secs(2); /// Maximum time to wait for QR code scan before timeout. const QR_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Minimum delay between QR login starts for one AionUI owner. +const QR_LOGIN_MIN_START_INTERVAL: Duration = Duration::from_secs(10); + +/// Why a new per-owner QR login could not be started. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WeixinLoginStartError { + /// The same owner already has a live QR login task. + InProgress, + /// The previous task ended too recently to start another external flow. + RateLimited { retry_after: Duration }, +} + +#[derive(Debug)] +struct LoginSlot { + active_generation: Option, + generation: u64, + last_started: Instant, +} + +/// Coordinates QR login tasks so one owner cannot accumulate external polling +/// loops by opening multiple SSE connections. +#[derive(Debug)] +pub struct WeixinLoginCoordinator { + slots: Mutex>, + min_start_interval: Duration, +} + +impl Default for WeixinLoginCoordinator { + fn default() -> Self { + Self::new() + } +} + +impl WeixinLoginCoordinator { + pub fn new() -> Self { + Self { + slots: Mutex::new(HashMap::new()), + min_start_interval: QR_LOGIN_MIN_START_INTERVAL, + } + } + + /// Start a QR login task for one authenticated AionUI owner. + pub fn start( + self: &Arc, + owner_user_id: &str, + ) -> Result, WeixinLoginStartError> { + let permit = self.acquire(owner_user_id)?; + Ok(spawn_login_stream(Some(permit))) + } + + fn acquire(self: &Arc, owner_user_id: &str) -> Result { + let now = Instant::now(); + let retention = self.min_start_interval.saturating_mul(2); + let mut slots = self.slots.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + slots.retain(|_, slot| { + slot.active_generation.is_some() || now.saturating_duration_since(slot.last_started) < retention + }); + + if let Some(slot) = slots.get_mut(owner_user_id) { + if slot.active_generation.is_some() { + return Err(WeixinLoginStartError::InProgress); + } + + let elapsed = now.saturating_duration_since(slot.last_started); + if elapsed < self.min_start_interval { + return Err(WeixinLoginStartError::RateLimited { + retry_after: self.min_start_interval - elapsed, + }); + } + + slot.generation = slot.generation.wrapping_add(1); + slot.active_generation = Some(slot.generation); + slot.last_started = now; + return Ok(LoginPermit { + coordinator: Arc::downgrade(self), + owner_user_id: owner_user_id.to_owned(), + generation: slot.generation, + }); + } + + slots.insert( + owner_user_id.to_owned(), + LoginSlot { + active_generation: Some(1), + generation: 1, + last_started: now, + }, + ); + Ok(LoginPermit { + coordinator: Arc::downgrade(self), + owner_user_id: owner_user_id.to_owned(), + generation: 1, + }) + } + + fn release(&self, owner_user_id: &str, generation: u64) { + let mut slots = self.slots.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(slot) = slots.get_mut(owner_user_id) + && slot.active_generation == Some(generation) + { + slot.active_generation = None; + } + } + + #[cfg(test)] + fn with_min_start_interval(min_start_interval: Duration) -> Self { + Self { + slots: Mutex::new(HashMap::new()), + min_start_interval, + } + } + + #[cfg(test)] + fn start_with_api( + self: &Arc, + owner_user_id: &str, + api: Arc, + poll_interval: Duration, + login_timeout: Duration, + ) -> Result, WeixinLoginStartError> { + let permit = self.acquire(owner_user_id)?; + Ok(spawn_login_stream_with_api( + api, + Some(permit), + poll_interval, + login_timeout, + )) + } +} + +#[derive(Debug)] +struct LoginPermit { + coordinator: Weak, + owner_user_id: String, + generation: u64, +} + +impl Drop for LoginPermit { + fn drop(&mut self) { + if let Some(coordinator) = self.coordinator.upgrade() { + coordinator.release(&self.owner_user_id, self.generation); + } + } +} + /// SSE event emitted during the WeChat QR code login flow. #[derive(Debug, Clone)] pub enum WeixinLoginEvent { @@ -72,8 +221,15 @@ impl WeixinLoginEvent { /// Start the WeChat QR code login flow, returning a channel of SSE events. pub fn weixin_login_stream() -> mpsc::Receiver { + spawn_login_stream(None) +} + +fn spawn_login_stream(permit: Option) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(16); - tokio::spawn(login_flow(tx)); + tokio::spawn(async move { + let _permit = permit; + login_flow(tx).await; + }); rx } @@ -89,10 +245,57 @@ async fn login_flow(tx: mpsc::Sender) { } }; - let api = WeixinApi::new(client, LOGIN_BASE_URL, ""); + let api: Arc = Arc::new(WeixinApi::new(client, LOGIN_BASE_URL, "")); + login_flow_with_api(tx, api, QR_POLL_INTERVAL, QR_LOGIN_TIMEOUT).await; +} + +#[async_trait::async_trait] +trait WeixinLoginApi: Send + Sync { + async fn get_bot_qrcode(&self) -> Result; + async fn get_qrcode_status(&self, qrcode: &str) -> Result; +} + +#[async_trait::async_trait] +impl WeixinLoginApi for WeixinApi { + async fn get_bot_qrcode(&self) -> Result { + WeixinApi::get_bot_qrcode(self).await + } + async fn get_qrcode_status(&self, qrcode: &str) -> Result { + WeixinApi::get_qrcode_status(self, qrcode).await + } +} + +#[cfg(test)] +fn spawn_login_stream_with_api( + api: Arc, + permit: Option, + poll_interval: Duration, + login_timeout: Duration, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(16); + tokio::spawn(async move { + let _permit = permit; + login_flow_with_api(tx, api, poll_interval, login_timeout).await; + }); + rx +} + +async fn login_flow_with_api( + tx: mpsc::Sender, + api: Arc, + poll_interval: Duration, + login_timeout: Duration, +) { // Step 1: Fetch QR code - let qr_data = match api.get_bot_qrcode().await { + let qr_result = tokio::select! { + _ = tx.closed() => { + debug!("WeChat QR login SSE consumer disconnected before QR fetch completed"); + return; + } + result = api.get_bot_qrcode() => result, + }; + let qr_data = match qr_result { Ok(data) => data, Err(e) => { error!(error = %e, "Failed to fetch WeChat QR code"); @@ -131,7 +334,7 @@ async fn login_flow(tx: mpsc::Sender) { } // Step 2: Poll for scan status - let deadline = tokio::time::Instant::now() + QR_LOGIN_TIMEOUT; + let deadline = tokio::time::Instant::now() + login_timeout; let mut scanned_sent = false; loop { @@ -140,9 +343,22 @@ async fn login_flow(tx: mpsc::Sender) { return; } - tokio::time::sleep(QR_POLL_INTERVAL).await; + tokio::select! { + _ = tx.closed() => { + debug!("WeChat QR login SSE consumer disconnected while waiting to poll"); + return; + } + _ = tokio::time::sleep(poll_interval) => {} + } - match api.get_qrcode_status(&ticket).await { + let status_result = tokio::select! { + _ = tx.closed() => { + debug!("WeChat QR login SSE consumer disconnected during status poll"); + return; + } + result = api.get_qrcode_status(&ticket) => result, + }; + match status_result { Ok(status) => { let state = status.status.as_deref().unwrap_or("wait"); debug!(status = state, "WeChat QR code status"); @@ -203,8 +419,43 @@ async fn login_flow(tx: mpsc::Sender) { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::Notify; + use tokio::time::timeout; + use super::*; + struct PendingPollApi { + poll_calls: AtomicUsize, + poll_started: Notify, + } + + impl PendingPollApi { + fn new() -> Self { + Self { + poll_calls: AtomicUsize::new(0), + poll_started: Notify::new(), + } + } + } + + #[async_trait::async_trait] + impl WeixinLoginApi for PendingPollApi { + async fn get_bot_qrcode(&self) -> Result { + Ok(QrCodeData { + qrcode: Some("ticket-1".into()), + qrcode_img_content: Some("qr-content".into()), + }) + } + + async fn get_qrcode_status(&self, _qrcode: &str) -> Result { + self.poll_calls.fetch_add(1, Ordering::SeqCst); + self.poll_started.notify_one(); + std::future::pending().await + } + } + #[test] fn login_event_names() { assert_eq!(WeixinLoginEvent::Qr("t".into()).event_name(), "qr"); @@ -261,5 +512,72 @@ mod tests { fn default_constants() { assert_eq!(QR_POLL_INTERVAL, Duration::from_secs(2)); assert_eq!(QR_LOGIN_TIMEOUT, Duration::from_secs(300)); + assert_eq!(QR_LOGIN_MIN_START_INTERVAL, Duration::from_secs(10)); + } + + #[test] + fn coordinator_is_single_flight_per_owner_without_blocking_other_owners() { + let coordinator = Arc::new(WeixinLoginCoordinator::with_min_start_interval(Duration::ZERO)); + let owner_a = coordinator.acquire("owner-a").unwrap(); + + assert_eq!( + coordinator.acquire("owner-a").unwrap_err(), + WeixinLoginStartError::InProgress, + ); + let owner_b = coordinator.acquire("owner-b").unwrap(); + + drop(owner_a); + drop(owner_b); + } + + #[test] + fn coordinator_rate_limits_immediate_restart_after_completed_login() { + let min_interval = Duration::from_secs(10); + let coordinator = Arc::new(WeixinLoginCoordinator::with_min_start_interval(min_interval)); + let permit = coordinator.acquire("owner-a").unwrap(); + drop(permit); + + let error = coordinator.acquire("owner-a").unwrap_err(); + assert!(matches!( + error, + WeixinLoginStartError::RateLimited { retry_after } + if retry_after > Duration::ZERO && retry_after <= min_interval + )); + } + + #[tokio::test] + async fn dropping_sse_receiver_cancels_in_flight_poll_and_releases_owner() { + let coordinator = Arc::new(WeixinLoginCoordinator::with_min_start_interval(Duration::ZERO)); + let api = Arc::new(PendingPollApi::new()); + let mut receiver = coordinator + .start_with_api("owner-a", api.clone(), Duration::ZERO, Duration::from_secs(30)) + .unwrap(); + + assert!(matches!(receiver.recv().await, Some(WeixinLoginEvent::Qr(value)) if value == "qr-content")); + timeout(Duration::from_secs(1), api.poll_started.notified()) + .await + .expect("status poll should start"); + assert_eq!( + coordinator + .start_with_api("owner-a", api.clone(), Duration::ZERO, Duration::from_secs(30),) + .unwrap_err(), + WeixinLoginStartError::InProgress, + ); + + drop(receiver); + + let replacement_permit = timeout(Duration::from_secs(1), async { + loop { + match coordinator.acquire("owner-a") { + Ok(permit) => break permit, + Err(WeixinLoginStartError::InProgress) => tokio::task::yield_now().await, + Err(error) => panic!("unexpected restart error: {error:?}"), + } + } + }) + .await + .expect("disconnected login task should release its owner slot"); + assert_eq!(api.poll_calls.load(Ordering::SeqCst), 1); + drop(replacement_permit); } } diff --git a/crates/aionui-channel/src/plugins/weixin/mod.rs b/crates/aionui-channel/src/plugins/weixin/mod.rs index 90a9b89ce..4ae86584e 100644 --- a/crates/aionui-channel/src/plugins/weixin/mod.rs +++ b/crates/aionui-channel/src/plugins/weixin/mod.rs @@ -3,5 +3,5 @@ mod login; mod plugin; mod types; -pub use login::{WeixinLoginEvent, weixin_login_stream}; +pub use login::{WeixinLoginCoordinator, WeixinLoginEvent, WeixinLoginStartError, weixin_login_stream}; pub use plugin::WeixinPlugin; diff --git a/crates/aionui-channel/src/plugins/weixin/plugin.rs b/crates/aionui-channel/src/plugins/weixin/plugin.rs index 471aa82f6..7f055fa1d 100644 --- a/crates/aionui-channel/src/plugins/weixin/plugin.rs +++ b/crates/aionui-channel/src/plugins/weixin/plugin.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use dashmap::DashMap; -use reqwest::Client; +use reqwest::{Client, redirect::Policy}; use tokio::sync::watch; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; @@ -31,6 +31,7 @@ pub struct WeixinPlugin { bot_info: Option, last_error: Option, api: Option>, + message_tx: Option>, poll_handle: Option>, shutdown_tx: Option>, context_tokens: Arc>, @@ -43,6 +44,7 @@ impl Default for WeixinPlugin { bot_info: None, last_error: None, api: None, + message_tx: None, poll_handle: None, shutdown_tx: None, context_tokens: Arc::new(DashMap::new()), @@ -83,15 +85,13 @@ impl ChannelPlugin for WeixinPlugin { ChannelError::InvalidConfig("Missing WeChat account_id".into()) })?; - let base_url = config - .credentials - .extra - .get("baseUrl") - .and_then(|v| v.as_str()) - .unwrap_or(DEFAULT_BASE_URL); + let base_url = resolve_base_url(&config)?; let http_client = Client::builder() .timeout(Duration::from_secs(WEIXIN_POLL_TIMEOUT.as_secs() + 10)) + // The authenticated bot token must never follow a redirect to a + // different or private origin. + .redirect(Policy::none()) .build() .map_err(|e| { self.status = PluginStatus::Error; @@ -110,18 +110,7 @@ impl ChannelPlugin for WeixinPlugin { info!(account_id, "WeChat bot initialized"); self.api = Some(api); - - let (shutdown_tx, shutdown_rx) = watch::channel(false); - self.shutdown_tx = Some(shutdown_tx); - - let api_clone = Arc::clone(self.api.as_ref().expect("api just set")); - let context_tokens = Arc::clone(&self.context_tokens); - self.poll_handle = Some(tokio::spawn(poll_loop( - api_clone, - callbacks.message_tx, - shutdown_rx, - context_tokens, - ))); + self.message_tx = Some(callbacks.message_tx); self.status = PluginStatus::Ready; Ok(()) @@ -129,6 +118,26 @@ impl ChannelPlugin for WeixinPlugin { async fn start(&mut self) -> Result<(), ChannelError> { self.status = PluginStatus::Starting; + + let api = self + .api + .as_ref() + .cloned() + .ok_or_else(|| ChannelError::ConnectionFailed("WeChat plugin is not initialized".into()))?; + let message_tx = self + .message_tx + .as_ref() + .cloned() + .ok_or_else(|| ChannelError::ConnectionFailed("WeChat callbacks are not initialized".into()))?; + let (shutdown_tx, shutdown_rx) = watch::channel(false); + self.shutdown_tx = Some(shutdown_tx); + self.poll_handle = Some(tokio::spawn(poll_loop( + api, + message_tx, + shutdown_rx, + Arc::clone(&self.context_tokens), + ))); + self.status = PluginStatus::Running; info!("WeChat plugin started"); Ok(()) @@ -141,11 +150,15 @@ impl ChannelPlugin for WeixinPlugin { let _ = tx.send(true); } - if let Some(handle) = self.poll_handle.take() { - let _ = tokio::time::timeout(Duration::from_secs(5), handle).await; + if let Some(mut handle) = self.poll_handle.take() + && tokio::time::timeout(Duration::from_secs(5), &mut handle).await.is_err() + { + handle.abort(); + let _ = handle.await; } self.api = None; + self.message_tx = None; self.context_tokens.clear(); self.status = PluginStatus::Stopped; info!("WeChat plugin stopped"); @@ -197,6 +210,20 @@ impl ChannelPlugin for WeixinPlugin { } } +fn resolve_base_url(config: &PluginConfig) -> Result<&'static str, ChannelError> { + if let Some(value) = config.credentials.extra.get("baseUrl") { + let requested = value + .as_str() + .ok_or_else(|| ChannelError::InvalidConfig("WeChat baseUrl must be a string".into()))?; + if requested.trim_end_matches('/') != DEFAULT_BASE_URL { + return Err(ChannelError::InvalidConfig( + "Custom WeChat API endpoints are not supported".into(), + )); + } + } + Ok(DEFAULT_BASE_URL) +} + // --------------------------------------------------------------------------- // Long-polling loop (buffer-based protocol) // --------------------------------------------------------------------------- @@ -219,7 +246,14 @@ async fn poll_loop( // Reduce each round to success or a failure reason. On success we also // advance the buffer and dispatch messages; on API error / transport // error we only record the reason. - let outcome: Result<(), String> = match api.get_updates(&buf).await { + let response = tokio::select! { + response = api.get_updates(&buf) => response, + _ = shutdown_rx.changed() => { + debug!("WeChat poll loop shutdown during request"); + break; + } + }; + let outcome: Result<(), String> = match response { Ok(resp) => { let is_api_error = resp.ret.unwrap_or(0) != 0 || resp.errcode.unwrap_or(0) != 0; if is_api_error { @@ -602,6 +636,32 @@ mod tests { assert_eq!(plugin.status(), PluginStatus::Error); } + #[test] + fn custom_wechat_api_origins_are_rejected_before_network_access() { + for base_url in [ + "http://127.0.0.1:8080", + "http://169.254.169.254/latest/meta-data", + "https://example.com", + ] { + let mut config = make_config(Some("tok_1"), Some("acc_1")); + config + .credentials + .extra + .insert("baseUrl".into(), serde_json::Value::String(base_url.into())); + assert!(resolve_base_url(&config).is_err(), "{base_url} must be rejected"); + } + } + + #[test] + fn official_wechat_origin_is_pinned() { + let mut config = make_config(Some("tok_1"), Some("acc_1")); + config.credentials.extra.insert( + "baseUrl".into(), + serde_json::Value::String("https://ilinkai.weixin.qq.com/".into()), + ); + assert_eq!(resolve_base_url(&config).unwrap(), DEFAULT_BASE_URL); + } + // -- Test helpers ----------------------------------------------------------- fn make_text_item(text: &str) -> WeixinRawItem { diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index 0156334dc..8d305e569 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -44,6 +44,8 @@ pub struct ChannelRouterState { pub plugin_factory: Arc, pub settings_service: Arc, pub extension_registry: ExtensionRegistry, + #[cfg(feature = "weixin")] + pub weixin_login_coordinator: Arc, } fn db_error_to_api_error(err: DbError) -> ApiError { @@ -684,7 +686,10 @@ async fn sync_channel_settings( /// `GET /api/channel/weixin/login` — start WeChat QR code login SSE stream. #[cfg(feature = "weixin")] -async fn weixin_login_sse(State(_state): State) -> impl axum::response::IntoResponse { +async fn weixin_login_sse( + State(state): State, + Extension(user): Extension, +) -> Result { use std::convert::Infallible; use axum::response::sse::{Event, KeepAlive, Sse}; @@ -692,9 +697,11 @@ async fn weixin_login_sse(State(_state): State) -> impl axum use tokio::sync::mpsc; use crate::plugins::weixin::WeixinLoginEvent; - use crate::plugins::weixin::weixin_login_stream; - let rx = weixin_login_stream(); + let rx = state + .weixin_login_coordinator + .start(&user.id) + .map_err(weixin_login_start_error)?; let sse_stream = futures_util::stream::unfold(rx, |mut rx: mpsc::Receiver| async move { match rx.recv().await { @@ -706,7 +713,35 @@ async fn weixin_login_sse(State(_state): State) -> impl axum } }); - Sse::new(sse_stream).keep_alive(KeepAlive::default()) + Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) +} + +#[cfg(feature = "weixin")] +fn weixin_login_start_error(error: crate::plugins::weixin::WeixinLoginStartError) -> ApiError { + use crate::plugins::weixin::WeixinLoginStartError; + + match error { + WeixinLoginStartError::InProgress => ApiError::coded( + StatusCode::CONFLICT, + "WEIXIN_LOGIN_IN_PROGRESS", + "A WeChat QR login is already in progress for this user.", + None, + ), + WeixinLoginStartError::RateLimited { retry_after } => { + let retry_after_seconds = retry_after + .as_secs() + .saturating_add(u64::from(retry_after.subsec_nanos() > 0)) + .max(1); + ApiError::coded( + StatusCode::TOO_MANY_REQUESTS, + "WEIXIN_LOGIN_RATE_LIMITED", + "Please wait before starting another WeChat QR login.", + Some(serde_json::json!({ + "retryAfterSeconds": retry_after_seconds, + })), + ) + } + } } // --------------------------------------------------------------------------- @@ -902,6 +937,27 @@ mod tests { assert_eq!(err.error_code(), "CROSS_ACCOUNT_REFERENCE"); } + #[cfg(feature = "weixin")] + #[test] + fn active_weixin_login_maps_to_stable_conflict() { + let err = weixin_login_start_error(crate::plugins::weixin::WeixinLoginStartError::InProgress); + + assert_eq!(err.status_code(), StatusCode::CONFLICT); + assert_eq!(err.error_code(), "WEIXIN_LOGIN_IN_PROGRESS"); + } + + #[cfg(feature = "weixin")] + #[test] + fn repeated_weixin_login_maps_to_rate_limit_with_rounded_retry() { + let err = weixin_login_start_error(crate::plugins::weixin::WeixinLoginStartError::RateLimited { + retry_after: std::time::Duration::from_millis(1_001), + }); + + assert_eq!(err.status_code(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(err.error_code(), "WEIXIN_LOGIN_RATE_LIMITED"); + assert_eq!(err.error_details().unwrap()["retryAfterSeconds"], 2); + } + #[test] fn invalid_config_maps_to_bad_request() { let err = ApiError::from(ChannelError::InvalidConfig("missing token".into())); diff --git a/crates/aionui-channel/tests/manager_integration.rs b/crates/aionui-channel/tests/manager_integration.rs index 657d46fa3..041351c92 100644 --- a/crates/aionui-channel/tests/manager_integration.rs +++ b/crates/aionui-channel/tests/manager_integration.rs @@ -56,6 +56,7 @@ struct MockPlugin { last_error: Option, should_fail_init: bool, start_calls: Arc, + stop_calls: Arc, } impl MockPlugin { @@ -67,6 +68,7 @@ impl MockPlugin { last_error: None, should_fail_init: false, start_calls: Arc::new(AtomicUsize::new(0)), + stop_calls: Arc::new(AtomicUsize::new(0)), } } @@ -104,6 +106,7 @@ impl ChannelPlugin for MockPlugin { } async fn stop(&mut self) -> Result<(), ChannelError> { + self.stop_calls.fetch_add(1, Ordering::SeqCst); self.status = PluginStatus::Stopping; self.status = PluginStatus::Stopped; Ok(()) @@ -199,15 +202,18 @@ fn make_no_impl_factory() -> PluginFactory { Box::new(|_pt| None) } -fn make_counting_factory() -> (PluginFactory, Arc) { +fn make_lifecycle_counting_factory() -> (PluginFactory, Arc, Arc) { let start_calls = Arc::new(AtomicUsize::new(0)); - let captured = Arc::clone(&start_calls); + let stop_calls = Arc::new(AtomicUsize::new(0)); + let captured_start_calls = Arc::clone(&start_calls); + let captured_stop_calls = Arc::clone(&stop_calls); let factory = Box::new(move |pt| { let mut plugin = MockPlugin::new(pt); - plugin.start_calls = Arc::clone(&captured); + plugin.start_calls = Arc::clone(&captured_start_calls); + plugin.stop_calls = Arc::clone(&captured_stop_calls); Some(Box::new(plugin) as Box) }); - (factory, start_calls) + (factory, start_calls, stop_calls) } fn make_telegram_config() -> serde_json::Value { @@ -455,7 +461,7 @@ async fn tp1_test_valid_credentials() { #[tokio::test] async fn test_plugin_initializes_without_starting_runtime() { let (mgr, _repo, _bc) = setup().await; - let (factory, start_calls) = make_counting_factory(); + let (factory, start_calls, stop_calls) = make_lifecycle_counting_factory(); let username = mgr .test_plugin("telegram", make_plugin_config(), &factory) @@ -468,6 +474,11 @@ async fn test_plugin_initializes_without_starting_runtime() { 0, "credential tests must not start long-running plugin runtime" ); + assert_eq!( + stop_calls.load(Ordering::SeqCst), + 1, + "credential tests must always clean up the temporary plugin" + ); } // ── TP-2: Test invalid credentials propagates error ─────────────── diff --git a/crates/aionui-channel/tests/weixin_integration.rs b/crates/aionui-channel/tests/weixin_integration.rs index 3280a8627..40aa754d9 100644 --- a/crates/aionui-channel/tests/weixin_integration.rs +++ b/crates/aionui-channel/tests/weixin_integration.rs @@ -213,6 +213,20 @@ mod weixin_tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_plugin_rejects_custom_api_origin_before_polling() { + let (manager, _repo, _bc) = setup().await; + let factory = weixin_factory(); + let mut config = make_plugin_config(Some("tok_1"), Some("acc_1")); + config.credentials.extra.insert( + "baseUrl".into(), + serde_json::Value::String("http://169.254.169.254/latest/meta-data".into()), + ); + + let error = manager.test_plugin("weixin", config, &factory).await.unwrap_err(); + assert!(error.to_string().contains("Custom WeChat API endpoints")); + } + // -- EP-5: Invalid plugin type ------------------------------------------ #[tokio::test] diff --git a/crates/aionui-common/Cargo.toml b/crates/aionui-common/Cargo.toml index c3dafa39c..ccaefc650 100644 --- a/crates/aionui-common/Cargo.toml +++ b/crates/aionui-common/Cargo.toml @@ -14,6 +14,8 @@ axum.workspace = true base64.workspace = true getrandom.workspace = true semver.workspace = true +url.workspace = true +sha2.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/aionui-common/src/lib.rs b/crates/aionui-common/src/lib.rs index 350480be6..4c4dc0401 100644 --- a/crates/aionui-common/src/lib.rs +++ b/crates/aionui-common/src/lib.rs @@ -2,6 +2,7 @@ //! Shared primitives: error types, enums, ID generation, crypto, timestamps, and pagination. pub mod constants; +mod public_url; pub mod user_paths; mod case_convert; @@ -29,6 +30,10 @@ pub use error::{ pub use hooks::OnConversationDelete; pub use id::{fnv1a_hex8, generate_id, generate_id_with_length, generate_prefixed_id, generate_short_id}; pub use pagination::PaginatedResult; +pub use public_url::{ + PublicHttpUrlError, is_public_ip, validate_public_http_url, validate_public_http_url_value, + validate_public_resolved_addresses, +}; pub use timestamp::{TimestampMs, now_ms}; pub use types::{CommandSpec, Confirmation, ConfirmationOption, EnvVar, ProviderWithModel, UpdateType, VersionInfo}; -pub use user_paths::{UserDirNameError, user_dir_name}; +pub use user_paths::{UserDirNameError, user_dir_name, user_dir_name_or_fingerprint}; diff --git a/crates/aionui-common/src/public_url.rs b/crates/aionui-common/src/public_url.rs new file mode 100644 index 000000000..5aa181c04 --- /dev/null +++ b/crates/aionui-common/src/public_url.rs @@ -0,0 +1,206 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Validation errors for user-controlled HTTP endpoints that must stay on the +/// public internet in a multi-user server. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PublicHttpUrlError { + #[error("URL must be a valid http or https URL")] + InvalidUrl, + #[error("URL must use http or https")] + UnsupportedScheme, + #[error("URL must include a host")] + MissingHost, + #[error("URL must not include credentials")] + EmbeddedCredentials, + #[error("URL must not target a private or local network address")] + BlockedAddress, + #[error("hostname did not resolve to an address")] + NoResolvedAddresses, +} + +/// Parse a user-controlled provider URL and reject non-HTTP schemes, +/// credentials, localhost names, and non-public literal IP addresses. +/// +/// DNS names require a second check at the transport boundary using +/// [`validate_public_resolved_addresses`]. +pub fn validate_public_http_url(raw_url: &str) -> Result { + let url = url::Url::parse(raw_url.trim()).map_err(|_| PublicHttpUrlError::InvalidUrl)?; + validate_public_http_url_value(&url)?; + Ok(url) +} + +/// Apply public-network URL checks to an already parsed URL. This is useful for +/// validating every redirect target before it is followed. +pub fn validate_public_http_url_value(url: &url::Url) -> Result<(), PublicHttpUrlError> { + if !matches!(url.scheme(), "http" | "https") { + return Err(PublicHttpUrlError::UnsupportedScheme); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(PublicHttpUrlError::EmbeddedCredentials); + } + + match url.host().ok_or(PublicHttpUrlError::MissingHost)? { + url::Host::Domain(host) => { + let normalized_host = host.trim_end_matches('.').to_ascii_lowercase(); + if normalized_host == "localhost" || normalized_host.ends_with(".localhost") { + return Err(PublicHttpUrlError::BlockedAddress); + } + } + url::Host::Ipv4(address) => { + if !is_public_ipv4(address) { + return Err(PublicHttpUrlError::BlockedAddress); + } + } + url::Host::Ipv6(address) => { + if !is_public_ipv6(address) { + return Err(PublicHttpUrlError::BlockedAddress); + } + } + } + Ok(()) +} + +/// Reject a DNS answer if it is empty or contains any non-public address. +/// Rejecting the complete mixed answer prevents fallback from a public record +/// to a private one. +pub fn validate_public_resolved_addresses( + addresses: impl IntoIterator, +) -> Result<(), PublicHttpUrlError> { + let mut found = false; + for address in addresses { + found = true; + if !is_public_ip(address) { + return Err(PublicHttpUrlError::BlockedAddress); + } + } + if !found { + return Err(PublicHttpUrlError::NoResolvedAddresses); + } + Ok(()) +} + +/// Return whether an address is suitable for a public-only outbound request. +pub fn is_public_ip(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => is_public_ipv4(address), + IpAddr::V6(address) => is_public_ipv6(address), + } +} + +fn is_public_ipv4(address: Ipv4Addr) -> bool { + let [a, b, c, _d] = address.octets(); + !matches!( + (a, b, c), + (0, _, _) + | (10, _, _) + | (100, 64..=127, _) + | (127, _, _) + | (169, 254, _) + | (172, 16..=31, _) + | (192, 0, 0) + | (192, 0, 2) + | (192, 88, 99) + | (192, 168, _) + | (198, 18..=19, _) + | (198, 51, 100) + | (203, 0, 113) + | (224..=255, _, _) + ) +} + +fn is_public_ipv6(address: Ipv6Addr) -> bool { + if let Some(mapped) = address.to_ipv4_mapped() { + return is_public_ipv4(mapped); + } + + let segments = address.segments(); + // Globally routable unicast space is currently 2000::/3. This excludes + // unspecified, loopback, unique-local, link-local, and multicast ranges. + if segments[0] & 0xe000 != 0x2000 { + return false; + } + // Documentation and Teredo ranges are not valid direct provider targets. + if segments[0] == 0x2001 && matches!(segments[1], 0x0000 | 0x0db8) { + return false; + } + // 6to4 embeds an IPv4 destination; apply the IPv4 policy to it as well. + if segments[0] == 0x2002 { + let embedded = Ipv4Addr::new( + (segments[1] >> 8) as u8, + segments[1] as u8, + (segments[2] >> 8) as u8, + segments[2] as u8, + ); + return is_public_ipv4(embedded); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_addresses_are_allowed() { + for address in ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"] { + assert!(is_public_ip(address.parse().unwrap()), "{address} must be allowed"); + } + } + + #[test] + fn local_private_link_local_and_metadata_addresses_are_blocked() { + for address in [ + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.168.1.1", + "224.0.0.1", + "::", + "::1", + "fc00::1", + "fe80::1", + "ff02::1", + "::ffff:127.0.0.1", + ] { + assert!(!is_public_ip(address.parse().unwrap()), "{address} must be blocked"); + } + } + + #[test] + fn numeric_and_localhost_url_forms_are_blocked() { + for raw_url in [ + "http://localhost:8080", + "http://service.localhost", + "http://localhost.", + "http://127.0.0.1", + "http://127.1", + "http://2130706433", + "http://0x7f000001", + "http://0177.0.0.1", + "http://[::1]", + "http://169.254.169.254/latest/meta-data", + ] { + assert!(validate_public_http_url(raw_url).is_err(), "{raw_url} must be blocked"); + } + } + + #[test] + fn credentials_and_non_http_schemes_are_blocked() { + for raw_url in [ + "https://user:password@example.com", + "file:///etc/passwd", + "ftp://example.com", + ] { + assert!(validate_public_http_url(raw_url).is_err(), "{raw_url} must be blocked"); + } + } + + #[test] + fn mixed_public_and_private_dns_answer_is_blocked() { + let result = validate_public_resolved_addresses(["8.8.8.8".parse().unwrap(), "127.0.0.1".parse().unwrap()]); + assert_eq!(result, Err(PublicHttpUrlError::BlockedAddress)); + } +} diff --git a/crates/aionui-common/src/user_paths.rs b/crates/aionui-common/src/user_paths.rs index a09d479d9..7ffec7bf2 100644 --- a/crates/aionui-common/src/user_paths.rs +++ b/crates/aionui-common/src/user_paths.rs @@ -6,6 +6,9 @@ use std::fmt; +use base64::Engine; +use sha2::{Digest, Sha256}; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum UserDirNameError { Empty, @@ -37,6 +40,21 @@ pub fn user_dir_name(user_id: &str) -> Result { Ok(name.to_owned()) } +/// Return a bounded, filename-safe directory component for every user id. +/// +/// Normal internal ids retain their readable representation. Unsafe legacy or +/// external ids use a deterministic SHA-256 fingerprint instead of ever being +/// joined into a filesystem path verbatim. +pub fn user_dir_name_or_fingerprint(user_id: &str) -> String { + user_dir_name(user_id).unwrap_or_else(|_| { + let digest = Sha256::digest(user_id.as_bytes()); + format!( + "invalid-{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) + ) + }) +} + fn is_filename_safe(name: &str) -> bool { if name == "." || name == ".." { return false; @@ -69,4 +87,19 @@ mod tests { assert!(matches!(user_dir_name("user_.."), Err(UserDirNameError::Unsafe(_)))); assert!(matches!(user_dir_name("../etc"), Err(UserDirNameError::Unsafe(_)))); } + + #[test] + fn unsafe_ids_get_bounded_safe_fingerprints() { + let first = user_dir_name_or_fingerprint("../other"); + let repeated = user_dir_name_or_fingerprint("../other"); + let different = user_dir_name_or_fingerprint("../elsewhere"); + + assert_eq!(first, repeated); + assert_ne!(first, different); + assert!(first.starts_with("invalid-")); + assert!(!first.contains('/')); + assert!(!first.contains("..")); + assert!(first.len() < 64); + assert_eq!(user_dir_name_or_fingerprint("user_safe-id"), "safe-id"); + } } diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 6575559ab..8dfef7917 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -40,7 +40,7 @@ use aionui_db::{ }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; -use aionui_project::{ProjectService, ResolvedChatMessage, canonical}; +use aionui_project::{FileOp, ProjectError, ProjectService, ResolvedChatMessage, canonical}; use aionui_realtime::EventBroadcaster; use aionui_runtime::{RuntimeCommandProbe, probe_node_runtime_supported, probe_runtime_command, resolve_command_path}; use chrono::Datelike; @@ -455,6 +455,64 @@ impl ConversationService { } } + /// Validate a user-selected workspace against both runtime availability + /// and the active identity mode's filesystem boundary. Local desktop mode + /// intentionally retains host-path support; hosted sessions are confined + /// to the caller's managed root (plus the bootstrap operator workspace). + pub(crate) fn authorize_workspace_path(&self, user_id: &str, workspace: &str) -> Result { + let normalized = normalize_workspace_path(workspace)?; + let project = self + .project_service + .read() + .map_err(|_| ConversationError::internal("Project filesystem policy is unavailable"))? + .clone(); + let Some(project) = project else { + // Unit-level consumers predating project injection retain their + // existing local semantics. App composition always injects it. + return Ok(normalized); + }; + project + .authorize_user_path(user_id, Path::new(&normalized), FileOp::Browse, false) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| match error { + ProjectError::UserFilesystemDenied => ConversationError::Forbidden { + reason: "Workspace is outside the current user's managed filesystem".into(), + }, + _ => ConversationError::WorkspacePathUnavailable { path: normalized }, + }) + } + + /// Whether legacy desktop workspace browsing may follow directory + /// symlinks outside the selected root. Hosted sessions always fail closed. + pub(crate) fn allows_workspace_symlink_escape(&self) -> bool { + self.project_service + .read() + .ok() + .and_then(|guard| guard.clone()) + .is_none_or(|project| project.allows_host_paths()) + } + + fn authorize_workspace_extra(&self, user_id: &str, extra: &mut serde_json::Value) -> Result<(), ConversationError> { + let Some(obj) = extra.as_object_mut() else { + return Ok(()); + }; + let Some(workspace) = obj + .get("workspace") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + else { + return Ok(()); + }; + if workspace.is_empty() { + return Ok(()); + } + let authorized = self.authorize_workspace_path(user_id, &workspace)?; + if authorized != workspace { + obj.insert("workspace".to_owned(), serde_json::Value::String(authorized)); + } + Ok(()) + } + /// Project-bind side branch: resolve the owner's workspace into a /// project/folder and backfill `conversations.project_id`/`folder_id`. /// @@ -991,7 +1049,7 @@ impl ConversationService { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { - Some(workspace) => Some(normalize_workspace_path(workspace)?), + Some(workspace) => Some(self.authorize_workspace_path(user_id, workspace)?), None => None, }; if let Some(workspace) = user_supplied_workspace.as_ref() { @@ -2195,7 +2253,7 @@ impl ConversationService { warn!("aionrs update: stripped legacy `extra.model` from merged extra"); } if new_extra.get("workspace").is_some() { - normalize_workspace_extra(&mut existing_extra)?; + self.authorize_workspace_extra(user_id, &mut existing_extra)?; } Some( serde_json::to_string(&existing_extra) @@ -2319,7 +2377,7 @@ impl ConversationService { serde_json::from_str(&existing.extra).unwrap_or_else(|_| serde_json::json!({})); merge_json(&mut merged, &patch); if patch.get("workspace").is_some() { - normalize_workspace_extra(&mut merged)?; + self.authorize_workspace_extra(user_id, &mut merged)?; } let updates = ConversationRowUpdate { @@ -4032,9 +4090,12 @@ impl ConversationService { ) -> Result { reject_deprecated_runtime_row(row)?; let seed = self.load_aionrs_permission_seed(row).await?; - SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) - .build_options(row, seed) - .await + let options = + SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) + .build_options(row, seed) + .await?; + self.authorize_workspace_path(&row.user_id, &options.context.workspace.path)?; + Ok(options) } pub async fn build_task_options_for_runtime( @@ -4044,9 +4105,12 @@ impl ConversationService { ) -> Result { reject_deprecated_runtime_row(row)?; let seed = self.load_aionrs_permission_seed(row).await?; - SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) - .build_options_with_workspace_override(row, workspace_override, seed) - .await + let options = + SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) + .build_options_with_workspace_override(row, workspace_override, seed) + .await?; + self.authorize_workspace_path(&row.user_id, &options.context.workspace.path)?; + Ok(options) } /// Re-read the persisted resume anchor into a turn's `BuildTaskOptions` @@ -4298,28 +4362,6 @@ fn backfill_cron_job_id_alias(extra: &mut serde_json::Value) -> bool { mutated } -fn normalize_workspace_extra(extra: &mut serde_json::Value) -> Result<(), ConversationError> { - let Some(obj) = extra.as_object_mut() else { - return Ok(()); - }; - let Some(workspace) = obj - .get("workspace") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned) - else { - return Ok(()); - }; - if workspace.is_empty() { - return Ok(()); - } - - let normalized = normalize_workspace_path(&workspace)?; - if normalized != workspace.as_str() { - obj.insert("workspace".to_owned(), serde_json::Value::String(normalized)); - } - Ok(()) -} - fn strip_request_owner_user_id(extra: &mut serde_json::Value) { if let Some(obj) = extra.as_object_mut() { obj.remove("user_id"); @@ -4389,7 +4431,7 @@ fn expected_auto_workspace_path( } fn auto_workspace_parent(workspace_root: &Path, user_id: &str) -> PathBuf { - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); + let dir = aionui_common::user_dir_name_or_fingerprint(user_id); let now = chrono::Local::now(); workspace_root .join("conversations") diff --git a/crates/aionui-conversation/src/service_ops.rs b/crates/aionui-conversation/src/service_ops.rs index 5a53e36f6..5a8ee00e7 100644 --- a/crates/aionui-conversation/src/service_ops.rs +++ b/crates/aionui-conversation/src/service_ops.rs @@ -259,24 +259,26 @@ impl ConversationService { }); } - // Resolve the browsed path relative to the workspace root - let base = std::path::Path::new(&workspace); + // Re-authorize the persisted workspace at use time. This fails closed + // for rows created before hosted filesystem isolation was enabled. + let authorized_workspace = self.authorize_workspace_path(user_id, &workspace)?; + let base = std::path::Path::new(&authorized_workspace); let browse_path = if relative_path.is_empty() { base.to_path_buf() } else { base.join(relative_path_obj) }; - // Security: reject direct traversal outside the workspace root, but allow - // symlinked directories mounted inside the workspace (e.g. native skill - // dirs that point at the builtin skills corpus under data-dir). + // Hosted sessions must resolve inside the caller's workspace root. + // Local desktop mode retains legacy support for symlink-mounted skill + // directories outside that root. let canonical_base = base .canonicalize() .map_err(|e| ConversationError::internal(format!("Failed to resolve workspace path: {e}")))?; let canonical_browse = browse_path .canonicalize() .map_err(|_| ConversationError::not_found_reason("Directory not found"))?; - if !browse_path.starts_with(base) && !canonical_browse.starts_with(&canonical_base) { + if !canonical_browse.starts_with(&canonical_base) && !self.allows_workspace_symlink_escape() { return Err(ConversationError::BadRequest { reason: "Path traversal outside workspace is not allowed".into(), }); diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index 94844194c..44eaf0397 100644 --- a/crates/aionui-conversation/src/session_context.rs +++ b/crates/aionui-conversation/src/session_context.rs @@ -610,7 +610,7 @@ fn expected_auto_workspace_path( } fn auto_workspace_parent(workspace_root: &Path, user_id: &str) -> PathBuf { - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); + let dir = aionui_common::user_dir_name_or_fingerprint(user_id); let now = chrono::Local::now(); workspace_root .join("conversations") diff --git a/crates/aionui-cron/src/routes.rs b/crates/aionui-cron/src/routes.rs index 6e2ff2d7f..cfe1a5cde 100644 --- a/crates/aionui-cron/src/routes.rs +++ b/crates/aionui-cron/src/routes.rs @@ -55,7 +55,7 @@ impl From for ApiError { } pub fn cron_routes(state: CronRouterState) -> Router { - Router::new() + let routes = Router::new() .route("/api/cron/jobs", get(list_jobs).post(create_job)) .route("/api/cron/jobs/{id}", get(get_job).put(update_job).delete(delete_job)) .route("/api/cron/jobs/{id}/run", post(run_now)) @@ -65,13 +65,17 @@ pub fn cron_routes(state: CronRouterState) -> Router { "/api/internal/conversation-cron/jobs/{id}", put(update_conversation_cron), ) - .route("/api/cron/internal/system-resume", post(system_resume)) .route("/api/cron/jobs/{id}/conversations", get(list_conversations_by_cron_job)) .route( "/api/cron/jobs/{id}/skill", get(has_skill).post(save_skill).delete(delete_skill), - ) - .with_state(state) + ); + let routes = if state.allow_system_resume_http { + routes.route("/api/cron/internal/system-resume", post(system_resume)) + } else { + routes + }; + routes.with_state(state) } async fn create_job( @@ -293,6 +297,8 @@ mod tests { username: "alice".to_owned(), user_type: aionui_db::UserType::Aionpro, status: aionui_db::UserStatus::Active, + site_role: aionui_db::SiteRole::Member, + must_change_password: false, }; assert!(matches!( @@ -313,6 +319,8 @@ mod tests { username: "alice".to_owned(), user_type: aionui_db::UserType::Aionpro, status: aionui_db::UserStatus::Active, + site_role: aionui_db::SiteRole::Member, + must_change_password: false, }; assert_eq!(trusted_header_user_id(&headers, ¤t_user).unwrap(), "user_a"); diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 4d04257c5..382da0c8d 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -454,6 +454,13 @@ impl CronService { } pub async fn remove_job(&self, user_id: &str, job_id: &str) -> Result<(), CronError> { + // Verify ownership before touching the shared scheduler or on-disk + // skill. A guessed cross-account id must have no observable side + // effects, while the owner keeps the original cleanup-before-row-delete + // ordering so a process interruption cannot strand a live timer. + if self.repo.get_by_id_for_user(user_id, job_id).await?.is_none() { + return Err(CronError::Database(DbError::NotFound(format!("cron job '{job_id}'")))); + } self.scheduler.cancel_job(job_id); if let Err(err) = delete_skill_file(&self.data_dir, job_id).await { warn!(job_id, error = %err, "Failed to delete cron skill file during job removal"); diff --git a/crates/aionui-cron/src/state.rs b/crates/aionui-cron/src/state.rs index 317612abe..70fd83409 100644 --- a/crates/aionui-cron/src/state.rs +++ b/crates/aionui-cron/src/state.rs @@ -8,4 +8,8 @@ use crate::service::CronService; pub struct CronRouterState { pub cron_service: Arc, pub conversation_service: ConversationService, + /// Whether the desktop-only system-resume HTTP bridge is registered. + /// Server identity modes must leave this disabled because HTTP headers are + /// not a trustworthy internal-call boundary. + pub allow_system_resume_http: bool, } diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 652c99ce1..96b0136eb 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -53,6 +53,8 @@ fn current_user(id: &str) -> CurrentUser { username: id.to_owned(), user_type: aionui_db::UserType::Local, status: aionui_db::UserStatus::Active, + site_role: aionui_db::SiteRole::Member, + must_change_password: false, } } @@ -882,7 +884,8 @@ async fn setup_with_conv_runtime() -> ( Arc, Arc, ) { - let (svc, cron_repo, bc, stub_conv_repo, conv_service, _, _) = setup_with_conv_runtime_and_agent_metadata().await; + let (svc, cron_repo, bc, stub_conv_repo, conv_service, _, _, _) = + setup_with_conv_runtime_and_agent_metadata().await; (svc, cron_repo, bc, stub_conv_repo, conv_service) } @@ -894,6 +897,7 @@ async fn setup_with_conv_runtime_and_agent_metadata() -> ( Arc, Arc, Arc, + Arc, ) { let db = init_database_memory().await.unwrap(); let pool = db.pool().clone(); @@ -973,7 +977,7 @@ async fn setup_with_conv_runtime_and_agent_metadata() -> ( assistant_definition_repo: assistant_definition_repo.clone(), assistant_overlay_repo: assistant_overlay_repo.clone(), skill_repo: skill_repo.clone(), - scheduler, + scheduler: scheduler.clone(), executor, emitter, data_dir, @@ -997,11 +1001,12 @@ async fn setup_with_conv_runtime_and_agent_metadata() -> ( conv_service, agent_metadata_repo, skill_repo, + scheduler, ) } async fn setup_with_skill_repo() -> (CronService, Arc) { - let (svc, _, _, _, _, _, skill_repo) = setup_with_conv_runtime_and_agent_metadata().await; + let (svc, _, _, _, _, _, skill_repo, _) = setup_with_conv_runtime_and_agent_metadata().await; (svc, skill_repo) } @@ -2114,6 +2119,40 @@ async fn cj12_delete_nonexistent() { )); } +#[tokio::test] +async fn cj12_cross_user_delete_has_no_scheduler_or_skill_side_effects() { + let (svc, _, _, _, _, _, _, scheduler) = setup_with_conv_runtime_and_agent_metadata().await; + let created = svc + .add_job("u1", make_create_req("Private job", every_60s())) + .await + .unwrap(); + svc.save_skill( + "u1", + &created.id, + SaveCronSkillRequest { + content: "---\nname: private\ndescription: owner only\n---\nKeep me".into(), + }, + ) + .await + .unwrap(); + assert!(scheduler.is_scheduled(&created.id)); + + let error = svc + .remove_job("other-user", &created.id) + .await + .expect_err("cross-user delete must fail"); + assert!(matches!( + error, + aionui_cron::error::CronError::Database(aionui_db::DbError::NotFound(_)) + )); + assert!( + scheduler.is_scheduled(&created.id), + "foreign timer must remain scheduled" + ); + assert!(svc.has_skill("u1", &created.id).await.unwrap().has_skill); + assert_eq!(svc.get_job("u1", &created.id).await.unwrap().id, created.id); +} + // ── SK-1: Save skill ────────────────────────────────────────────── #[tokio::test] @@ -2782,7 +2821,7 @@ async fn create_for_conversation_helper_uses_assistant_metadata_full_auto_mode() #[tokio::test] async fn create_for_conversation_helper_uses_codex_canonical_full_auto_mode_from_fallback() { - let (svc, cron_repo, _, _, conv_service, agent_metadata_repo, _) = + let (svc, cron_repo, _, _, conv_service, agent_metadata_repo, _, _) = setup_with_conv_runtime_and_agent_metadata().await; let codex = agent_metadata_repo .find_builtin_by_backend("codex") @@ -2858,6 +2897,48 @@ async fn create_for_conversation_helper_fails_when_conversation_binding_fails() assert!(rows.is_empty()); } +#[tokio::test] +async fn system_resume_http_route_is_registered_only_for_local_mode() { + let (svc, _, _, _, conv_service) = setup_with_conv_runtime().await; + let cron_service = Arc::new(svc); + + let server_app = cron_routes(CronRouterState { + cron_service: cron_service.clone(), + conversation_service: (*conv_service).clone(), + allow_system_resume_http: false, + }); + let server_response = server_app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/cron/internal/system-resume") + .header("x-aionui-internal", "1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(server_response.status(), StatusCode::NOT_FOUND); + + let local_app = cron_routes(CronRouterState { + cron_service, + conversation_service: (*conv_service).clone(), + allow_system_resume_http: true, + }); + let local_response = local_app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/cron/internal/system-resume") + .header("x-aionui-internal", "1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(local_response.status(), StatusCode::OK); +} + #[tokio::test] async fn conversation_cron_routes_create_list_and_update_claimed_job() { let (svc, cron_repo, _, _, conv_service) = setup_with_conv_runtime().await; @@ -2869,6 +2950,7 @@ async fn conversation_cron_routes_create_list_and_update_claimed_job() { let app = cron_routes(CronRouterState { cron_service: Arc::new(svc), conversation_service: (*conv_service).clone(), + allow_system_resume_http: false, }) .layer(Extension(current_user("u1"))); @@ -2954,6 +3036,7 @@ async fn conversation_cron_routes_reject_missing_headers_unclaimed_and_wrong_use let app = cron_routes(CronRouterState { cron_service: Arc::new(svc), conversation_service: (*conv_service).clone(), + allow_system_resume_http: false, }) .layer(Extension(current_user("u1"))); diff --git a/crates/aionui-db/migrations/038_multi_user_identity_foundation.sql b/crates/aionui-db/migrations/038_multi_user_identity_foundation.sql new file mode 100644 index 000000000..61dcce611 --- /dev/null +++ b/crates/aionui-db/migrations/038_multi_user_identity_foundation.sql @@ -0,0 +1,55 @@ +-- Phase 1 multi-user identity foundation. + +ALTER TABLE users + ADD COLUMN site_role TEXT NOT NULL DEFAULT 'member' + CHECK (site_role IN ('admin', 'member')); + +ALTER TABLE users + ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 + CHECK (must_change_password IN (0, 1)); + +-- The legacy/bootstrap identity is the initial site administrator. +UPDATE users +SET site_role = 'admin' +WHERE id = 'system_default_user'; + +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + revoke_reason TEXT +); + +CREATE INDEX idx_auth_sessions_user_active + ON auth_sessions(user_id, revoked_at, expires_at); + +CREATE TABLE admin_audit_log ( + id TEXT PRIMARY KEY, + occurred_at INTEGER NOT NULL, + actor_user_id TEXT, + actor_username TEXT, + action TEXT NOT NULL, + target_user_id TEXT, + target_username TEXT, + details TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(details)) +); + +CREATE INDEX idx_admin_audit_log_cursor + ON admin_audit_log(occurred_at DESC, id DESC); + +-- Audit history is append-only, including for direct database clients. +CREATE TRIGGER admin_audit_log_no_update +BEFORE UPDATE ON admin_audit_log +BEGIN + SELECT RAISE(ABORT, 'admin audit log is append-only'); +END; + +CREATE TRIGGER admin_audit_log_no_delete +BEFORE DELETE ON admin_audit_log +BEGIN + SELECT RAISE(ABORT, 'admin audit log is append-only'); +END; diff --git a/crates/aionui-db/migrations/039_user_scope_assistant_ids.sql b/crates/aionui-db/migrations/039_user_scope_assistant_ids.sql new file mode 100644 index 000000000..a587aa8a2 --- /dev/null +++ b/crates/aionui-db/migrations/039_user_scope_assistant_ids.sql @@ -0,0 +1,42 @@ +-- Make legacy user-authored assistant ids tenant-local. The unified +-- assistant_definitions table is already scoped by (user_id, assistant_id), +-- but this compatibility table retained its original global PRIMARY KEY(id), +-- allowing one account to reserve another account's id. + +CREATE TABLE assistants_user_scoped ( + id TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'system_default_user' REFERENCES users(id), + name TEXT NOT NULL, + description TEXT, + avatar TEXT, + enabled_skills TEXT, + custom_skill_names TEXT, + disabled_builtin_skills TEXT, + prompts TEXT, + models TEXT, + name_i18n TEXT, + description_i18n TEXT, + prompts_i18n TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, id) +); + +INSERT INTO assistants_user_scoped ( + id, user_id, name, description, avatar, enabled_skills, + custom_skill_names, disabled_builtin_skills, prompts, models, + name_i18n, description_i18n, prompts_i18n, created_at, updated_at +) +SELECT + id, COALESCE(user_id, 'system_default_user'), name, description, avatar, enabled_skills, + custom_skill_names, disabled_builtin_skills, prompts, models, + name_i18n, description_i18n, prompts_i18n, created_at, updated_at +FROM assistants; + +DROP TABLE assistants; +ALTER TABLE assistants_user_scoped RENAME TO assistants; + +CREATE INDEX idx_assistants_updated_at + ON assistants(updated_at DESC); +CREATE INDEX idx_assistants_user_updated_at + ON assistants(user_id, updated_at DESC); diff --git a/crates/aionui-db/migrations/040_resource_collaboration.sql b/crates/aionui-db/migrations/040_resource_collaboration.sql new file mode 100644 index 000000000..a701abe7e --- /dev/null +++ b/crates/aionui-db/migrations/040_resource_collaboration.sql @@ -0,0 +1,17 @@ +-- Phase 2 multi-user resource collaboration (explicit shares). + +CREATE TABLE resource_shares ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL CHECK (resource_type IN ('conversation','project','provider')), + resource_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + grantee_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK (permission IN ('view','edit')), + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + UNIQUE(resource_type, resource_id, grantee_user_id), + CHECK (owner_user_id != grantee_user_id) +); + +CREATE INDEX idx_resource_shares_grantee ON resource_shares(grantee_user_id, resource_type); +CREATE INDEX idx_resource_shares_resource ON resource_shares(resource_type, resource_id); diff --git a/crates/aionui-db/src/database.rs b/crates/aionui-db/src/database.rs index dbafe5d83..1895bc21e 100644 --- a/crates/aionui-db/src/database.rs +++ b/crates/aionui-db/src/database.rs @@ -655,8 +655,9 @@ async fn align_reconciled_mcp_migration_checksum(conn: &mut sqlx::SqliteConnecti async fn ensure_system_user(pool: &SqlitePool) -> Result<(), DbError> { let now = aionui_common::now_ms(); sqlx::query( - "INSERT OR IGNORE INTO users (id, username, password_hash, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO users \ + (id, username, password_hash, site_role, must_change_password, created_at, updated_at) \ + VALUES (?, ?, ?, 'admin', 0, ?, ?)", ) .bind("system_default_user") .bind("admin") diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index b73cbe983..323cc74de 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -24,11 +24,12 @@ pub use error::{ }; pub use instance_lock::{DataDirInstanceGuard, instance_lock_path}; pub use models::{ - AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, - AssistantRow, ConversationArtifactRow, ConversationAssistantSnapshotRow, CreateAssistantParams, - ExternalUserProjection, FolderRow, ProjectExplorerRow, ProjectKind, ProjectRow, Role, SkillImportRecordRow, - SkillRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpdateAssistantParams, - UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, + AdminAuditRecord, AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, + AssistantPreferenceRow, AssistantRow, AuditActor, ConversationArtifactRow, ConversationAssistantSnapshotRow, + CreateAssistantParams, ExternalUserProjection, FolderRow, GrantShareParams, ProjectExplorerRow, ProjectKind, + ProjectRow, ResourceAccess, ResourceShareRow, Role, SharePermission, ShareResourceType, SiteRole, + SkillImportRecordRow, SkillRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, + UpdateAssistantParams, UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, UpsertOverrideParams, UserStatus, UserType, }; @@ -47,20 +48,21 @@ pub use repository::remote_agent::{CreateRemoteAgentParams, UpdateRemoteAgentPar pub use repository::skill::{CreateSkillImportRecordParams, UpsertSkillParams}; pub use repository::team::{UpdateTaskParams, UpdateTeamParams}; pub use repository::{ - ActivityCursor, CreateAcpSessionParams, FeedbackDiagnosticsDbContext, FeedbackDiagnosticsProfile, - FeedbackDiagnosticsProfileResult, FeedbackDiagnosticsRequest, FeedbackDiagnosticsResult, IAcpSessionRepository, - IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, - IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IChannelRepository, - IClientPreferenceRepository, IConversationRepository, ICronRepository, IFeedbackDiagnosticsRepository, - IMcpServerRepository, IOAuthTokenRepository, IProjectStore, IProviderRepository, IRemoteAgentRepository, + ActivityCursor, AdminUserRepositoryError, CreateAcpSessionParams, FeedbackDiagnosticsDbContext, + FeedbackDiagnosticsProfile, FeedbackDiagnosticsProfileResult, FeedbackDiagnosticsRequest, + FeedbackDiagnosticsResult, IAcpSessionRepository, IAdminUserRepository, IAgentMetadataRepository, + IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantOverrideRepository, + IAssistantPreferenceRepository, IAssistantRepository, IChannelRepository, IClientPreferenceRepository, + IConversationRepository, ICronRepository, IFeedbackDiagnosticsRepository, IMcpServerRepository, + IOAuthTokenRepository, IProjectStore, IProviderRepository, IRemoteAgentRepository, IResourceShareRepository, ISettingsRepository, ISkillRepository, ITeamRepository, IUserRepository, PageDirection, PersistedSessionState, SaveRuntimeStateParams, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantOverrideRepository, SqliteAssistantPreferenceRepository, SqliteAssistantRepository, SqliteChannelRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, SqliteCronRepository, SqliteFeedbackDiagnosticsRepository, SqliteMcpServerRepository, SqliteOAuthTokenRepository, SqliteProjectStore, - SqliteProviderRepository, SqliteRemoteAgentRepository, SqliteSettingsRepository, SqliteSkillRepository, - SqliteTeamRepository, SqliteUserRepository, + SqliteProviderRepository, SqliteRemoteAgentRepository, SqliteResourceShareRepository, SqliteSettingsRepository, + SqliteSkillRepository, SqliteTeamRepository, SqliteUserRepository, }; // Re-export sqlx pool type for downstream crates diff --git a/crates/aionui-db/src/models/mod.rs b/crates/aionui-db/src/models/mod.rs index 2c5ac5953..40425cd8a 100644 --- a/crates/aionui-db/src/models/mod.rs +++ b/crates/aionui-db/src/models/mod.rs @@ -12,6 +12,7 @@ mod oauth_token; mod project; mod provider; mod remote_agent; +mod resource_share; mod skill; mod system_settings; mod team; @@ -37,7 +38,8 @@ pub use oauth_token::OAuthTokenRow; pub use project::{FolderRow, ProjectExplorerRow, ProjectKind, ProjectRow, Role}; pub use provider::Provider; pub use remote_agent::RemoteAgentRow; +pub use resource_share::{GrantShareParams, ResourceAccess, ResourceShareRow, SharePermission, ShareResourceType}; pub use skill::{SkillImportRecordRow, SkillRow}; pub use system_settings::SystemSettings; pub use team::{MailboxMessageRow, TeamRow, TeamTaskRow}; -pub use user::{ExternalUserProjection, User, UserStatus, UserType}; +pub use user::{AdminAuditRecord, AuditActor, ExternalUserProjection, SiteRole, User, UserStatus, UserType}; diff --git a/crates/aionui-db/src/models/resource_share.rs b/crates/aionui-db/src/models/resource_share.rs new file mode 100644 index 000000000..c6319d289 --- /dev/null +++ b/crates/aionui-db/src/models/resource_share.rs @@ -0,0 +1,156 @@ +use aionui_common::TimestampMs; +use serde::{Deserialize, Serialize}; + +/// Resource types that support explicit multi-user sharing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum ShareResourceType { + Conversation, + Project, + Provider, +} + +impl ShareResourceType { + pub fn as_str(self) -> &'static str { + match self { + Self::Conversation => "conversation", + Self::Project => "project", + Self::Provider => "provider", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "conversation" => Some(Self::Conversation), + "project" => Some(Self::Project), + "provider" => Some(Self::Provider), + _ => None, + } + } +} + +/// Permission granted by a resource share. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum SharePermission { + View, + Edit, +} + +impl SharePermission { + pub fn as_str(self) -> &'static str { + match self { + Self::View => "view", + Self::Edit => "edit", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "view" => Some(Self::View), + "edit" => Some(Self::Edit), + _ => None, + } + } + + /// True when this permission includes write/mutate rights. + pub fn allows_edit(self) -> bool { + matches!(self, Self::Edit) + } +} + +/// Resolved access level for a user against a resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResourceAccess { + Owner, + View, + Edit, + None, +} + +impl ResourceAccess { + pub fn allows_read(self) -> bool { + !matches!(self, Self::None) + } + + pub fn allows_edit(self) -> bool { + matches!(self, Self::Owner | Self::Edit) + } + + pub fn is_owner(self) -> bool { + matches!(self, Self::Owner) + } +} + +/// Row mapping for the `resource_shares` table. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ResourceShareRow { + pub id: String, + pub resource_type: String, + pub resource_id: String, + pub owner_user_id: String, + pub grantee_user_id: String, + pub permission: String, + pub created_at: TimestampMs, + pub created_by: String, +} + +impl ResourceShareRow { + pub fn resource_type(&self) -> Option { + ShareResourceType::parse(&self.resource_type) + } + + pub fn permission(&self) -> Option { + SharePermission::parse(&self.permission) + } +} + +/// Parameters for granting a share. +#[derive(Debug, Clone)] +pub struct GrantShareParams<'a> { + pub resource_type: ShareResourceType, + pub resource_id: &'a str, + pub owner_user_id: &'a str, + pub grantee_user_id: &'a str, + pub permission: SharePermission, + pub created_by: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_type_roundtrips() { + for t in [ + ShareResourceType::Conversation, + ShareResourceType::Project, + ShareResourceType::Provider, + ] { + assert_eq!(ShareResourceType::parse(t.as_str()), Some(t)); + } + assert_eq!(ShareResourceType::parse("unknown"), None); + } + + #[test] + fn permission_roundtrips_and_edit_flag() { + assert!(SharePermission::Edit.allows_edit()); + assert!(!SharePermission::View.allows_edit()); + assert_eq!(SharePermission::parse("view"), Some(SharePermission::View)); + assert_eq!(SharePermission::parse("edit"), Some(SharePermission::Edit)); + } + + #[test] + fn resource_access_flags() { + assert!(ResourceAccess::Owner.allows_read()); + assert!(ResourceAccess::Owner.allows_edit()); + assert!(ResourceAccess::Owner.is_owner()); + assert!(ResourceAccess::View.allows_read()); + assert!(!ResourceAccess::View.allows_edit()); + assert!(ResourceAccess::Edit.allows_edit()); + assert!(!ResourceAccess::None.allows_read()); + } +} diff --git a/crates/aionui-db/src/models/user.rs b/crates/aionui-db/src/models/user.rs index 2cfa9dcbd..1065ff254 100644 --- a/crates/aionui-db/src/models/user.rs +++ b/crates/aionui-db/src/models/user.rs @@ -26,6 +26,23 @@ pub enum UserStatus { Disabled, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum SiteRole { + Admin, + Member, +} + +impl SiteRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Member => "member", + } + } +} + impl UserStatus { pub fn as_str(self) -> &'static str { match self { @@ -50,12 +67,41 @@ pub struct User { pub avatar_path: Option, pub jwt_secret: Option, pub status: UserStatus, + pub site_role: SiteRole, + pub must_change_password: bool, pub session_generation: i64, pub created_at: TimestampMs, pub updated_at: TimestampMs, pub last_login: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditActor { + pub user_id: Option, + pub username: Option, +} + +impl AuditActor { + pub fn system() -> Self { + Self { + user_id: None, + username: None, + } + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct AdminAuditRecord { + pub id: String, + pub occurred_at: TimestampMs, + pub actor_user_id: Option, + pub actor_username: Option, + pub action: String, + pub target_user_id: Option, + pub target_username: Option, + pub details: String, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ExternalUserProjection { pub username: Option, diff --git a/crates/aionui-db/src/repository/admin_user.rs b/crates/aionui-db/src/repository/admin_user.rs new file mode 100644 index 000000000..6f1c5e7a5 --- /dev/null +++ b/crates/aionui-db/src/repository/admin_user.rs @@ -0,0 +1,88 @@ +use crate::error::DbError; +use crate::models::{AdminAuditRecord, AuditActor, SiteRole, User, UserStatus}; + +#[derive(Debug, thiserror::Error)] +pub enum AdminUserRepositoryError { + #[error("database error: {0}")] + Database(#[from] DbError), + #[error("the last active administrator cannot be demoted or disabled")] + LastActiveAdmin, + #[error("the operation is only supported for local password users")] + UnsupportedIdentity, +} + +impl From for AdminUserRepositoryError { + fn from(error: sqlx::Error) -> Self { + Self::Database(DbError::Query(error)) + } +} + +#[async_trait::async_trait] +pub trait IAdminUserRepository: Send + Sync { + async fn list_managed_users(&self, limit: i64, offset: i64) -> Result, DbError>; + async fn count_managed_users(&self) -> Result; + + async fn create_managed_user( + &self, + username: &str, + password_hash: &str, + role: SiteRole, + actor: &AuditActor, + ) -> Result; + + async fn update_managed_username( + &self, + user_id: &str, + username: &str, + actor: &AuditActor, + ) -> Result; + + async fn update_managed_role( + &self, + user_id: &str, + role: SiteRole, + actor: &AuditActor, + ) -> Result; + + async fn update_managed_status( + &self, + user_id: &str, + status: UserStatus, + actor: &AuditActor, + ) -> Result; + + async fn reset_managed_password( + &self, + user_id: &str, + password_hash: &str, + actor: &AuditActor, + ) -> Result; + + async fn change_own_password( + &self, + user_id: &str, + password_hash: &str, + actor: &AuditActor, + ) -> Result; + + async fn revoke_managed_sessions( + &self, + user_id: &str, + actor: &AuditActor, + ) -> Result; + + /// Atomically creates the first usable administrator, if one does not + /// already exist. The returned `None` means another process/admin won. + async fn bootstrap_initial_admin( + &self, + username: &str, + password_hash: &str, + ) -> Result, AdminUserRepositoryError>; + + /// Returns one extra row when a next page exists. + async fn list_admin_audit( + &self, + cursor: Option<&str>, + limit_plus_one: i64, + ) -> Result, DbError>; +} diff --git a/crates/aionui-db/src/repository/mod.rs b/crates/aionui-db/src/repository/mod.rs index 09613c96d..0dcefc573 100644 --- a/crates/aionui-db/src/repository/mod.rs +++ b/crates/aionui-db/src/repository/mod.rs @@ -1,4 +1,5 @@ pub mod acp_session; +mod admin_user; pub mod agent_metadata; pub mod assistant; pub mod channel; @@ -12,9 +13,11 @@ pub mod oauth_token; pub mod project; pub mod provider; pub mod remote_agent; +pub mod resource_share; mod settings; pub mod skill; mod sqlite_acp_session; +mod sqlite_admin_user; mod sqlite_agent_metadata; mod sqlite_assistant; mod sqlite_channel; @@ -27,6 +30,7 @@ mod sqlite_oauth_token; mod sqlite_project; mod sqlite_provider; mod sqlite_remote_agent; +mod sqlite_resource_share; mod sqlite_settings; mod sqlite_skill; mod sqlite_team; @@ -35,6 +39,7 @@ pub mod team; mod user; pub use acp_session::{CreateAcpSessionParams, IAcpSessionRepository, PersistedSessionState, SaveRuntimeStateParams}; +pub use admin_user::{AdminUserRepositoryError, IAdminUserRepository}; pub use agent_metadata::IAgentMetadataRepository; pub use assistant::{ IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantOverrideRepository, @@ -53,6 +58,7 @@ pub use oauth_token::IOAuthTokenRepository; pub use project::IProjectStore; pub use provider::IProviderRepository; pub use remote_agent::IRemoteAgentRepository; +pub use resource_share::IResourceShareRepository; pub use settings::ISettingsRepository; pub use skill::ISkillRepository; pub use sqlite_acp_session::SqliteAcpSessionRepository; @@ -71,6 +77,7 @@ pub use sqlite_oauth_token::SqliteOAuthTokenRepository; pub use sqlite_project::SqliteProjectStore; pub use sqlite_provider::SqliteProviderRepository; pub use sqlite_remote_agent::SqliteRemoteAgentRepository; +pub use sqlite_resource_share::SqliteResourceShareRepository; pub use sqlite_settings::SqliteSettingsRepository; pub use sqlite_skill::SqliteSkillRepository; pub use sqlite_team::SqliteTeamRepository; diff --git a/crates/aionui-db/src/repository/project.rs b/crates/aionui-db/src/repository/project.rs index e586e32c4..3bba8e539 100644 --- a/crates/aionui-db/src/repository/project.rs +++ b/crates/aionui-db/src/repository/project.rs @@ -23,8 +23,12 @@ pub trait IProjectStore: Send + Sync { async fn upsert_folder(&self, canonical: &str, raw_uri: &str) -> Result; async fn get_folder(&self, folder_id: &str) -> Result, DbError>; + /// Returns a project when `user_id` is the owner or holds any share. async fn get_project(&self, user_id: &str, project_id: &str) -> Result, DbError>; + /// Owner of a project, if it exists. + async fn project_owner_user_id(&self, project_id: &str) -> Result, DbError>; + /// The workspace entry (if any) of `user_id` whose folder is `folder_id`. /// At most one exists per owner (enforced by /// `UNIQUE(owner_user_id, folder_id) WHERE role = 'workspace'`). @@ -34,10 +38,11 @@ pub trait IProjectStore: Send + Sync { folder_id: &str, ) -> Result, DbError>; + /// Entry readable when the caller owns it or holds a share on its project. async fn get_entry(&self, user_id: &str, pe_id: &str) -> Result, DbError>; - /// All explorer entries of a project owned by `user_id`, each joined with - /// its folder, ordered by `order_index`. + /// All explorer entries of a project the caller can read (owner or share), + /// each joined with its folder, ordered by `order_index`. async fn list_entries( &self, user_id: &str, diff --git a/crates/aionui-db/src/repository/resource_share.rs b/crates/aionui-db/src/repository/resource_share.rs new file mode 100644 index 000000000..b92dddf3a --- /dev/null +++ b/crates/aionui-db/src/repository/resource_share.rs @@ -0,0 +1,58 @@ +use crate::error::DbError; +use crate::models::{GrantShareParams, ResourceAccess, ResourceShareRow, SharePermission, ShareResourceType}; + +/// Resource share data access abstraction. +/// +/// Explicit grants only: resources stay private unless the owner creates a share. +#[async_trait::async_trait] +pub trait IResourceShareRepository: Send + Sync { + /// Grant a share (or upsert permission when the grantee already has one). + async fn grant(&self, params: GrantShareParams<'_>) -> Result; + + /// Revoke a share by id. Returns `NotFound` when missing. + async fn revoke(&self, share_id: &str) -> Result<(), DbError>; + + /// List shares for a specific resource (owner view). + async fn list_for_resource( + &self, + resource_type: ShareResourceType, + resource_id: &str, + ) -> Result, DbError>; + + /// Shares granted by this owner (across all resource types). + async fn list_granted_by(&self, owner_user_id: &str) -> Result, DbError>; + + /// Shares received by this grantee (across all resource types). + async fn list_received_by(&self, grantee_user_id: &str) -> Result, DbError>; + + /// Permission granted to `grantee_user_id` for the resource, if any. + /// + /// Does not treat ownership as a permission row — use [`resolve_access`]. + async fn get_permission( + &self, + resource_type: ShareResourceType, + resource_id: &str, + grantee_user_id: &str, + ) -> Result, DbError>; + + /// Resolve effective access for `user_id`: owner | view | edit | none. + /// + /// Returns `None` both when the resource is missing and when the user has + /// no grant (callers should not distinguish these to avoid existence leaks). + async fn resolve_access( + &self, + resource_type: ShareResourceType, + resource_id: &str, + user_id: &str, + ) -> Result; + + /// Owner user id for a resource, if the resource exists. + async fn resource_owner( + &self, + resource_type: ShareResourceType, + resource_id: &str, + ) -> Result, DbError>; + + /// Load a share by primary key. + async fn find_by_id(&self, share_id: &str) -> Result, DbError>; +} diff --git a/crates/aionui-db/src/repository/sqlite_admin_user.rs b/crates/aionui-db/src/repository/sqlite_admin_user.rs new file mode 100644 index 000000000..ab439de03 --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_admin_user.rs @@ -0,0 +1,611 @@ +use sqlx::{Sqlite, Transaction}; + +use crate::error::DbError; +use crate::models::{AdminAuditRecord, AuditActor, SiteRole, User, UserStatus, UserType}; +use crate::repository::{AdminUserRepositoryError, IAdminUserRepository, SqliteUserRepository}; + +#[async_trait::async_trait] +impl IAdminUserRepository for SqliteUserRepository { + async fn list_managed_users(&self, limit: i64, offset: i64) -> Result, DbError> { + Ok(sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE user_type = 'local' ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await?) + } + + async fn count_managed_users(&self) -> Result { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE user_type = 'local'") + .fetch_one(&self.pool) + .await?, + ) + } + + async fn create_managed_user( + &self, + username: &str, + password_hash: &str, + role: SiteRole, + actor: &AuditActor, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + let id = aionui_common::generate_prefixed_id("user"); + let now = aionui_common::now_ms(); + sqlx::query( + "INSERT INTO users \ + (id, user_type, username, password_hash, status, site_role, must_change_password, \ + session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, ?, 'active', ?, 1, 0, ?, ?)", + ) + .bind(&id) + .bind(username) + .bind(password_hash) + .bind(role.as_str()) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await + .map_err(map_write_error)?; + append_audit( + &mut tx, + actor, + "user.created", + Some(&id), + Some(username), + serde_json::json!({ "role": role.as_str() }), + ) + .await?; + let user = load_user(&mut tx, &id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) + } + + async fn update_managed_username( + &self, + user_id: &str, + username: &str, + actor: &AuditActor, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + acquire_user_write_lock(&mut tx, user_id).await?; + let before = load_user(&mut tx, user_id).await?; + ensure_local_identity(&before)?; + sqlx::query( + "UPDATE users SET username = ?, session_generation = session_generation + 1, updated_at = ? \ + WHERE id = ?", + ) + .bind(username) + .bind(aionui_common::now_ms()) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(map_write_error)?; + revoke_sessions(&mut tx, user_id, "username_changed").await?; + append_audit( + &mut tx, + actor, + "user.username_changed", + Some(user_id), + Some(username), + serde_json::json!({ "from": before.username, "to": username }), + ) + .await?; + let user = load_user(&mut tx, user_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) + } + + async fn update_managed_role( + &self, + user_id: &str, + role: SiteRole, + actor: &AuditActor, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + acquire_user_write_lock(&mut tx, user_id).await?; + let before = load_user(&mut tx, user_id).await?; + ensure_local_identity(&before)?; + if before.site_role == SiteRole::Admin && role != SiteRole::Admin { + ensure_another_active_admin(&mut tx, user_id).await?; + } + sqlx::query( + "UPDATE users SET site_role = ?, session_generation = session_generation + 1, updated_at = ? \ + WHERE id = ?", + ) + .bind(role.as_str()) + .bind(aionui_common::now_ms()) + .bind(user_id) + .execute(&mut *tx) + .await?; + revoke_sessions(&mut tx, user_id, "role_changed").await?; + append_audit( + &mut tx, + actor, + "user.site_role_changed", + Some(user_id), + before.username.as_deref(), + serde_json::json!({ "from": before.site_role.as_str(), "to": role.as_str() }), + ) + .await?; + let user = load_user(&mut tx, user_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) + } + + async fn update_managed_status( + &self, + user_id: &str, + status: UserStatus, + actor: &AuditActor, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + acquire_user_write_lock(&mut tx, user_id).await?; + let before = load_user(&mut tx, user_id).await?; + ensure_local_identity(&before)?; + if before.site_role == SiteRole::Admin && before.status == UserStatus::Active && status != UserStatus::Active { + ensure_another_active_admin(&mut tx, user_id).await?; + } + sqlx::query( + "UPDATE users SET status = ?, session_generation = session_generation + 1, updated_at = ? \ + WHERE id = ?", + ) + .bind(status.as_str()) + .bind(aionui_common::now_ms()) + .bind(user_id) + .execute(&mut *tx) + .await?; + revoke_sessions(&mut tx, user_id, "status_changed").await?; + append_audit( + &mut tx, + actor, + "user.status_changed", + Some(user_id), + before.username.as_deref(), + serde_json::json!({ "from": before.status.as_str(), "to": status.as_str() }), + ) + .await?; + let user = load_user(&mut tx, user_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) + } + + async fn reset_managed_password( + &self, + user_id: &str, + password_hash: &str, + actor: &AuditActor, + ) -> Result { + password_mutation(self, user_id, password_hash, true, actor, "user.password_reset").await + } + + async fn change_own_password( + &self, + user_id: &str, + password_hash: &str, + actor: &AuditActor, + ) -> Result { + password_mutation(self, user_id, password_hash, false, actor, "user.password_changed").await + } + + async fn revoke_managed_sessions( + &self, + user_id: &str, + actor: &AuditActor, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + acquire_user_write_lock(&mut tx, user_id).await?; + let target = load_user(&mut tx, user_id).await?; + ensure_local_identity(&target)?; + sqlx::query("UPDATE users SET session_generation = session_generation + 1, updated_at = ? WHERE id = ?") + .bind(aionui_common::now_ms()) + .bind(user_id) + .execute(&mut *tx) + .await?; + revoke_sessions(&mut tx, user_id, "admin_revoked").await?; + append_audit( + &mut tx, + actor, + "user.sessions_revoked", + Some(user_id), + target.username.as_deref(), + serde_json::json!({}), + ) + .await?; + let user = load_user(&mut tx, user_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) + } + + async fn bootstrap_initial_admin( + &self, + username: &str, + password_hash: &str, + ) -> Result, AdminUserRepositoryError> { + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + // Acquire SQLite's writer lock before evaluating the invariant so two + // bootstrappers cannot both observe an empty administrator set. + sqlx::query( + "UPDATE users SET updated_at = updated_at \ + WHERE id = (SELECT id FROM users ORDER BY created_at ASC, id ASC LIMIT 1)", + ) + .execute(&mut *tx) + .await?; + let usable_admins: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE user_type = 'local' AND site_role = 'admin' \ + AND status = 'active' AND password_hash IS NOT NULL AND password_hash != ''", + ) + .fetch_one(&mut *tx) + .await?; + if usable_admins > 0 { + tx.rollback().await.map_err(DbError::from)?; + return Ok(None); + } + + let target_id: Option = sqlx::query_scalar( + "SELECT id FROM users WHERE user_type = 'local' AND ( \ + id = 'system_default_user' OR (status = 'active' AND password_hash IS NOT NULL AND password_hash != '') \ + ) ORDER BY CASE WHEN id = 'system_default_user' THEN 0 ELSE 1 END, \ + CASE WHEN username = ? THEN 0 ELSE 1 END, created_at ASC, id ASC LIMIT 1", + ) + .bind(username) + .fetch_optional(&mut *tx) + .await?; + let target_id = if let Some(target_id) = target_id { + target_id + } else { + let now = aionui_common::now_ms(); + sqlx::query( + "INSERT INTO users \ + (id, user_type, username, password_hash, status, site_role, must_change_password, \ + session_generation, created_at, updated_at) \ + VALUES ('system_default_user', 'local', ?, ?, 'active', 'admin', 1, 1, ?, ?)", + ) + .bind(username) + .bind(password_hash) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await + .map_err(map_write_error)?; + "system_default_user".to_string() + }; + sqlx::query( + "UPDATE users SET username = ?, password_hash = ?, site_role = 'admin', status = 'active', \ + must_change_password = 1, session_generation = session_generation + 1, updated_at = ? \ + WHERE id = ? AND user_type = 'local'", + ) + .bind(username) + .bind(password_hash) + .bind(aionui_common::now_ms()) + .bind(&target_id) + .execute(&mut *tx) + .await + .map_err(map_write_error)?; + revoke_sessions(&mut tx, &target_id, "bootstrap_credentials_set").await?; + append_audit( + &mut tx, + &AuditActor::system(), + "bootstrap.credentials_set", + Some(&target_id), + Some(username), + serde_json::json!({ "must_change_password": true }), + ) + .await?; + let user = load_user(&mut tx, &target_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(Some(user)) + } + + async fn list_admin_audit( + &self, + cursor: Option<&str>, + limit_plus_one: i64, + ) -> Result, DbError> { + if let Some(cursor) = cursor { + let boundary: Option<(i64, String)> = + sqlx::query_as("SELECT occurred_at, id FROM admin_audit_log WHERE id = ?") + .bind(cursor) + .fetch_optional(&self.pool) + .await?; + let (occurred_at, id) = boundary.ok_or_else(|| DbError::NotFound("audit cursor not found".into()))?; + Ok(sqlx::query_as::<_, AdminAuditRecord>( + "SELECT * FROM admin_audit_log \ + WHERE occurred_at < ? OR (occurred_at = ? AND id < ?) \ + ORDER BY occurred_at DESC, id DESC LIMIT ?", + ) + .bind(occurred_at) + .bind(occurred_at) + .bind(id) + .bind(limit_plus_one) + .fetch_all(&self.pool) + .await?) + } else { + Ok(sqlx::query_as::<_, AdminAuditRecord>( + "SELECT * FROM admin_audit_log ORDER BY occurred_at DESC, id DESC LIMIT ?", + ) + .bind(limit_plus_one) + .fetch_all(&self.pool) + .await?) + } + } +} + +async fn password_mutation( + repo: &SqliteUserRepository, + user_id: &str, + password_hash: &str, + must_change_password: bool, + actor: &AuditActor, + action: &str, +) -> Result { + let mut tx = repo.pool.begin().await.map_err(DbError::from)?; + acquire_user_write_lock(&mut tx, user_id).await?; + let target = load_user(&mut tx, user_id).await?; + if target.user_type != UserType::Local { + return Err(AdminUserRepositoryError::UnsupportedIdentity); + } + sqlx::query( + "UPDATE users SET password_hash = ?, must_change_password = ?, \ + session_generation = session_generation + 1, updated_at = ? WHERE id = ?", + ) + .bind(password_hash) + .bind(must_change_password) + .bind(aionui_common::now_ms()) + .bind(user_id) + .execute(&mut *tx) + .await?; + revoke_sessions(&mut tx, user_id, action).await?; + append_audit( + &mut tx, + actor, + action, + Some(user_id), + target.username.as_deref(), + serde_json::json!({ "must_change_password": must_change_password }), + ) + .await?; + let user = load_user(&mut tx, user_id).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(user) +} + +async fn acquire_user_write_lock( + tx: &mut Transaction<'_, Sqlite>, + user_id: &str, +) -> Result<(), AdminUserRepositoryError> { + let result = sqlx::query("UPDATE users SET updated_at = updated_at WHERE id = ?") + .bind(user_id) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!("User '{user_id}' not found")).into()); + } + Ok(()) +} + +async fn ensure_another_active_admin( + tx: &mut Transaction<'_, Sqlite>, + user_id: &str, +) -> Result<(), AdminUserRepositoryError> { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE site_role = 'admin' AND status = 'active' \ + AND user_type = 'local' AND password_hash IS NOT NULL AND password_hash != '' AND id != ?", + ) + .bind(user_id) + .fetch_one(&mut **tx) + .await?; + if count == 0 { + return Err(AdminUserRepositoryError::LastActiveAdmin); + } + Ok(()) +} + +fn ensure_local_identity(user: &User) -> Result<(), AdminUserRepositoryError> { + if user.user_type != UserType::Local { + return Err(AdminUserRepositoryError::UnsupportedIdentity); + } + Ok(()) +} + +async fn load_user(tx: &mut Transaction<'_, Sqlite>, user_id: &str) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| DbError::NotFound(format!("User '{user_id}' not found")).into()) +} + +async fn revoke_sessions( + tx: &mut Transaction<'_, Sqlite>, + user_id: &str, + reason: &str, +) -> Result<(), AdminUserRepositoryError> { + sqlx::query( + "UPDATE auth_sessions SET revoked_at = ?, revoke_reason = ? \ + WHERE user_id = ? AND revoked_at IS NULL", + ) + .bind(aionui_common::now_ms()) + .bind(reason) + .bind(user_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn append_audit( + tx: &mut Transaction<'_, Sqlite>, + actor: &AuditActor, + action: &str, + target_user_id: Option<&str>, + target_username: Option<&str>, + details: serde_json::Value, +) -> Result<(), AdminUserRepositoryError> { + sqlx::query( + "INSERT INTO admin_audit_log \ + (id, occurred_at, actor_user_id, actor_username, action, target_user_id, target_username, details) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(aionui_common::generate_prefixed_id("audit")) + .bind(aionui_common::now_ms()) + .bind(actor.user_id.as_deref()) + .bind(actor.username.as_deref()) + .bind(action) + .bind(target_user_id) + .bind(target_username) + .bind(details.to_string()) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn map_write_error(error: sqlx::Error) -> AdminUserRepositoryError { + if error + .as_database_error() + .is_some_and(|db_error| matches!(db_error.kind(), sqlx::error::ErrorKind::UniqueViolation)) + { + DbError::Conflict("username already exists".into()).into() + } else { + DbError::Query(error).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::IUserRepository; + + async fn setup() -> (SqliteUserRepository, crate::Database) { + let database = crate::init_database_memory().await.unwrap(); + let repository = SqliteUserRepository::new(database.pool().clone()); + (repository, database) + } + + #[tokio::test] + async fn bootstrap_is_atomic_and_marks_temporary_admin() { + let (repository, _database) = setup().await; + let created = repository + .bootstrap_initial_admin("admin", "hash") + .await + .unwrap() + .unwrap(); + assert_eq!(created.site_role, SiteRole::Admin); + assert_eq!(created.status, UserStatus::Active); + assert!(created.must_change_password); + assert!( + repository + .bootstrap_initial_admin("other", "hash2") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn bootstrap_recovers_when_the_system_user_row_is_missing() { + let (repository, database) = setup().await; + sqlx::query("DELETE FROM users WHERE id = 'system_default_user'") + .execute(database.pool()) + .await + .unwrap(); + + let created = repository + .bootstrap_initial_admin("recovered-admin", "hash") + .await + .unwrap() + .unwrap(); + assert_eq!(created.id, "system_default_user"); + assert_eq!(created.username.as_deref(), Some("recovered-admin")); + assert_eq!(created.site_role, SiteRole::Admin); + assert!(created.must_change_password); + } + + #[tokio::test] + async fn last_usable_admin_cannot_be_demoted_or_disabled() { + let (repository, _database) = setup().await; + repository.bootstrap_initial_admin("admin", "hash").await.unwrap(); + let actor = AuditActor::system(); + let demote = repository + .update_managed_role("system_default_user", SiteRole::Member, &actor) + .await; + assert!(matches!(demote, Err(AdminUserRepositoryError::LastActiveAdmin))); + let disable = repository + .update_managed_status("system_default_user", UserStatus::Disabled, &actor) + .await; + assert!(matches!(disable, Err(AdminUserRepositoryError::LastActiveAdmin))); + + let second = repository + .create_managed_user("second-admin", "hash", SiteRole::Admin, &actor) + .await + .unwrap(); + repository + .update_managed_role("system_default_user", SiteRole::Member, &actor) + .await + .unwrap(); + assert_eq!(second.site_role, SiteRole::Admin); + } + + #[tokio::test] + async fn browser_admin_repository_excludes_external_identities() { + let (repository, _database) = setup().await; + repository + .ensure_external_user(UserType::Aionpro, "external", Default::default()) + .await + .unwrap(); + assert_eq!(repository.count_managed_users().await.unwrap(), 1); + let external = repository + .find_by_external_user_id(UserType::Aionpro, "external") + .await + .unwrap() + .unwrap(); + let result = repository + .update_managed_status(&external.id, UserStatus::Disabled, &AuditActor::system()) + .await; + assert!(matches!(result, Err(AdminUserRepositoryError::UnsupportedIdentity))); + } + + #[tokio::test] + async fn password_reset_revokes_only_target_sessions_and_audit_is_append_only() { + let (repository, database) = setup().await; + repository.bootstrap_initial_admin("admin", "hash").await.unwrap(); + let member = repository + .create_managed_user("member-one", "hash", SiteRole::Member, &AuditActor::system()) + .await + .unwrap(); + let admin_session = repository + .create_auth_session("system_default_user", i64::MAX) + .await + .unwrap(); + let member_session = repository.create_auth_session(&member.id, i64::MAX).await.unwrap(); + repository + .reset_managed_password(&member.id, "new-hash", &AuditActor::system()) + .await + .unwrap(); + assert!( + repository + .is_auth_session_active(&admin_session, "system_default_user") + .await + .unwrap() + ); + assert!( + !repository + .is_auth_session_active(&member_session, &member.id) + .await + .unwrap() + ); + + let audit_id: String = sqlx::query_scalar("SELECT id FROM admin_audit_log LIMIT 1") + .fetch_one(database.pool()) + .await + .unwrap(); + assert!( + sqlx::query("DELETE FROM admin_audit_log WHERE id = ?") + .bind(audit_id) + .execute(database.pool()) + .await + .is_err() + ); + } +} diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index fd59bf401..75e3ed8ec 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -188,13 +188,13 @@ impl IAssistantRepository for SqliteAssistantRepository { ) -> Result { let now = now_ms(); - let result = sqlx::query( + sqlx::query( "INSERT INTO assistants \ (id, user_id, name, description, avatar, enabled_skills, \ custom_skill_names, disabled_builtin_skills, prompts, models, \ name_i18n, description_i18n, prompts_i18n, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT(id) DO UPDATE SET \ + ON CONFLICT(user_id, id) DO UPDATE SET \ name = excluded.name, \ description = excluded.description, \ avatar = excluded.avatar, \ @@ -206,8 +206,7 @@ impl IAssistantRepository for SqliteAssistantRepository { name_i18n = excluded.name_i18n, \ description_i18n = excluded.description_i18n, \ prompts_i18n = excluded.prompts_i18n, \ - updated_at = excluded.updated_at \ - WHERE assistants.user_id = excluded.user_id", + updated_at = excluded.updated_at", ) .bind(params.id) .bind(user_id) @@ -227,13 +226,6 @@ impl IAssistantRepository for SqliteAssistantRepository { .execute(&self.pool) .await?; - if result.rows_affected() == 0 { - return Err(DbError::Conflict(format!( - "Assistant with id '{}' already exists for another user", - params.id - ))); - } - let row = self .get_for_user(user_id, params.id) .await? @@ -1256,17 +1248,21 @@ mod tests { #[tokio::test] async fn assistants_are_scoped_by_user() { let (a, _o, _db) = setup().await; - a.create_for_user(USER_A, ¶ms("a1", "User A Assistant")) + a.create_for_user(USER_A, ¶ms("shared", "User A Assistant")) .await .unwrap(); - a.create_for_user(USER_B, ¶ms("b1", "User B Assistant")) + a.create_for_user(USER_B, ¶ms("shared", "User B Assistant")) .await .unwrap(); - assert!(a.get_for_user(USER_A, "a1").await.unwrap().is_some()); - assert!(a.get_for_user(USER_A, "b1").await.unwrap().is_none()); - assert!(a.get_for_user(USER_B, "a1").await.unwrap().is_none()); - assert!(a.get_for_user(USER_B, "b1").await.unwrap().is_some()); + assert_eq!( + a.get_for_user(USER_A, "shared").await.unwrap().unwrap().name, + "User A Assistant", + ); + assert_eq!( + a.get_for_user(USER_B, "shared").await.unwrap().unwrap().name, + "User B Assistant", + ); let user_a_ids: Vec = a .list_for_user(USER_A) @@ -1282,26 +1278,27 @@ mod tests { .into_iter() .map(|row| row.id) .collect(); - assert_eq!(user_a_ids, vec!["a1"]); - assert_eq!(user_b_ids, vec!["b1"]); + assert_eq!(user_a_ids, vec!["shared"]); + assert_eq!(user_b_ids, vec!["shared"]); } #[tokio::test] - async fn assistant_upsert_rejects_cross_user_id_takeover() { + async fn assistant_upsert_updates_same_id_independently_per_user() { let (a, _o, _db) = setup().await; a.upsert_for_user(USER_A, ¶ms("shared", "User A Assistant")) .await .unwrap(); - - let err = a - .upsert_for_user(USER_B, ¶ms("shared", "User B Assistant")) + a.upsert_for_user(USER_B, ¶ms("shared", "User B Assistant")) .await - .expect_err("cross-user upsert must not take over an existing assistant id"); - assert!(matches!(err, DbError::Conflict(_))); + .unwrap(); + a.upsert_for_user(USER_B, ¶ms("shared", "User B Updated")) + .await + .unwrap(); let user_a = a.get_for_user(USER_A, "shared").await.unwrap().unwrap(); assert_eq!(user_a.name, "User A Assistant"); - assert!(a.get_for_user(USER_B, "shared").await.unwrap().is_none()); + let user_b = a.get_for_user(USER_B, "shared").await.unwrap().unwrap(); + assert_eq!(user_b.name, "User B Updated"); } #[tokio::test] diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index f0ca3d90c..d2fa1def2 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -20,6 +20,37 @@ use crate::repository::conversation::{ /// out-of-order streaming upsert (older event time) can never move a /// conversation backward in the list. Runs inside the caller's transaction so /// the message write and the bump commit atomically. + +/// Owner or any share (view/edit) — binds `user_id` twice against alias `c`. +const CONV_READ_BY_C: &str = "(c.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'conversation' AND s.resource_id = c.id AND s.grantee_user_id = ? \ +))"; + +/// Owner or edit share — binds `user_id` twice against alias `c`. +const CONV_WRITE_BY_C: &str = "(c.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'conversation' AND s.resource_id = c.id \ + AND s.grantee_user_id = ? AND s.permission = 'edit' \ +))"; + +/// Owner or any share against bare `conversations` table — binds id once, user twice. +const CONV_READ_BY_ID: &str = "id = ? AND ( \ + user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'conversation' AND s.resource_id = conversations.id AND s.grantee_user_id = ? \ + ) \ +)"; + +/// Owner or edit share against bare `conversations` table — binds id once, user twice. +const CONV_WRITE_BY_ID: &str = "id = ? AND ( \ + user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'conversation' AND s.resource_id = conversations.id \ + AND s.grantee_user_id = ? AND s.permission = 'edit' \ + ) \ +)"; + /// SQLite-backed implementation of [`IConversationRepository`]. #[derive(Clone, Debug)] pub struct SqliteConversationRepository { @@ -32,11 +63,14 @@ impl SqliteConversationRepository { } async fn conversation_exists_for_user(&self, user_id: &str, conversation_id: &str) -> Result { - let exists: i64 = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM conversations WHERE user_id = ? AND id = ?)") - .bind(user_id) - .bind(conversation_id) - .fetch_one(&self.pool) - .await?; + let exists: i64 = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM conversations c WHERE c.id = ? AND {CONV_READ_BY_C})" + )) + .bind(conversation_id) + .bind(user_id) + .bind(user_id) + .fetch_one(&self.pool) + .await?; Ok(exists != 0) } @@ -60,12 +94,14 @@ impl SqliteConversationRepository { let result: Result<(), DbError> = async { // Ownership check inside the same transaction as the insert + // bump, so parent-chain authorization and the write are atomic. - let exists: i64 = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM conversations WHERE user_id = ? AND id = ?)") - .bind(user_id) - .bind(&message.conversation_id) - .fetch_one(&mut *connection) - .await?; + let exists: i64 = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM conversations c WHERE c.id = ? AND {CONV_WRITE_BY_C})" + )) + .bind(&message.conversation_id) + .bind(user_id) + .bind(user_id) + .fetch_one(&mut *connection) + .await?; if exists == 0 { return Err(DbError::NotFound(format!( "Conversation '{}' not found", @@ -121,7 +157,7 @@ impl SqliteConversationRepository { sqlx::query("BEGIN IMMEDIATE").execute(&mut *connection).await?; let result: Result<(), DbError> = async { - let result = sqlx::query( + let result = sqlx::query(&format!( "INSERT INTO messages \ (id, conversation_id, msg_id, type, content, position, \ status, hidden, created_at, backend_turn_id) \ @@ -154,9 +190,9 @@ impl SqliteConversationRepository { WHERE messages.conversation_id = excluded.conversation_id \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = messages.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = messages.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(&message.id) .bind(&message.conversation_id) .bind(&message.msg_id) @@ -168,6 +204,7 @@ impl SqliteConversationRepository { .bind(message.created_at) .bind(&message.backend_turn_id) .bind(user_id) + .bind(user_id) .execute(&mut *connection) .await?; @@ -208,16 +245,17 @@ impl SqliteConversationRepository { conv_id: &str, cursor: &MessagePageCursor, ) -> Result { - let exists: i64 = sqlx::query_scalar( + let exists: i64 = sqlx::query_scalar(&format!( "SELECT EXISTS( \ SELECT 1 FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at < ? OR (m.created_at = ? AND m.id < ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ - )", - ) + )" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(cursor.created_at) @@ -235,16 +273,17 @@ impl SqliteConversationRepository { conv_id: &str, cursor: &MessagePageCursor, ) -> Result { - let exists: i64 = sqlx::query_scalar( + let exists: i64 = sqlx::query_scalar(&format!( "SELECT EXISTS( \ SELECT 1 FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at > ? OR (m.created_at = ? AND m.id > ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ - )", - ) + )" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(cursor.created_at) @@ -294,9 +333,10 @@ impl IConversationRepository for SqliteConversationRepository { // ── Conversation CRUD ─────────────────────────────────────────── async fn get(&self, user_id: &str, id: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, ConversationRow>("SELECT * FROM conversations WHERE user_id = ? AND id = ?") - .bind(user_id) + let row = sqlx::query_as::<_, ConversationRow>(&format!("SELECT * FROM conversations WHERE {CONV_READ_BY_ID}")) .bind(id) + .bind(user_id) + .bind(user_id) .fetch_optional(&self.pool) .await?; @@ -391,7 +431,7 @@ impl IConversationRepository for SqliteConversationRepository { } let sql = format!( - "UPDATE conversations SET {} WHERE user_id = ? AND id = ?", + "UPDATE conversations SET {} WHERE {CONV_WRITE_BY_ID}", set_parts.join(", ") ); @@ -399,8 +439,9 @@ impl IConversationRepository for SqliteConversationRepository { for bind in &binds { query = bind_value(query, bind); } - query = query.bind(user_id); query = query.bind(id); + query = query.bind(user_id); + query = query.bind(user_id); let result = query.execute(&self.pool).await?; @@ -412,9 +453,10 @@ impl IConversationRepository for SqliteConversationRepository { } async fn delete(&self, user_id: &str, id: &str) -> Result<(), DbError> { - let result = sqlx::query("DELETE FROM conversations WHERE user_id = ? AND id = ?") - .bind(user_id) + let result = sqlx::query(&format!("DELETE FROM conversations WHERE {CONV_WRITE_BY_ID}")) .bind(id) + .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -434,8 +476,8 @@ impl IConversationRepository for SqliteConversationRepository { // Fetch one extra row to determine hasMore let fetch_limit = limit + 1; - let mut where_parts = vec!["c.user_id = ?".to_string()]; - let mut binds: Vec = vec![BindValue::Str(user_id.to_string())]; + let mut where_parts = vec![CONV_READ_BY_C.to_string()]; + let mut binds: Vec = vec![BindValue::Str(user_id.to_string()), BindValue::Str(user_id.to_string())]; // Cursor-based pagination: use updated_at of the cursor row if let Some(ref cursor_id) = filters.cursor { @@ -529,12 +571,14 @@ impl IConversationRepository for SqliteConversationRepository { async fn list_associated(&self, user_id: &str, conversation_id: &str) -> Result, DbError> { // First get the target conversation's workspace - let target = sqlx::query_as::<_, ConversationRow>("SELECT * FROM conversations WHERE id = ? AND user_id = ?") - .bind(conversation_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| DbError::NotFound(format!("Conversation '{conversation_id}' not found")))?; + let target = + sqlx::query_as::<_, ConversationRow>(&format!("SELECT * FROM conversations WHERE {CONV_READ_BY_ID}")) + .bind(conversation_id) + .bind(user_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound(format!("Conversation '{conversation_id}' not found")))?; // Extract workspace from extra JSON let workspace: Option = serde_json::from_str::(&target.extra) @@ -571,11 +615,12 @@ impl IConversationRepository for SqliteConversationRepository { user_id: &str, conversation_id: &str, ) -> Result, DbError> { - let row = sqlx::query_as::<_, ConversationAssistantSnapshotRow>( + let row = sqlx::query_as::<_, ConversationAssistantSnapshotRow>(&format!( "SELECT s.* FROM conversation_assistant_snapshots s \ INNER JOIN conversations c ON c.id = s.conversation_id \ - WHERE c.user_id = ? AND s.conversation_id = ?", - ) + WHERE {CONV_READ_BY_C} AND s.conversation_id = ?" + )) + .bind(user_id) .bind(user_id) .bind(conversation_id) .fetch_optional(&self.pool) @@ -659,17 +704,18 @@ impl IConversationRepository for SqliteConversationRepository { } async fn delete_assistant_snapshot(&self, user_id: &str, conversation_id: &str) -> Result { - let result = sqlx::query( + let result = sqlx::query(&format!( "DELETE FROM conversation_assistant_snapshots \ WHERE conversation_id = ? \ AND EXISTS ( \ SELECT 1 FROM conversations c \ WHERE c.id = conversation_assistant_snapshots.conversation_id \ - AND c.user_id = ? \ - )", - ) + AND {CONV_WRITE_BY_C} \ + )" + )) .bind(conversation_id) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -690,15 +736,16 @@ impl IConversationRepository for SqliteConversationRepository { let mut rows = match ¶ms.direction { MessagePageDirection::InitialLatest => { - let mut rows = sqlx::query_as::<_, MessageRow>( + let mut rows = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ ORDER BY m.created_at DESC, m.id DESC \ - LIMIT ?", - ) + LIMIT ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(fetch_limit) @@ -709,16 +756,17 @@ impl IConversationRepository for SqliteConversationRepository { rows } MessagePageDirection::Before { cursor } => { - let mut rows = sqlx::query_as::<_, MessageRow>( + let mut rows = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at < ? OR (m.created_at = ? AND m.id < ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ ORDER BY m.created_at DESC, m.id DESC \ - LIMIT ?", - ) + LIMIT ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(cursor.created_at) @@ -732,16 +780,17 @@ impl IConversationRepository for SqliteConversationRepository { rows } MessagePageDirection::After { cursor } => { - let mut rows = sqlx::query_as::<_, MessageRow>( + let mut rows = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at > ? OR (m.created_at = ? AND m.id > ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ ORDER BY m.created_at ASC, m.id ASC \ - LIMIT ?", - ) + LIMIT ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(cursor.created_at) @@ -754,14 +803,15 @@ impl IConversationRepository for SqliteConversationRepository { rows } MessagePageDirection::Anchor { message_id } => { - let anchor = sqlx::query_as::<_, MessageRow>( + let anchor = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND m.id = ? \ - AND m.type NOT IN ('cron_trigger', 'skill_suggest')", - ) + AND m.type NOT IN ('cron_trigger', 'skill_suggest')" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(message_id) @@ -770,16 +820,17 @@ impl IConversationRepository for SqliteConversationRepository { .ok_or_else(|| DbError::NotFound(format!("Message '{message_id}' not found")))?; let side_limit = limit; - let mut before = sqlx::query_as::<_, MessageRow>( + let mut before = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at < ? OR (m.created_at = ? AND m.id < ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ ORDER BY m.created_at DESC, m.id DESC \ - LIMIT ?", - ) + LIMIT ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(anchor.created_at) @@ -790,16 +841,17 @@ impl IConversationRepository for SqliteConversationRepository { .await?; before.reverse(); - let after = sqlx::query_as::<_, MessageRow>( + let after = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND (m.created_at > ? OR (m.created_at = ? AND m.id > ?)) \ AND m.type NOT IN ('cron_trigger', 'skill_suggest') \ ORDER BY m.created_at ASC, m.id ASC \ - LIMIT ?", - ) + LIMIT ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(anchor.created_at) @@ -835,14 +887,15 @@ impl IConversationRepository for SqliteConversationRepository { } async fn get_message(&self, user_id: &str, conv_id: &str, message_id: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, MessageRow>( + let row = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? \ + WHERE {CONV_READ_BY_C} \ AND m.conversation_id = ? \ AND m.id = ? \ - AND m.type NOT IN ('cron_trigger', 'skill_suggest')", - ) + AND m.type NOT IN ('cron_trigger', 'skill_suggest')" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(message_id) @@ -892,7 +945,7 @@ impl IConversationRepository for SqliteConversationRepository { WHERE conversation_id = ? AND id = ? \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = messages.conversation_id AND c.user_id = ? \ + WHERE c.id = messages.conversation_id AND {CONV_WRITE_BY_C} \ )", set_parts.join(", ") ); @@ -915,16 +968,17 @@ impl IConversationRepository for SqliteConversationRepository { } async fn delete_messages_by_conversation(&self, user_id: &str, conv_id: &str) -> Result<(), DbError> { - sqlx::query( + sqlx::query(&format!( "DELETE FROM messages \ WHERE conversation_id = ? \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = messages.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = messages.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(conv_id) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -948,12 +1002,14 @@ impl IConversationRepository for SqliteConversationRepository { // Both endpoints must belong to the caller; checked inside the // transaction so authorization and the copy are atomic. for conv_id in [source_conversation_id, target_conversation_id] { - let exists: i64 = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM conversations WHERE user_id = ? AND id = ?)") - .bind(user_id) - .bind(conv_id) - .fetch_one(&mut *connection) - .await?; + let exists: i64 = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM conversations c WHERE c.id = ? AND {CONV_WRITE_BY_C})" + )) + .bind(conv_id) + .bind(user_id) + .bind(user_id) + .fetch_one(&mut *connection) + .await?; if exists == 0 { return Err(DbError::NotFound(format!("Conversation '{conv_id}' not found"))); } @@ -1032,15 +1088,16 @@ impl IConversationRepository for SqliteConversationRepository { cursor: (TimestampMs, &str), ) -> Result, DbError> { let (cursor_created_at, cursor_id) = cursor; - let anchor: Option = sqlx::query_scalar( + let anchor: Option = sqlx::query_scalar(&format!( "SELECT m.backend_turn_id FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? AND m.conversation_id = ? \ + WHERE {CONV_READ_BY_C} AND m.conversation_id = ? \ AND m.backend_turn_id IS NOT NULL \ AND (m.created_at < ? OR (m.created_at = ? AND m.id <= ?)) \ ORDER BY m.created_at DESC, m.id DESC \ - LIMIT 1", - ) + LIMIT 1" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(cursor_created_at) @@ -1058,12 +1115,13 @@ impl IConversationRepository for SqliteConversationRepository { conv_id: &str, msg_id: &str, ) -> Result, DbError> { - let row = sqlx::query_as::<_, MessageRow>( + let row = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? AND m.conversation_id = ? AND m.msg_id = ? \ - ORDER BY m.created_at ASC, m.id ASC LIMIT 1", - ) + WHERE {CONV_READ_BY_C} AND m.conversation_id = ? AND m.msg_id = ? \ + ORDER BY m.created_at ASC, m.id ASC LIMIT 1" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(msg_id) @@ -1080,11 +1138,12 @@ impl IConversationRepository for SqliteConversationRepository { msg_id: &str, msg_type: &str, ) -> Result, DbError> { - let row = sqlx::query_as::<_, MessageRow>( + let row = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? AND m.conversation_id = ? AND m.msg_id = ? AND m.type = ?", - ) + WHERE {CONV_READ_BY_C} AND m.conversation_id = ? AND m.msg_id = ? AND m.type = ?" + )) + .bind(user_id) .bind(user_id) .bind(conv_id) .bind(msg_id) @@ -1143,18 +1202,19 @@ impl IConversationRepository for SqliteConversationRepository { let like_pattern = format!("%{keyword}%"); - let count_row: (i64,) = sqlx::query_as( + let count_row: (i64,) = sqlx::query_as(&format!( "SELECT COUNT(*) FROM messages m \ INNER JOIN conversations c ON m.conversation_id = c.id \ - WHERE c.user_id = ? AND m.content LIKE ?", - ) + WHERE {CONV_READ_BY_C} AND m.content LIKE ?" + )) + .bind(user_id) .bind(user_id) .bind(&like_pattern) .fetch_one(&self.pool) .await?; let total = count_row.0 as u64; - let rows = sqlx::query_as::<_, MessageSearchRow>( + let rows = sqlx::query_as::<_, MessageSearchRow>(&format!( "SELECT \ m.id AS message_id, \ m.type, \ @@ -1174,10 +1234,11 @@ impl IConversationRepository for SqliteConversationRepository { c.updated_at AS conversation_updated_at \ FROM messages m \ INNER JOIN conversations c ON m.conversation_id = c.id \ - WHERE c.user_id = ? AND m.content LIKE ? \ + WHERE {CONV_READ_BY_C} AND m.content LIKE ? \ ORDER BY m.created_at DESC \ - LIMIT ? OFFSET ?", - ) + LIMIT ? OFFSET ?" + )) + .bind(user_id) .bind(user_id) .bind(&like_pattern) .bind(fetch_limit) @@ -1200,12 +1261,13 @@ impl IConversationRepository for SqliteConversationRepository { user_id: &str, conversation_id: &str, ) -> Result, DbError> { - let rows = sqlx::query_as::<_, ConversationArtifactRow>( + let rows = sqlx::query_as::<_, ConversationArtifactRow>(&format!( "SELECT a.* FROM conversation_artifacts a \ INNER JOIN conversations c ON c.id = a.conversation_id \ - WHERE c.user_id = ? AND a.conversation_id = ? \ - ORDER BY a.created_at ASC, a.id ASC", - ) + WHERE {CONV_READ_BY_C} AND a.conversation_id = ? \ + ORDER BY a.created_at ASC, a.id ASC" + )) + .bind(user_id) .bind(user_id) .bind(conversation_id) .fetch_all(&self.pool) @@ -1220,11 +1282,12 @@ impl IConversationRepository for SqliteConversationRepository { conversation_id: &str, artifact_id: &str, ) -> Result, DbError> { - let row = sqlx::query_as::<_, ConversationArtifactRow>( + let row = sqlx::query_as::<_, ConversationArtifactRow>(&format!( "SELECT a.* FROM conversation_artifacts a \ INNER JOIN conversations c ON c.id = a.conversation_id \ - WHERE c.user_id = ? AND a.conversation_id = ? AND a.id = ?", - ) + WHERE {CONV_READ_BY_C} AND a.conversation_id = ? AND a.id = ?" + )) + .bind(user_id) .bind(user_id) .bind(conversation_id) .bind(artifact_id) @@ -1241,7 +1304,7 @@ impl IConversationRepository for SqliteConversationRepository { ) -> Result { self.ensure_conversation_for_user(user_id, &artifact.conversation_id) .await?; - let result = sqlx::query( + let result = sqlx::query(&format!( "INSERT INTO conversation_artifacts \ (id, conversation_id, cron_job_id, kind, status, payload, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ @@ -1255,9 +1318,9 @@ impl IConversationRepository for SqliteConversationRepository { WHERE conversation_artifacts.conversation_id = excluded.conversation_id \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = conversation_artifacts.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = conversation_artifacts.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(&artifact.id) .bind(&artifact.conversation_id) .bind(&artifact.cron_job_id) @@ -1267,6 +1330,7 @@ impl IConversationRepository for SqliteConversationRepository { .bind(artifact.created_at) .bind(artifact.updated_at) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -1290,20 +1354,21 @@ impl IConversationRepository for SqliteConversationRepository { status: &str, updated_at: i64, ) -> Result, DbError> { - let result = sqlx::query( + let result = sqlx::query(&format!( "UPDATE conversation_artifacts \ SET status = ?, updated_at = ? \ WHERE conversation_id = ? AND id = ? \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = conversation_artifacts.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = conversation_artifacts.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(status) .bind(updated_at) .bind(conversation_id) .bind(artifact_id) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -1320,27 +1385,29 @@ impl IConversationRepository for SqliteConversationRepository { cron_job_id: &str, updated_at: i64, ) -> Result, DbError> { - sqlx::query( + sqlx::query(&format!( "UPDATE conversation_artifacts \ SET status = 'saved', updated_at = ? \ WHERE kind = 'skill_suggest' AND cron_job_id = ? AND status != 'saved' \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = conversation_artifacts.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = conversation_artifacts.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(updated_at) .bind(cron_job_id) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; - let rows = sqlx::query_as::<_, ConversationArtifactRow>( + let rows = sqlx::query_as::<_, ConversationArtifactRow>(&format!( "SELECT a.* FROM conversation_artifacts a \ INNER JOIN conversations c ON c.id = a.conversation_id \ - WHERE c.user_id = ? AND a.kind = 'skill_suggest' AND a.cron_job_id = ? \ - ORDER BY a.created_at ASC, a.id ASC", - ) + WHERE {CONV_READ_BY_C} AND a.kind = 'skill_suggest' AND a.cron_job_id = ? \ + ORDER BY a.created_at ASC, a.id ASC" + )) + .bind(user_id) .bind(user_id) .bind(cron_job_id) .fetch_all(&self.pool) @@ -1350,16 +1417,17 @@ impl IConversationRepository for SqliteConversationRepository { } async fn delete_artifacts_by_conversation(&self, user_id: &str, conversation_id: &str) -> Result<(), DbError> { - sqlx::query( + sqlx::query(&format!( "DELETE FROM conversation_artifacts \ WHERE conversation_id = ? \ AND EXISTS ( \ SELECT 1 FROM conversations c \ - WHERE c.id = conversation_artifacts.conversation_id AND c.user_id = ? \ - )", - ) + WHERE c.id = conversation_artifacts.conversation_id AND {CONV_WRITE_BY_C} \ + )" + )) .bind(conversation_id) .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; @@ -1371,12 +1439,13 @@ impl IConversationRepository for SqliteConversationRepository { user_id: &str, conversation_id: &str, ) -> Result, DbError> { - let rows = sqlx::query_as::<_, MessageRow>( + let rows = sqlx::query_as::<_, MessageRow>(&format!( "SELECT m.* FROM messages m \ INNER JOIN conversations c ON c.id = m.conversation_id \ - WHERE c.user_id = ? AND m.conversation_id = ? AND m.type = 'cron_trigger' \ - ORDER BY m.created_at ASC, m.id ASC", - ) + WHERE {CONV_READ_BY_C} AND m.conversation_id = ? AND m.type = 'cron_trigger' \ + ORDER BY m.created_at ASC, m.id ASC" + )) + .bind(user_id) .bind(user_id) .bind(conversation_id) .fetch_all(&self.pool) @@ -1449,8 +1518,8 @@ fn append_filter_conditions(filters: &ConversationFilters, where_parts: &mut Vec /// Builds a count query and bind values for the total (ignoring cursor). fn build_count_sql(user_id: &str, filters: &ConversationFilters) -> (String, Vec) { - let mut where_parts = vec!["c.user_id = ?".to_string()]; - let mut binds: Vec = vec![BindValue::Str(user_id.to_string())]; + let mut where_parts = vec![CONV_READ_BY_C.to_string()]; + let mut binds: Vec = vec![BindValue::Str(user_id.to_string()), BindValue::Str(user_id.to_string())]; append_filter_conditions(filters, &mut where_parts, &mut binds); diff --git a/crates/aionui-db/src/repository/sqlite_project.rs b/crates/aionui-db/src/repository/sqlite_project.rs index ca1f20e67..c6bd6ede6 100644 --- a/crates/aionui-db/src/repository/sqlite_project.rs +++ b/crates/aionui-db/src/repository/sqlite_project.rs @@ -58,16 +58,33 @@ impl IProjectStore for SqliteProjectStore { } async fn get_project(&self, user_id: &str, project_id: &str) -> Result, DbError> { + // Owner or any explicit share (view/edit) may read project metadata. let row = sqlx::query_as::<_, ProjectRow>(&format!( - "SELECT {PROJECT_COLS} FROM projects WHERE project_id = ? AND user_id = ?" + "SELECT {PROJECT_COLS} FROM projects p \ + WHERE p.project_id = ? AND ( \ + p.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'project' AND s.resource_id = p.project_id \ + AND s.grantee_user_id = ? \ + ) \ + )" )) .bind(project_id) .bind(user_id) + .bind(user_id) .fetch_optional(&self.pool) .await?; Ok(row) } + async fn project_owner_user_id(&self, project_id: &str) -> Result, DbError> { + let owner = sqlx::query_scalar::<_, String>("SELECT user_id FROM projects WHERE project_id = ?") + .bind(project_id) + .fetch_optional(&self.pool) + .await?; + Ok(owner) + } + async fn select_workspace_entry_by_folder( &self, user_id: &str, @@ -86,10 +103,18 @@ impl IProjectStore for SqliteProjectStore { async fn get_entry(&self, user_id: &str, pe_id: &str) -> Result, DbError> { let row = sqlx::query_as::<_, ProjectExplorerRow>(&format!( - "SELECT {ENTRY_COLS} FROM project_explorer WHERE pe_id = ? AND owner_user_id = ?" + "SELECT {ENTRY_COLS} FROM project_explorer pe \ + WHERE pe.pe_id = ? AND ( \ + pe.owner_user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'project' AND s.resource_id = pe.project_id \ + AND s.grantee_user_id = ? \ + ) \ + )" )) .bind(pe_id) .bind(user_id) + .bind(user_id) .fetch_optional(&self.pool) .await?; Ok(row) @@ -103,6 +128,8 @@ impl IProjectStore for SqliteProjectStore { // Manual join projection: project_explorer and folders share column // names (folder_id / created_at / updated_at), so folder columns are // aliased and rows are mapped by name rather than via a tuple FromRow. + // Access is project-scoped (owner or share) so collaborators see the + // same root list as the owner. let rows = sqlx::query( "SELECT pe.pe_id, pe.project_id, pe.folder_id, pe.role, pe.display_name, pe.order_index, \ pe.created_at, pe.updated_at, \ @@ -110,11 +137,19 @@ impl IProjectStore for SqliteProjectStore { f.created_at AS f_created_at, f.updated_at AS f_updated_at \ FROM project_explorer pe \ JOIN folders f ON f.folder_id = pe.folder_id \ - WHERE pe.project_id = ? AND pe.owner_user_id = ? \ + JOIN projects p ON p.project_id = pe.project_id \ + WHERE pe.project_id = ? AND ( \ + p.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'project' AND s.resource_id = p.project_id \ + AND s.grantee_user_id = ? \ + ) \ + ) \ ORDER BY pe.order_index ASC, pe.created_at ASC", ) .bind(project_id) .bind(user_id) + .bind(user_id) .fetch_all(&self.pool) .await?; diff --git a/crates/aionui-db/src/repository/sqlite_provider.rs b/crates/aionui-db/src/repository/sqlite_provider.rs index d18045fe0..31ab54a83 100644 --- a/crates/aionui-db/src/repository/sqlite_provider.rs +++ b/crates/aionui-db/src/repository/sqlite_provider.rs @@ -17,23 +17,42 @@ impl SqliteProviderRepository { } } +/// Owner or any share — binds `user_id` twice against alias `p`. +const PROVIDER_READ_BY_P: &str = "(p.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'provider' AND s.resource_id = p.id AND s.grantee_user_id = ? \ +))"; + +/// Owner or edit share — binds `user_id` twice against alias `p`. +const PROVIDER_WRITE_BY_P: &str = "(p.user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'provider' AND s.resource_id = p.id \ + AND s.grantee_user_id = ? AND s.permission = 'edit' \ +))"; + #[async_trait::async_trait] impl IProviderRepository for SqliteProviderRepository { async fn list(&self, user_id: &str) -> Result, DbError> { - let rows = sqlx::query_as::<_, Provider>("SELECT * FROM providers WHERE user_id = ? ORDER BY created_at ASC") - .bind(user_id) - .fetch_all(&self.pool) - .await?; + let rows = sqlx::query_as::<_, Provider>(&format!( + "SELECT p.* FROM providers p WHERE {PROVIDER_READ_BY_P} ORDER BY p.created_at ASC" + )) + .bind(user_id) + .bind(user_id) + .fetch_all(&self.pool) + .await?; Ok(rows) } async fn find_by_id(&self, user_id: &str, id: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, Provider>("SELECT * FROM providers WHERE user_id = ? AND id = ?") - .bind(user_id) - .bind(id) - .fetch_optional(&self.pool) - .await?; + let row = sqlx::query_as::<_, Provider>(&format!( + "SELECT p.* FROM providers p WHERE p.id = ? AND {PROVIDER_READ_BY_P}" + )) + .bind(id) + .bind(user_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; Ok(row) } @@ -102,20 +121,32 @@ impl IProviderRepository for SqliteProviderRepository { } async fn update(&self, user_id: &str, id: &str, params: UpdateProviderParams<'_>) -> Result { - let existing = self - .find_by_id(user_id, id) - .await? - .ok_or_else(|| DbError::NotFound(format!("Provider '{id}' not found")))?; + // Edit requires owner or edit share (view-only cannot update). + let existing = sqlx::query_as::<_, Provider>(&format!( + "SELECT p.* FROM providers p WHERE p.id = ? AND {PROVIDER_WRITE_BY_P}" + )) + .bind(id) + .bind(user_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound(format!("Provider '{id}' not found")))?; let merged = merge_update(existing, params); - sqlx::query( + let result = sqlx::query( "UPDATE providers SET \ platform = ?, name = ?, base_url = ?, api_key_encrypted = ?, \ models = ?, enabled = ?, capabilities = ?, context_limit = ?, \ model_protocols = ?, model_enabled = ?, model_health = ?, \ model_settings = ?, bedrock_config = ?, is_full_url = ?, updated_at = ? \ - WHERE user_id = ? AND id = ?", + WHERE id = ? AND ( \ + user_id = ? OR EXISTS ( \ + SELECT 1 FROM resource_shares s \ + WHERE s.resource_type = 'provider' AND s.resource_id = providers.id \ + AND s.grantee_user_id = ? AND s.permission = 'edit' \ + ) \ + )", ) .bind(&merged.platform) .bind(&merged.name) @@ -132,15 +163,21 @@ impl IProviderRepository for SqliteProviderRepository { .bind(&merged.bedrock_config) .bind(merged.is_full_url) .bind(merged.updated_at) - .bind(user_id) .bind(id) + .bind(user_id) + .bind(user_id) .execute(&self.pool) .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!("Provider '{id}' not found"))); + } + Ok(merged) } async fn delete(&self, user_id: &str, id: &str) -> Result<(), DbError> { + // Delete remains owner-only. let result = sqlx::query("DELETE FROM providers WHERE user_id = ? AND id = ?") .bind(user_id) .bind(id) diff --git a/crates/aionui-db/src/repository/sqlite_resource_share.rs b/crates/aionui-db/src/repository/sqlite_resource_share.rs new file mode 100644 index 000000000..efce1894f --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_resource_share.rs @@ -0,0 +1,433 @@ +use sqlx::SqlitePool; + +use crate::error::DbError; +use crate::models::{GrantShareParams, ResourceAccess, ResourceShareRow, SharePermission, ShareResourceType}; +use crate::repository::IResourceShareRepository; + +/// SQLite-backed implementation of [`IResourceShareRepository`]. +#[derive(Clone, Debug)] +pub struct SqliteResourceShareRepository { + pool: SqlitePool, +} + +impl SqliteResourceShareRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl IResourceShareRepository for SqliteResourceShareRepository { + async fn grant(&self, params: GrantShareParams<'_>) -> Result { + if params.owner_user_id == params.grantee_user_id { + return Err(DbError::Conflict("Cannot share a resource with its owner".to_owned())); + } + + let id = aionui_common::generate_prefixed_id("share"); + let now = aionui_common::now_ms(); + let resource_type = params.resource_type.as_str(); + let permission = params.permission.as_str(); + + // Upsert: if the grantee already has a share, refresh permission/created_by. + sqlx::query( + "INSERT INTO resource_shares \ + (id, resource_type, resource_id, owner_user_id, grantee_user_id, permission, created_at, created_by) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(resource_type, resource_id, grantee_user_id) DO UPDATE SET \ + permission = excluded.permission, \ + created_by = excluded.created_by, \ + owner_user_id = excluded.owner_user_id", + ) + .bind(&id) + .bind(resource_type) + .bind(params.resource_id) + .bind(params.owner_user_id) + .bind(params.grantee_user_id) + .bind(permission) + .bind(now) + .bind(params.created_by) + .execute(&self.pool) + .await + .map_err(map_write_error)?; + + // ON CONFLICT keeps the original id; re-select the canonical row. + self.find_by_resource_grantee(params.resource_type, params.resource_id, params.grantee_user_id) + .await? + .ok_or_else(|| DbError::NotFound("Share row missing after grant".to_owned())) + } + + async fn revoke(&self, share_id: &str) -> Result<(), DbError> { + let result = sqlx::query("DELETE FROM resource_shares WHERE id = ?") + .bind(share_id) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!("Share '{share_id}' not found"))); + } + Ok(()) + } + + async fn list_for_resource( + &self, + resource_type: ShareResourceType, + resource_id: &str, + ) -> Result, DbError> { + let rows = sqlx::query_as::<_, ResourceShareRow>( + "SELECT * FROM resource_shares \ + WHERE resource_type = ? AND resource_id = ? \ + ORDER BY created_at ASC, id ASC", + ) + .bind(resource_type.as_str()) + .bind(resource_id) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + async fn list_granted_by(&self, owner_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ResourceShareRow>( + "SELECT * FROM resource_shares \ + WHERE owner_user_id = ? \ + ORDER BY created_at DESC, id DESC", + ) + .bind(owner_user_id) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + async fn list_received_by(&self, grantee_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ResourceShareRow>( + "SELECT * FROM resource_shares \ + WHERE grantee_user_id = ? \ + ORDER BY created_at DESC, id DESC", + ) + .bind(grantee_user_id) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + async fn get_permission( + &self, + resource_type: ShareResourceType, + resource_id: &str, + grantee_user_id: &str, + ) -> Result, DbError> { + let permission: Option = sqlx::query_scalar( + "SELECT permission FROM resource_shares \ + WHERE resource_type = ? AND resource_id = ? AND grantee_user_id = ?", + ) + .bind(resource_type.as_str()) + .bind(resource_id) + .bind(grantee_user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(permission.and_then(|p| SharePermission::parse(&p))) + } + + async fn resolve_access( + &self, + resource_type: ShareResourceType, + resource_id: &str, + user_id: &str, + ) -> Result { + let Some(owner_id) = self.resource_owner(resource_type, resource_id).await? else { + return Ok(ResourceAccess::None); + }; + + if owner_id == user_id { + return Ok(ResourceAccess::Owner); + } + + match self.get_permission(resource_type, resource_id, user_id).await? { + Some(SharePermission::Edit) => Ok(ResourceAccess::Edit), + Some(SharePermission::View) => Ok(ResourceAccess::View), + None => Ok(ResourceAccess::None), + } + } + + async fn resource_owner( + &self, + resource_type: ShareResourceType, + resource_id: &str, + ) -> Result, DbError> { + let sql = match resource_type { + ShareResourceType::Conversation => "SELECT user_id FROM conversations WHERE id = ?", + ShareResourceType::Project => "SELECT user_id FROM projects WHERE project_id = ?", + ShareResourceType::Provider => "SELECT user_id FROM providers WHERE id = ?", + }; + let owner = sqlx::query_scalar::<_, String>(sql) + .bind(resource_id) + .fetch_optional(&self.pool) + .await?; + Ok(owner) + } + + async fn find_by_id(&self, share_id: &str) -> Result, DbError> { + let row = sqlx::query_as::<_, ResourceShareRow>("SELECT * FROM resource_shares WHERE id = ?") + .bind(share_id) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } +} + +impl SqliteResourceShareRepository { + async fn find_by_resource_grantee( + &self, + resource_type: ShareResourceType, + resource_id: &str, + grantee_user_id: &str, + ) -> Result, DbError> { + let row = sqlx::query_as::<_, ResourceShareRow>( + "SELECT * FROM resource_shares \ + WHERE resource_type = ? AND resource_id = ? AND grantee_user_id = ?", + ) + .bind(resource_type.as_str()) + .bind(resource_id) + .bind(grantee_user_id) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } +} + +fn map_write_error(error: sqlx::Error) -> DbError { + match &error { + sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => { + DbError::Conflict("Share already exists".to_owned()) + } + sqlx::Error::Database(db_err) if is_foreign_key_violation(db_err.as_ref()) => { + DbError::NotFound("Referenced user or resource is missing".to_owned()) + } + sqlx::Error::Database(db_err) if is_check_violation(db_err.as_ref()) => { + DbError::Conflict(db_err.message().to_owned()) + } + _ => DbError::Query(error), + } +} + +fn is_unique_violation(err: &dyn sqlx::error::DatabaseError) -> bool { + err.code().is_some_and(|c| c == "2067" || c == "1555") +} + +fn is_foreign_key_violation(err: &dyn sqlx::error::DatabaseError) -> bool { + err.code().is_some_and(|c| c == "787") +} + +fn is_check_violation(err: &dyn sqlx::error::DatabaseError) -> bool { + err.code().is_some_and(|c| c == "275") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IUserRepository, SqliteUserRepository, init_database_memory}; + + async fn setup() -> (SqliteResourceShareRepository, String, String) { + let db = init_database_memory().await.unwrap(); + let share_repo = SqliteResourceShareRepository::new(db.pool().clone()); + let user_repo = SqliteUserRepository::new(db.pool().clone()); + let owner = user_repo.create_user("owner", "hash").await.unwrap(); + let grantee = user_repo.create_user("grantee", "hash").await.unwrap(); + + // Seed a conversation owned by owner so resolve_access can find it. + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, status, source, pinned, created_at, updated_at) \ + VALUES ('conv_1', ?, 'Test', 'gemini', '{}', 'pending', 'aionui', 0, 1, 1)", + ) + .bind(&owner.id) + .execute(db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO providers \ + (id, user_id, platform, name, base_url, api_key_encrypted, models, enabled, \ + capabilities, model_settings, is_full_url, created_at, updated_at) \ + VALUES ('prov_1', ?, 'openai', 'OpenAI', 'https://api.openai.com', 'enc', '[]', 1, '[]', '{}', 0, 1, 1)", + ) + .bind(&owner.id) + .execute(db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO projects (project_id, user_id, name, kind, created_at, updated_at) \ + VALUES ('proj_1', ?, 'Project', 'standard', 1, 1)", + ) + .bind(&owner.id) + .execute(db.pool()) + .await + .unwrap(); + + (share_repo, owner.id, grantee.id) + } + + #[tokio::test] + async fn grant_and_resolve_access() { + let (repo, owner_id, grantee_id) = setup().await; + + assert_eq!( + repo.resolve_access(ShareResourceType::Conversation, "conv_1", &owner_id) + .await + .unwrap(), + ResourceAccess::Owner + ); + assert_eq!( + repo.resolve_access(ShareResourceType::Conversation, "conv_1", &grantee_id) + .await + .unwrap(), + ResourceAccess::None + ); + + let share = repo + .grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: "conv_1", + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::View, + created_by: &owner_id, + }) + .await + .unwrap(); + + assert_eq!(share.permission, "view"); + assert_eq!( + repo.resolve_access(ShareResourceType::Conversation, "conv_1", &grantee_id) + .await + .unwrap(), + ResourceAccess::View + ); + assert!( + !repo + .resolve_access(ShareResourceType::Conversation, "conv_1", &grantee_id) + .await + .unwrap() + .allows_edit() + ); + } + + #[tokio::test] + async fn grant_upserts_permission() { + let (repo, owner_id, grantee_id) = setup().await; + + repo.grant(GrantShareParams { + resource_type: ShareResourceType::Provider, + resource_id: "prov_1", + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::View, + created_by: &owner_id, + }) + .await + .unwrap(); + + let upgraded = repo + .grant(GrantShareParams { + resource_type: ShareResourceType::Provider, + resource_id: "prov_1", + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::Edit, + created_by: &owner_id, + }) + .await + .unwrap(); + + assert_eq!(upgraded.permission, "edit"); + assert_eq!( + repo.list_for_resource(ShareResourceType::Provider, "prov_1") + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + repo.resolve_access(ShareResourceType::Provider, "prov_1", &grantee_id) + .await + .unwrap(), + ResourceAccess::Edit + ); + } + + #[tokio::test] + async fn revoke_removes_access() { + let (repo, owner_id, grantee_id) = setup().await; + let share = repo + .grant(GrantShareParams { + resource_type: ShareResourceType::Project, + resource_id: "proj_1", + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::Edit, + created_by: &owner_id, + }) + .await + .unwrap(); + + repo.revoke(&share.id).await.unwrap(); + assert_eq!( + repo.resolve_access(ShareResourceType::Project, "proj_1", &grantee_id) + .await + .unwrap(), + ResourceAccess::None + ); + assert!(matches!( + repo.revoke(&share.id).await.unwrap_err(), + DbError::NotFound(_) + )); + } + + #[tokio::test] + async fn cannot_share_with_self() { + let (repo, owner_id, _) = setup().await; + let err = repo + .grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: "conv_1", + owner_user_id: &owner_id, + grantee_user_id: &owner_id, + permission: SharePermission::View, + created_by: &owner_id, + }) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Conflict(_))); + } + + #[tokio::test] + async fn list_granted_and_received() { + let (repo, owner_id, grantee_id) = setup().await; + repo.grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: "conv_1", + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::View, + created_by: &owner_id, + }) + .await + .unwrap(); + + assert_eq!(repo.list_granted_by(&owner_id).await.unwrap().len(), 1); + assert!(repo.list_granted_by(&grantee_id).await.unwrap().is_empty()); + assert_eq!(repo.list_received_by(&grantee_id).await.unwrap().len(), 1); + assert!(repo.list_received_by(&owner_id).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn missing_resource_resolves_none() { + let (repo, owner_id, _) = setup().await; + assert_eq!( + repo.resolve_access(ShareResourceType::Conversation, "missing", &owner_id) + .await + .unwrap(), + ResourceAccess::None + ); + } +} diff --git a/crates/aionui-db/src/repository/sqlite_team.rs b/crates/aionui-db/src/repository/sqlite_team.rs index a2d3a9db6..fd94d4428 100644 --- a/crates/aionui-db/src/repository/sqlite_team.rs +++ b/crates/aionui-db/src/repository/sqlite_team.rs @@ -315,16 +315,23 @@ impl ITeamRepository for SqliteTeamRepository { Ok(rows) } - async fn list_messages_by_team(&self, team_id: &str, limit: i64) -> Result, DbError> { + async fn list_messages_by_team( + &self, + user_id: &str, + team_id: &str, + limit: i64, + ) -> Result, DbError> { let rows = sqlx::query_as::<_, MailboxMessageRow>( "SELECT id, team_id, to_agent_id, from_agent_id, \ type, content, summary, files, read, created_at \ FROM mailbox \ WHERE team_id = ? \ + AND EXISTS (SELECT 1 FROM teams t WHERE t.id = mailbox.team_id AND t.user_id = ?) \ ORDER BY created_at DESC \ LIMIT ?", ) .bind(team_id) + .bind(user_id) .bind(limit) .fetch_all(&self.pool) .await?; @@ -333,6 +340,7 @@ impl ITeamRepository for SqliteTeamRepository { async fn list_messages_by_team_paged( &self, + user_id: &str, team_id: &str, cursor: Option, direction: PageDirection, @@ -351,11 +359,13 @@ impl ITeamRepository for SqliteTeamRepository { "SELECT id, team_id, to_agent_id, from_agent_id, \ type, content, summary, files, read, created_at \ FROM mailbox \ - WHERE team_id = ? {cursor_clause}\ + WHERE team_id = ? \ + AND EXISTS (SELECT 1 FROM teams t WHERE t.id = mailbox.team_id AND t.user_id = ?) \ + {cursor_clause}\ ORDER BY created_at {order}, id {order} \ LIMIT ?" ); - let mut q = sqlx::query_as::<_, MailboxMessageRow>(&sql).bind(team_id); + let mut q = sqlx::query_as::<_, MailboxMessageRow>(&sql).bind(team_id).bind(user_id); if let Some(c) = &cursor { q = q.bind(c.created_at).bind(c.created_at).bind(&c.id); } @@ -363,7 +373,12 @@ impl ITeamRepository for SqliteTeamRepository { Ok(rows) } - async fn list_messages_by_ids(&self, ids: &[String]) -> Result, DbError> { + async fn list_messages_by_ids( + &self, + user_id: &str, + team_id: &str, + ids: &[String], + ) -> Result, DbError> { if ids.is_empty() { return Ok(Vec::new()); } @@ -376,10 +391,12 @@ impl ITeamRepository for SqliteTeamRepository { "SELECT id, team_id, to_agent_id, from_agent_id, \ type, content, summary, files, read, created_at \ FROM mailbox \ - WHERE id IN ({placeholders}) \ + WHERE team_id = ? \ + AND EXISTS (SELECT 1 FROM teams t WHERE t.id = mailbox.team_id AND t.user_id = ?) \ + AND id IN ({placeholders}) \ ORDER BY created_at DESC" ); - let mut query = sqlx::query_as::<_, MailboxMessageRow>(&sql); + let mut query = sqlx::query_as::<_, MailboxMessageRow>(&sql).bind(team_id).bind(user_id); for id in chunk { query = query.bind(id); } diff --git a/crates/aionui-db/src/repository/sqlite_user.rs b/crates/aionui-db/src/repository/sqlite_user.rs index 239e06592..05ae7607a 100644 --- a/crates/aionui-db/src/repository/sqlite_user.rs +++ b/crates/aionui-db/src/repository/sqlite_user.rs @@ -1,13 +1,13 @@ use sqlx::SqlitePool; use crate::error::DbError; -use crate::models::{ExternalUserProjection, User, UserStatus, UserType}; +use crate::models::{ExternalUserProjection, SiteRole, User, UserStatus, UserType}; use crate::repository::IUserRepository; /// SQLite-backed implementation of [`IUserRepository`]. #[derive(Clone, Debug)] pub struct SqliteUserRepository { - pool: SqlitePool, + pub(crate) pool: SqlitePool, } impl SqliteUserRepository { @@ -29,6 +29,17 @@ impl IUserRepository for SqliteUserRepository { Ok(row.0 > 0) } + async fn has_usable_admin(&self) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users \ + WHERE user_type = 'local' AND site_role = 'admin' AND status = 'active' \ + AND password_hash IS NOT NULL AND password_hash != ''", + ) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + async fn get_system_user(&self) -> Result, DbError> { let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = 'system_default_user'") .fetch_optional(&self.pool) @@ -54,7 +65,8 @@ impl IUserRepository for SqliteUserRepository { async fn set_system_user_credentials(&self, username: &str, password_hash: &str) -> Result<(), DbError> { let now = aionui_common::now_ms(); let result = sqlx::query( - "UPDATE users SET username = ?, password_hash = ?, updated_at = ? \ + "UPDATE users SET username = ?, password_hash = ?, site_role = 'admin', \ + status = 'active', must_change_password = 0, updated_at = ? \ WHERE id = 'system_default_user' AND user_type = 'local'", ) .bind(username) @@ -108,6 +120,8 @@ impl IUserRepository for SqliteUserRepository { avatar_path: None, jwt_secret: None, status: UserStatus::Active, + site_role: SiteRole::Member, + must_change_password: false, session_generation: 0, created_at: now, updated_at: now, @@ -118,7 +132,7 @@ impl IUserRepository for SqliteUserRepository { async fn find_by_username(&self, username: &str) -> Result, DbError> { let user = sqlx::query_as::<_, User>( "SELECT * FROM users \ - WHERE user_type = 'local' AND password_hash IS NOT NULL AND username = ?", + WHERE user_type = 'local' AND status = 'active' AND password_hash IS NOT NULL AND username = ?", ) .bind(username) .fetch_optional(&self.pool) @@ -447,6 +461,77 @@ impl IUserRepository for SqliteUserRepository { Ok(generation) } + + async fn create_auth_session(&self, user_id: &str, expires_at: i64) -> Result { + let id = aionui_common::generate_prefixed_id("session"); + let now = aionui_common::now_ms(); + sqlx::query( + "INSERT INTO auth_sessions (id, user_id, created_at, last_seen_at, expires_at) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(user_id) + .bind(now) + .bind(now) + .bind(expires_at) + .execute(&self.pool) + .await?; + Ok(id) + } + + async fn is_auth_session_active(&self, session_id: &str, user_id: &str) -> Result { + let now = aionui_common::now_ms(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth_sessions \ + WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?", + ) + .bind(session_id) + .bind(user_id) + .bind(now) + .fetch_one(&self.pool) + .await?; + Ok(count == 1) + } + + async fn touch_auth_session(&self, session_id: &str, user_id: &str) -> Result<(), DbError> { + sqlx::query( + "UPDATE auth_sessions SET last_seen_at = ? \ + WHERE id = ? AND user_id = ? AND revoked_at IS NULL", + ) + .bind(aionui_common::now_ms()) + .bind(session_id) + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn revoke_auth_session(&self, session_id: &str, user_id: &str, reason: &str) -> Result<(), DbError> { + sqlx::query( + "UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, ?), \ + revoke_reason = COALESCE(revoke_reason, ?) WHERE id = ? AND user_id = ?", + ) + .bind(aionui_common::now_ms()) + .bind(reason) + .bind(session_id) + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn revoke_all_auth_sessions(&self, user_id: &str, reason: &str) -> Result { + let result = sqlx::query( + "UPDATE auth_sessions SET revoked_at = ?, revoke_reason = ? \ + WHERE user_id = ? AND revoked_at IS NULL", + ) + .bind(aionui_common::now_ms()) + .bind(reason) + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } } /// Checks if a SQLite database error is a UNIQUE constraint violation. diff --git a/crates/aionui-db/src/repository/team.rs b/crates/aionui-db/src/repository/team.rs index 472d39163..694f5dc6e 100644 --- a/crates/aionui-db/src/repository/team.rs +++ b/crates/aionui-db/src/repository/team.rs @@ -111,13 +111,19 @@ pub trait ITeamRepository: Send + Sync { /// Returns the most recent messages for the whole team, ordered by /// `created_at` descending and capped at `limit`. Backs the read-only /// team activity view (all recipients, not a single mailbox). - async fn list_messages_by_team(&self, team_id: &str, limit: i64) -> Result, DbError>; + async fn list_messages_by_team( + &self, + user_id: &str, + team_id: &str, + limit: i64, + ) -> Result, DbError>; /// Keyset-paginated team-wide messages for the activity feed. Returns up to /// `limit` rows strictly beyond `cursor` in `direction` order (no cursor = /// first page). Ordered `(created_at, id)` per direction. async fn list_messages_by_team_paged( &self, + user_id: &str, team_id: &str, cursor: Option, direction: PageDirection, @@ -127,7 +133,12 @@ pub trait ITeamRepository: Send + Sync { /// Returns the message rows with the given ids, ordered by `created_at` /// descending. Used to build full payloads after a batch read-mark. /// An empty `ids` slice yields an empty result without querying. - async fn list_messages_by_ids(&self, ids: &[String]) -> Result, DbError>; + async fn list_messages_by_ids( + &self, + user_id: &str, + team_id: &str, + ids: &[String], + ) -> Result, DbError>; /// Deletes all mailbox messages belonging to a team. async fn delete_mailbox_by_team(&self, user_id: &str, team_id: &str) -> Result<(), DbError>; diff --git a/crates/aionui-db/src/repository/user.rs b/crates/aionui-db/src/repository/user.rs index ea3f4a35b..618b2bf81 100644 --- a/crates/aionui-db/src/repository/user.rs +++ b/crates/aionui-db/src/repository/user.rs @@ -14,6 +14,9 @@ pub trait IUserRepository: Send + Sync { /// The system default user (empty password_hash) does not count. async fn has_users(&self) -> Result; + /// Returns true when at least one active local administrator can log in. + async fn has_usable_admin(&self) -> Result; + /// Returns the system default user (`id = "system_default_user"`). async fn get_system_user(&self) -> Result, DbError>; @@ -104,4 +107,19 @@ pub trait IUserRepository: Send + Sync { /// Increments a user's session generation and returns the new value. async fn increment_session_generation(&self, user_id: &str) -> Result; + + /// Creates a persistent authentication session and returns its opaque ID. + async fn create_auth_session(&self, user_id: &str, expires_at: i64) -> Result; + + /// Returns whether a persistent session is active for this user. + async fn is_auth_session_active(&self, session_id: &str, user_id: &str) -> Result; + + /// Updates last-seen time for an active persistent session. + async fn touch_auth_session(&self, session_id: &str, user_id: &str) -> Result<(), DbError>; + + /// Revokes one persistent authentication session. + async fn revoke_auth_session(&self, session_id: &str, user_id: &str, reason: &str) -> Result<(), DbError>; + + /// Revokes all active persistent sessions for a user. + async fn revoke_all_auth_sessions(&self, user_id: &str, reason: &str) -> Result; } diff --git a/crates/aionui-db/tests/adoption_coverage.rs b/crates/aionui-db/tests/adoption_coverage.rs index 4ef2b33a8..33eb45c5c 100644 --- a/crates/aionui-db/tests/adoption_coverage.rs +++ b/crates/aionui-db/tests/adoption_coverage.rs @@ -54,7 +54,13 @@ const NON_CORE_USER_ID_TABLES: &[(&str, &str)] = &[ ]; /// Identity / infrastructure tables outside the ownership model. -const INFRA_TABLES: &[&str] = &["users", "_sqlx_migrations"]; +const INFRA_TABLES: &[&str] = &[ + // Site-level identity administration history must remain append-only and + // is not adopted into any individual AionPro identity. + "admin_audit_log", + "users", + "_sqlx_migrations", +]; async fn table_names(pool: &sqlx::SqlitePool) -> Vec { sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") diff --git a/crates/aionui-db/tests/assistant_data_unification_schema.rs b/crates/aionui-db/tests/assistant_data_unification_schema.rs index 38a17045b..2b17f897e 100644 --- a/crates/aionui-db/tests/assistant_data_unification_schema.rs +++ b/crates/aionui-db/tests/assistant_data_unification_schema.rs @@ -31,6 +31,22 @@ async fn migration_creates_assistant_unification_tables_and_keeps_legacy_tables( ); } +#[tokio::test] +async fn legacy_assistant_ids_are_scoped_by_user_in_primary_key() { + let db = init_database_memory().await.unwrap(); + + let primary_key_columns: Vec<(String, i64)> = + sqlx::query_as("SELECT name, pk FROM pragma_table_info('assistants') WHERE pk > 0 ORDER BY pk") + .fetch_all(db.pool()) + .await + .unwrap(); + + assert_eq!( + primary_key_columns, + vec![("user_id".to_string(), 1), ("id".to_string(), 2)], + ); +} + #[tokio::test] async fn assistant_definition_table_has_expected_default_columns() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-db/tests/resource_share_repository.rs b/crates/aionui-db/tests/resource_share_repository.rs new file mode 100644 index 000000000..dcacd2421 --- /dev/null +++ b/crates/aionui-db/tests/resource_share_repository.rs @@ -0,0 +1,172 @@ +//! Black-box integration tests for resource share collaboration. +//! +//! Covers grant/revoke and access resolution against an in-memory database, +//! including conversation readability for shared grantees. + +use std::sync::Arc; + +use aionui_db::{ + ConversationFilters, GrantShareParams, IConversationRepository, IResourceShareRepository, IUserRepository, + ResourceAccess, SharePermission, ShareResourceType, SqliteConversationRepository, SqliteResourceShareRepository, + SqliteUserRepository, init_database_memory, models::ConversationRow, +}; + +async fn setup() -> ( + Arc, + Arc, + Arc, + String, + String, +) { + let db = init_database_memory().await.unwrap(); + let share_repo: Arc = Arc::new(SqliteResourceShareRepository::new(db.pool().clone())); + let conv_repo: Arc = Arc::new(SqliteConversationRepository::new(db.pool().clone())); + let user_repo: Arc = Arc::new(SqliteUserRepository::new(db.pool().clone())); + + let owner = user_repo.create_user("owner_alice", "hash").await.unwrap(); + let grantee = user_repo.create_user("grantee_bob", "hash").await.unwrap(); + (share_repo, conv_repo, user_repo, owner.id, grantee.id) +} + +fn make_conv(owner_id: &str) -> ConversationRow { + let now = aionui_common::now_ms(); + ConversationRow { + id: aionui_common::generate_prefixed_id("conv"), + user_id: owner_id.to_owned(), + name: "Shared chat".into(), + r#type: "gemini".into(), + extra: "{}".into(), + model: None, + status: Some("pending".into()), + source: Some("aionui".into()), + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: now, + updated_at: now, + project_id: None, + folder_id: None, + name_source: None, + } +} + +#[tokio::test] +async fn shared_conversation_is_readable_but_not_writable_with_view() { + let (shares, convs, _users, owner_id, grantee_id) = setup().await; + let conv = make_conv(&owner_id); + convs.create(&conv).await.unwrap(); + + shares + .grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: &conv.id, + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::View, + created_by: &owner_id, + }) + .await + .unwrap(); + + assert_eq!( + shares + .resolve_access(ShareResourceType::Conversation, &conv.id, &grantee_id) + .await + .unwrap(), + ResourceAccess::View + ); + + // List/get include shared conversations. + let found = convs.get(&grantee_id, &conv.id).await.unwrap(); + assert!(found.is_some()); + let listed = convs + .list_paginated( + &grantee_id, + &ConversationFilters { + cursor: None, + limit: 20, + source: None, + cron_job_id: None, + pinned: None, + }, + ) + .await + .unwrap(); + assert_eq!(listed.items.len(), 1); + + // View cannot update (write requires edit/owner). + let err = convs + .update( + &grantee_id, + &conv.id, + &aionui_db::ConversationRowUpdate { + name: Some("hacked".into()), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, aionui_db::DbError::NotFound(_))); +} + +#[tokio::test] +async fn edit_share_allows_conversation_update() { + let (shares, convs, _users, owner_id, grantee_id) = setup().await; + let conv = make_conv(&owner_id); + convs.create(&conv).await.unwrap(); + + shares + .grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: &conv.id, + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::Edit, + created_by: &owner_id, + }) + .await + .unwrap(); + + convs + .update( + &grantee_id, + &conv.id, + &aionui_db::ConversationRowUpdate { + name: Some("renamed by grantee".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let updated = convs.get(&owner_id, &conv.id).await.unwrap().unwrap(); + assert_eq!(updated.name, "renamed by grantee"); +} + +#[tokio::test] +async fn revoke_restores_isolation() { + let (shares, convs, _users, owner_id, grantee_id) = setup().await; + let conv = make_conv(&owner_id); + convs.create(&conv).await.unwrap(); + let share = shares + .grant(GrantShareParams { + resource_type: ShareResourceType::Conversation, + resource_id: &conv.id, + owner_user_id: &owner_id, + grantee_user_id: &grantee_id, + permission: SharePermission::Edit, + created_by: &owner_id, + }) + .await + .unwrap(); + + shares.revoke(&share.id).await.unwrap(); + assert!(convs.get(&grantee_id, &conv.id).await.unwrap().is_none()); + assert_eq!( + shares + .resolve_access(ShareResourceType::Conversation, &conv.id, &grantee_id) + .await + .unwrap(), + ResourceAccess::None + ); +} diff --git a/crates/aionui-db/tests/team_repository.rs b/crates/aionui-db/tests/team_repository.rs index 60d374282..cce847ad2 100644 --- a/crates/aionui-db/tests/team_repository.rs +++ b/crates/aionui-db/tests/team_repository.rs @@ -443,14 +443,14 @@ async fn list_messages_by_team_orders_desc_and_clamps_limit() { } // limit smaller than total keeps the newest ones, newest first. - let latest = repo.list_messages_by_team("t1", 3).await.unwrap(); + let latest = repo.list_messages_by_team(DEFAULT_USER_ID, "t1", 3).await.unwrap(); assert_eq!(latest.len(), 3); assert_eq!(latest[0].id, "m5"); assert_eq!(latest[1].id, "m4"); assert_eq!(latest[2].id, "m3"); // limit above total returns everything (whole team, not a single mailbox). - let all = repo.list_messages_by_team("t1", 1000).await.unwrap(); + let all = repo.list_messages_by_team(DEFAULT_USER_ID, "t1", 1000).await.unwrap(); assert_eq!(all.len(), 5); assert_eq!(all.first().unwrap().id, "m5"); } @@ -465,7 +465,7 @@ async fn list_messages_by_team_limit_one() { repo.write_message(DEFAULT_USER_ID, &msg).await.unwrap(); } - let one = repo.list_messages_by_team("t1", 1).await.unwrap(); + let one = repo.list_messages_by_team(DEFAULT_USER_ID, "t1", 1).await.unwrap(); assert_eq!(one.len(), 1); assert_eq!(one[0].id, "m3"); } @@ -475,7 +475,7 @@ async fn list_messages_by_team_empty() { let (repo, _db) = repo().await; repo.create_team(&make_team("t1", "Team")).await.unwrap(); - let msgs = repo.list_messages_by_team("t1", 500).await.unwrap(); + let msgs = repo.list_messages_by_team(DEFAULT_USER_ID, "t1", 500).await.unwrap(); assert!(msgs.is_empty()); } @@ -490,12 +490,16 @@ async fn list_messages_by_ids_hits_empty_and_partial() { } // Empty ids -> empty result without touching the DB. - let none = repo.list_messages_by_ids(&[]).await.unwrap(); + let none = repo.list_messages_by_ids(DEFAULT_USER_ID, "t1", &[]).await.unwrap(); assert!(none.is_empty()); // Mix of existing and missing ids returns only the hits, newest first. let hits = repo - .list_messages_by_ids(&["m1".to_string(), "m3".to_string(), "missing".to_string()]) + .list_messages_by_ids( + DEFAULT_USER_ID, + "t1", + &["m1".to_string(), "m3".to_string(), "missing".to_string()], + ) .await .unwrap(); assert_eq!(hits.len(), 2); @@ -503,6 +507,49 @@ async fn list_messages_by_ids_hits_empty_and_partial() { assert_eq!(hits[1].id, "m1"); } +#[tokio::test] +async fn team_message_listing_queries_enforce_owner_and_team() { + let (repo, _db) = repo().await; + repo.create_team(&make_team_for_user("team-a", "user-a", "A")) + .await + .unwrap(); + repo.create_team(&make_team_for_user("team-b", "user-b", "B")) + .await + .unwrap(); + repo.write_message( + "user-a", + &make_mailbox_msg("message-a", "team-a", "lead", "worker", "message"), + ) + .await + .unwrap(); + repo.write_message( + "user-b", + &make_mailbox_msg("message-b", "team-b", "lead", "worker", "message"), + ) + .await + .unwrap(); + + assert!( + repo.list_messages_by_team("user-a", "team-b", 10) + .await + .unwrap() + .is_empty() + ); + assert!( + repo.list_messages_by_team_paged("user-a", "team-b", None, PageDirection::Desc, 10) + .await + .unwrap() + .is_empty() + ); + + let rows = repo + .list_messages_by_ids("user-a", "team-a", &["message-a".to_owned(), "message-b".to_owned()]) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "message-a"); +} + #[tokio::test] async fn scoped_mailbox_delete_and_mark_read_do_not_cross_team_owner() { let (repo, _db) = repo().await; @@ -1050,7 +1097,7 @@ async fn paged_messages_desc_walks_older_with_stable_tiebreak() { // First page desc, limit 2 -> newest two: m5(4000), m4(3000,id"m4"). let page1 = repo - .list_messages_by_team_paged("t1", None, PageDirection::Desc, 2) + .list_messages_by_team_paged(DEFAULT_USER_ID, "t1", None, PageDirection::Desc, 2) .await .unwrap(); assert_eq!(page1.iter().map(|r| r.id.as_str()).collect::>(), ["m5", "m4"]); @@ -1061,7 +1108,7 @@ async fn paged_messages_desc_walks_older_with_stable_tiebreak() { id: "m4".into(), }; let page2 = repo - .list_messages_by_team_paged("t1", Some(cursor), PageDirection::Desc, 2) + .list_messages_by_team_paged(DEFAULT_USER_ID, "t1", Some(cursor), PageDirection::Desc, 2) .await .unwrap(); assert_eq!(page2.iter().map(|r| r.id.as_str()).collect::>(), ["m3", "m2"]); @@ -1077,7 +1124,7 @@ async fn paged_messages_asc_walks_newer_from_oldest() { repo.write_message(DEFAULT_USER_ID, &msg).await.unwrap(); } let page1 = repo - .list_messages_by_team_paged("t1", None, PageDirection::Asc, 2) + .list_messages_by_team_paged(DEFAULT_USER_ID, "t1", None, PageDirection::Asc, 2) .await .unwrap(); assert_eq!(page1.iter().map(|r| r.id.as_str()).collect::>(), ["m1", "m2"]); @@ -1087,7 +1134,7 @@ async fn paged_messages_asc_walks_newer_from_oldest() { id: "m2".into(), }; let page2 = repo - .list_messages_by_team_paged("t1", Some(cursor), PageDirection::Asc, 2) + .list_messages_by_team_paged(DEFAULT_USER_ID, "t1", Some(cursor), PageDirection::Asc, 2) .await .unwrap(); assert_eq!(page2.iter().map(|r| r.id.as_str()).collect::>(), ["m3"]); diff --git a/crates/aionui-extension/src/hub_routes.rs b/crates/aionui-extension/src/hub_routes.rs index 00b9b4dbd..67a05f250 100644 --- a/crates/aionui-extension/src/hub_routes.rs +++ b/crates/aionui-extension/src/hub_routes.rs @@ -3,11 +3,13 @@ use axum::Router; use axum::extract::rejection::JsonRejection; use axum::extract::{Json, State}; +use axum::middleware::from_fn; use axum::routing::{get, post}; use aionui_api_types::{ ApiResponse, HubExtensionListItem, HubOperationResponse, HubUpdateInfo as ApiHubUpdateInfo, InstallExtensionRequest, }; +use aionui_auth::admin_required_middleware; use aionui_common::ApiError; use crate::hub::index_manager::HubIndexManager; @@ -34,11 +36,23 @@ pub struct HubRouterState { pub fn hub_routes(state: HubRouterState) -> Router { Router::new() .route("/api/hub/extensions", get(get_hub_extensions)) - .route("/api/hub/install", post(install_extension)) - .route("/api/hub/retry-install", post(retry_install)) + .route( + "/api/hub/install", + post(install_extension).route_layer(from_fn(admin_required_middleware)), + ) + .route( + "/api/hub/retry-install", + post(retry_install).route_layer(from_fn(admin_required_middleware)), + ) .route("/api/hub/check-updates", post(check_updates)) - .route("/api/hub/update", post(update_extension)) - .route("/api/hub/uninstall", post(uninstall_extension)) + .route( + "/api/hub/update", + post(update_extension).route_layer(from_fn(admin_required_middleware)), + ) + .route( + "/api/hub/uninstall", + post(uninstall_extension).route_layer(from_fn(admin_required_middleware)), + ) .with_state(state) } @@ -151,8 +165,14 @@ mod tests { use super::*; use crate::registry::ExtensionRegistry; use crate::state::ExtensionStateStore; + use aionui_auth::CurrentUser; + use aionui_db::{SiteRole, UserStatus, UserType}; use aionui_realtime::BroadcastEventBus; + use axum::Extension; + use axum::body::Body; + use axum::http::{Method, Request, StatusCode, header}; use std::sync::Arc; + use tower::ServiceExt; fn make_state() -> HubRouterState { let tmp = tempfile::TempDir::new().unwrap(); @@ -174,4 +194,62 @@ mod tests { let state = make_state(); let _router = hub_routes(state); } + + fn current_user(role: SiteRole) -> CurrentUser { + CurrentUser { + id: "test-user".to_owned(), + username: "test-user".to_owned(), + user_type: UserType::Local, + status: UserStatus::Active, + site_role: role, + must_change_password: false, + } + } + + fn operation_request(path: &str) -> Request { + Request::builder() + .method(Method::POST) + .uri(path) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"name":"missing-extension"}"#)) + .unwrap() + } + + #[tokio::test] + async fn member_cannot_mutate_global_hub_state_but_can_list() { + let app = hub_routes(make_state()).layer(Extension(current_user(SiteRole::Member))); + + for path in [ + "/api/hub/install", + "/api/hub/retry-install", + "/api/hub/update", + "/api/hub/uninstall", + ] { + let response = app.clone().oneshot(operation_request(path)).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "member mutation must fail: {path}" + ); + } + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/hub/extensions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn live_local_admin_reaches_global_hub_mutation_handler() { + let app = hub_routes(make_state()).layer(Extension(current_user(SiteRole::Admin))); + let response = app.oneshot(operation_request("/api/hub/install")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/crates/aionui-extension/src/skill_routes.rs b/crates/aionui-extension/src/skill_routes.rs index bb664a499..02a14567e 100644 --- a/crates/aionui-extension/src/skill_routes.rs +++ b/crates/aionui-extension/src/skill_routes.rs @@ -1,11 +1,12 @@ #![allow(clippy::disallowed_types)] -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use axum::Router; use axum::extract::rejection::JsonRejection; use axum::extract::{Extension, Json, Path as AxumPath, State}; +use axum::http::StatusCode; use axum::routing::{delete, get, post}; use tracing::warn; @@ -19,7 +20,7 @@ use aionui_api_types::{ }; use aionui_auth::CurrentUser; use aionui_common::ApiError; -use aionui_db::ISkillRepository; +use aionui_db::{ISkillRepository, SiteRole}; use crate::classifier::AssistantRuleDispatcher; use crate::error::ExtensionError; @@ -39,6 +40,165 @@ fn is_auto_inject_builtin_skill(source: SkillSource, relative_location: Option<& source == SkillSource::Builtin && relative_location.is_some_and(|location| location.starts_with("auto-inject/")) } +fn admin_required(user: &CurrentUser) -> Result<(), ApiError> { + if user.site_role != SiteRole::Admin { + return Err(ApiError::coded( + StatusCode::FORBIDDEN, + "ADMIN_REQUIRED", + "Administrator access required.", + None, + )); + } + Ok(()) +} + +fn user_filesystem_denied() -> ApiError { + ApiError::coded( + StatusCode::FORBIDDEN, + "USER_FILESYSTEM_DENIED", + "User filesystem access denied.", + None, + ) +} + +fn normalized_absolute_path(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| user_filesystem_denied())? + .join(path) + }; + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => return Err(user_filesystem_denied()), + Component::Normal(name) => normalized.push(name), + } + } + Ok(normalized) +} + +fn user_dir_name(user: &CurrentUser) -> Result { + aionui_common::user_dir_name(&user.id).map_err(|_| user_filesystem_denied()) +} + +fn own_managed_roots(state: &SkillRouterState, user: &CurrentUser) -> Result, ApiError> { + let user_dir = user_dir_name(user)?; + Ok(vec![ + state.skill_paths.user_skills_dir.join("users").join(&user_dir), + state.skill_paths.data_dir.join("uploads").join("users").join(&user_dir), + state + .skill_paths + .data_dir + .join("user-workspaces") + .join("conversations") + .join("users") + .join(&user_dir), + state.skill_paths.assistant_rules_dir.join("users").join(&user_dir), + state.skill_paths.assistant_skills_dir.join("users").join(user_dir), + ]) +} + +fn private_managed_roots(state: &SkillRouterState) -> Vec { + vec![ + state.skill_paths.user_skills_dir.join("users"), + state.skill_paths.data_dir.join("uploads").join("users"), + state + .skill_paths + .data_dir + .join("user-workspaces") + .join("conversations") + .join("users"), + state.skill_paths.assistant_rules_dir.join("users"), + state.skill_paths.assistant_skills_dir.join("users"), + state.skill_paths.cron_skills_dir.clone(), + ] +} + +async fn path_matches_root(raw_path: &Path, canonical_path: &Path, root: &Path) -> bool { + let Ok(raw_root) = normalized_absolute_path(root) else { + return false; + }; + let Ok(canonical_root) = tokio::fs::canonicalize(root).await else { + return false; + }; + raw_path.starts_with(raw_root) && canonical_path.starts_with(canonical_root) +} + +async fn canonical_path_matches_root(canonical_path: &Path, root: &Path) -> bool { + tokio::fs::canonicalize(root) + .await + .is_ok_and(|canonical_root| canonical_path.starts_with(canonical_root)) +} + +async fn resolve_path_for_authorization(raw_path: &Path) -> Result { + if let Ok(canonical_path) = tokio::fs::canonicalize(raw_path).await { + return Ok(canonical_path); + } + + // Preserve domain-level not-found behavior without trusting lexical + // ancestry: canonicalize the nearest existing ancestor, then append only + // the already-normalized missing suffix. + let mut ancestor = raw_path; + let mut suffix = Vec::new(); + loop { + if let Ok(mut canonical_ancestor) = tokio::fs::canonicalize(ancestor).await { + for component in suffix.iter().rev() { + canonical_ancestor.push(component); + } + return Ok(canonical_ancestor); + } + let Some(name) = ancestor.file_name() else { + return Err(user_filesystem_denied()); + }; + suffix.push(name.to_os_string()); + ancestor = ancestor.parent().ok_or_else(user_filesystem_denied)?; + } +} + +/// Resolve a client path and enforce per-user managed roots. Administrators +/// may use host infrastructure paths, but never another user's managed data. +async fn authorize_skill_path( + state: &SkillRouterState, + user: &CurrentUser, + requested_path: &Path, +) -> Result { + let raw_path = normalized_absolute_path(requested_path)?; + let canonical_path = resolve_path_for_authorization(&raw_path).await?; + let own_roots = own_managed_roots(state, user)?; + + for root in &own_roots { + if path_matches_root(&raw_path, &canonical_path, root).await { + return Ok(canonical_path); + } + } + + if user.site_role != SiteRole::Admin { + return Err(user_filesystem_denied()); + } + + // An administrator may reach their own managed root through a host alias, + // but must not use either a lexical path or a symlink target to reach a + // different user's private tree. + for root in &own_roots { + if canonical_path_matches_root(&canonical_path, root).await { + return Ok(canonical_path); + } + } + for root in private_managed_roots(state) { + let raw_root = normalized_absolute_path(&root)?; + if raw_path.starts_with(raw_root) || canonical_path_matches_root(&canonical_path, &root).await { + return Err(user_filesystem_denied()); + } + } + + Ok(canonical_path) +} + // --------------------------------------------------------------------------- // Router state // --------------------------------------------------------------------------- @@ -137,18 +297,33 @@ async fn list_skills( /// `POST /api/skills/info` — read skill info without importing. async fn read_skill_info( + State(state): State, + Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let (name, description) = skill_service::read_skill_info(Path::new(&req.skill_path)).await?; + let skill_path = authorize_skill_path(&state, ¤t_user, Path::new(&req.skill_path)).await?; + let (name, description) = skill_service::read_skill_info(&skill_path).await?; Ok(Json(ApiResponse::ok(ReadSkillInfoResponse { name, description }))) } /// `GET /api/skills/paths` — get user and built-in skill directories. async fn get_skill_paths( State(state): State, + Extension(current_user): Extension, ) -> Result>, ApiError> { - let (user_dir, builtin_dir) = skill_service::get_skill_paths(&state.skill_paths); + let (user_dir, builtin_dir) = if current_user.site_role == SiteRole::Admin { + skill_service::get_skill_paths(&state.skill_paths) + } else { + let own_root = own_managed_roots(&state, ¤t_user)? + .into_iter() + .next() + .ok_or_else(user_filesystem_denied)?; + ( + own_root.to_string_lossy().into_owned(), + state.skill_paths.builtin_skills_dir.to_string_lossy().into_owned(), + ) + }; Ok(Json(ApiResponse::ok(SkillPathsResponse { user_skills_dir: user_dir, builtin_skills_dir: builtin_dir, @@ -175,11 +350,12 @@ async fn import_skill( body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let source_path = authorize_skill_path(&state, ¤t_user, Path::new(&req.skill_path)).await?; let outcome = match skill_service::import_skills_with_repo_for_user( &state.skill_paths, state.skill_repo.as_ref(), ¤t_user.id, - Path::new(&req.skill_path), + &source_path, ) .await { @@ -226,10 +402,15 @@ async fn import_skill( /// `POST /api/skills/export-symlink` — export a skill symlink. async fn export_skill_symlink( + State(state): State, + Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + admin_required(¤t_user)?; let Json(req) = body.map_err(ApiError::from)?; - skill_service::export_skill_with_symlink(Path::new(&req.skill_path), Path::new(&req.target_dir)).await?; + let skill_path = authorize_skill_path(&state, ¤t_user, Path::new(&req.skill_path)).await?; + let target_dir = authorize_skill_path(&state, ¤t_user, Path::new(&req.target_dir)).await?; + skill_service::export_skill_with_symlink(&skill_path, &target_dir).await?; Ok(Json(ApiResponse::success())) } @@ -288,10 +469,13 @@ async fn list_import_history( /// `POST /api/skills/scan` — scan a directory for skills. async fn scan_for_skills( + State(state): State, + Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let skills = skill_service::scan_for_skills(Path::new(&req.folder_path)).await?; + let folder_path = authorize_skill_path(&state, ¤t_user, Path::new(&req.folder_path)).await?; + let skills = skill_service::scan_for_skills(&folder_path).await?; let resp = ScanForSkillsResponse { skills: skills .into_iter() @@ -306,7 +490,10 @@ async fn scan_for_skills( } /// `GET /api/skills/detect-paths` — detect common skill paths. -async fn detect_paths() -> Result>>, ApiError> { +async fn detect_paths( + Extension(current_user): Extension, +) -> Result>>, ApiError> { + admin_required(¤t_user)?; let paths = skill_service::detect_common_skill_paths().await; let resp: Vec = paths .into_iter() @@ -321,9 +508,30 @@ async fn detect_paths() -> Result>>, Api /// `GET /api/skills/detect-external` — discover external skills from all sources. async fn detect_external( State(state): State, + Extension(current_user): Extension, ) -> Result>>, ApiError> { + admin_required(¤t_user)?; let custom = state.external_paths_manager.get_custom_external_paths().await; - let sources = skill_service::detect_and_count_external_skills(&custom).await; + let mut authorized_custom = Vec::new(); + for path in custom { + if authorize_skill_path(&state, ¤t_user, Path::new(&path.path)) + .await + .is_ok() + { + authorized_custom.push(path); + } + } + let mut authorized_common = Vec::new(); + for path in skill_service::detect_common_skill_paths().await { + if let Ok(canonical_path) = authorize_skill_path(&state, ¤t_user, Path::new(&path.path)).await { + authorized_common.push(canonical_path); + } + } + let sources = skill_service::detect_and_count_external_skills_with_allowed_common_paths( + &authorized_custom, + &authorized_common, + ) + .await; let resp: Vec = sources .into_iter() .map(|s| ExternalSkillSourceResponse { @@ -378,6 +586,7 @@ async fn materialize_for_agent( Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + admin_required(¤t_user)?; let Json(req) = body.map_err(ApiError::from)?; if req.conversation_id.trim().is_empty() { return Err(ApiError::BadRequest("conversationId must not be empty".into())); @@ -558,7 +767,9 @@ async fn delete_assistant_skill( /// `GET /api/skills/external-paths` — list custom external paths. async fn get_external_paths( State(state): State, + Extension(current_user): Extension, ) -> Result>>, ApiError> { + admin_required(¤t_user)?; let paths = state.external_paths_manager.get_custom_external_paths().await; let resp: Vec = paths .into_iter() @@ -573,9 +784,12 @@ async fn get_external_paths( /// `POST /api/skills/external-paths` — add a custom external path. async fn add_external_path( State(state): State, + Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + admin_required(¤t_user)?; let Json(req) = body.map_err(ApiError::from)?; + authorize_skill_path(&state, ¤t_user, Path::new(&req.path)).await?; state .external_paths_manager .add_custom_external_path(&req.name, &req.path) @@ -586,8 +800,10 @@ async fn add_external_path( /// `DELETE /api/skills/external-paths` — remove a custom external path. async fn remove_external_path( State(state): State, + Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + admin_required(¤t_user)?; let Json(req) = body.map_err(ApiError::from)?; state .external_paths_manager @@ -601,13 +817,21 @@ async fn remove_external_path( // --------------------------------------------------------------------------- /// `POST /api/skills/market/enable` — enable the aionui skills market. -async fn enable_skills_market(State(state): State) -> Result>, ApiError> { +async fn enable_skills_market( + State(state): State, + Extension(current_user): Extension, +) -> Result>, ApiError> { + admin_required(¤t_user)?; state.external_paths_manager.enable_skills_market().await?; Ok(Json(ApiResponse::success())) } /// `POST /api/skills/market/disable` — disable the aionui skills market. -async fn disable_skills_market(State(state): State) -> Result>, ApiError> { +async fn disable_skills_market( + State(state): State, + Extension(current_user): Extension, +) -> Result>, ApiError> { + admin_required(¤t_user)?; state.external_paths_manager.disable_skills_market().await?; Ok(Json(ApiResponse::success())) } @@ -619,8 +843,9 @@ async fn disable_skills_market(State(state): State) -> Result< #[cfg(test)] mod tests { use super::*; - use axum::body::Body; - use axum::http::{Request, StatusCode}; + use aionui_db::{UserStatus, UserType}; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode, header}; use tower::ServiceExt; async fn make_state() -> SkillRouterState { @@ -646,6 +871,43 @@ mod tests { } } + fn current_user(id: &str, site_role: SiteRole) -> CurrentUser { + CurrentUser { + id: id.to_owned(), + username: id.to_owned(), + user_type: UserType::Local, + status: UserStatus::Active, + site_role, + must_change_password: false, + } + } + + fn write_skill(path: &Path, name: &str) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write( + path.join("SKILL.md"), + format!("---\nname: {name}\ndescription: Test skill\n---\n\n# {name}\n"), + ) + .unwrap(); + } + + fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() + } + + async fn response_code(response: axum::response::Response) -> String { + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice::(&body).unwrap()["code"] + .as_str() + .unwrap() + .to_owned() + } + #[tokio::test] async fn skill_routes_builds_router() { let state = make_state().await; @@ -668,4 +930,239 @@ mod tests { assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); } + + #[tokio::test] + async fn member_cannot_manage_host_skill_infrastructure() { + let state = make_state().await; + let app = skill_routes(state).layer(Extension(current_user("user_member-a", SiteRole::Member))); + let requests = [ + Request::builder() + .uri("/api/skills/external-paths") + .body(Body::empty()) + .unwrap(), + Request::builder() + .uri("/api/skills/detect-paths") + .body(Body::empty()) + .unwrap(), + Request::builder() + .uri("/api/skills/detect-external") + .body(Body::empty()) + .unwrap(), + json_request("POST", "/api/skills/market/enable", serde_json::json!({})), + json_request("POST", "/api/skills/export-symlink", serde_json::json!({})), + json_request("POST", "/api/skills/materialize-for-agent", serde_json::json!({})), + ]; + + for request in requests { + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(response).await, "ADMIN_REQUIRED"); + } + } + + #[tokio::test] + async fn member_export_cannot_replace_an_existing_target() { + let state = make_state().await; + let host_temp = tempfile::tempdir().unwrap(); + let source = host_temp.path().join("source-skill"); + let target = host_temp.path().join("agent-skills"); + let existing = target.join("source-skill"); + write_skill(&source, "source-skill"); + std::fs::create_dir_all(&existing).unwrap(); + std::fs::write(existing.join("sentinel.txt"), "keep").unwrap(); + + let app = skill_routes(state).layer(Extension(current_user("user_member-a", SiteRole::Member))); + let response = app + .oneshot(json_request( + "POST", + "/api/skills/export-symlink", + serde_json::json!({ "skill_path": source, "target_dir": target }), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(response).await, "ADMIN_REQUIRED"); + assert_eq!(std::fs::read_to_string(existing.join("sentinel.txt")).unwrap(), "keep"); + } + + #[tokio::test] + async fn member_import_is_limited_to_their_managed_roots() { + let state = make_state().await; + let own_upload = state + .skill_paths + .data_dir + .join("uploads/users/system_default_user/source-skill"); + let foreign_upload = state.skill_paths.data_dir.join("uploads/users/member-b/foreign-skill"); + write_skill(&own_upload, "owned-skill"); + write_skill(&foreign_upload, "foreign-skill"); + let host_temp = tempfile::tempdir().unwrap(); + let host_skill = host_temp.path().join("host-skill"); + write_skill(&host_skill, "host-skill"); + + // The in-memory database seeds this identity, which lets the import + // exercise its ownership foreign key while the injected live role is + // intentionally Member. + let app = skill_routes(state.clone()).layer(Extension(current_user("system_default_user", SiteRole::Member))); + let own_response = app + .clone() + .oneshot(json_request( + "POST", + "/api/skills/import", + serde_json::json!({ "skill_path": own_upload }), + )) + .await + .unwrap(); + let own_status = own_response.status(); + let own_body = to_bytes(own_response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + own_status, + StatusCode::OK, + "managed import failed: {}", + String::from_utf8_lossy(&own_body) + ); + assert!( + state + .skill_paths + .user_skills_dir + .join("users/system_default_user/owned-skill/SKILL.md") + .is_file() + ); + + for denied_path in [&foreign_upload, &host_skill] { + let response = app + .clone() + .oneshot(json_request( + "POST", + "/api/skills/import", + serde_json::json!({ "skill_path": denied_path }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(response).await, "USER_FILESYSTEM_DENIED"); + } + + let paths_response = app + .oneshot(Request::builder().uri("/api/skills/paths").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(paths_response.status(), StatusCode::OK); + let body = to_bytes(paths_response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["data"]["user_skills_dir"], + state + .skill_paths + .user_skills_dir + .join("users/system_default_user") + .to_string_lossy() + .as_ref() + ); + } + + #[tokio::test] + async fn admin_can_use_host_paths_but_not_foreign_managed_content() { + let state = make_state().await; + let host_temp = tempfile::tempdir().unwrap(); + let host_skill = host_temp.path().join("host-skill"); + write_skill(&host_skill, "host-skill"); + let own_skill = state.skill_paths.data_dir.join("uploads/users/admin-a/own-skill"); + let foreign_skill = state.skill_paths.data_dir.join("uploads/users/member-b/foreign-skill"); + write_skill(&own_skill, "own-skill"); + write_skill(&foreign_skill, "foreign-skill"); + + let app = skill_routes(state).layer(Extension(current_user("user_admin-a", SiteRole::Admin))); + for allowed_path in [&host_skill, &own_skill] { + let response = app + .clone() + .oneshot(json_request( + "POST", + "/api/skills/info", + serde_json::json!({ "skill_path": allowed_path }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + let foreign_response = app + .clone() + .oneshot(json_request( + "POST", + "/api/skills/info", + serde_json::json!({ "skill_path": foreign_skill }), + )) + .await + .unwrap(); + assert_eq!(foreign_response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(foreign_response).await, "USER_FILESYSTEM_DENIED"); + + let external_path_response = app + .oneshot(json_request( + "POST", + "/api/skills/external-paths", + serde_json::json!({ "name": "Host Skills", "path": host_temp.path() }), + )) + .await + .unwrap(); + assert_eq!(external_path_response.status(), StatusCode::OK); + } + + #[cfg(unix)] + #[tokio::test] + async fn symlinks_cannot_escape_or_alias_foreign_managed_roots() { + use std::os::unix::fs::symlink; + + let state = make_state().await; + let host_temp = tempfile::tempdir().unwrap(); + let host_skill = host_temp.path().join("host-skill"); + write_skill(&host_skill, "host-skill"); + let own_upload_root = state.skill_paths.data_dir.join("uploads/users/member-a"); + std::fs::create_dir_all(&own_upload_root).unwrap(); + let escape_link = own_upload_root.join("escape"); + symlink(&host_skill, &escape_link).unwrap(); + + let member_app = skill_routes(state.clone()).layer(Extension(current_user("user_member-a", SiteRole::Member))); + let escape_response = member_app + .clone() + .oneshot(json_request( + "POST", + "/api/skills/info", + serde_json::json!({ "skill_path": escape_link }), + )) + .await + .unwrap(); + assert_eq!(escape_response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(escape_response).await, "USER_FILESYSTEM_DENIED"); + + let scan_response = member_app + .oneshot(json_request( + "POST", + "/api/skills/scan", + serde_json::json!({ "folder_path": own_upload_root }), + )) + .await + .unwrap(); + assert_eq!(scan_response.status(), StatusCode::OK); + let scan_body = to_bytes(scan_response.into_body(), usize::MAX).await.unwrap(); + let scan_payload: serde_json::Value = serde_json::from_slice(&scan_body).unwrap(); + assert_eq!(scan_payload["data"]["skills"], serde_json::json!([])); + + let foreign_skill = state.skill_paths.data_dir.join("uploads/users/member-b/foreign-skill"); + write_skill(&foreign_skill, "foreign-skill"); + let foreign_alias = host_temp.path().join("foreign-alias"); + symlink(&foreign_skill, &foreign_alias).unwrap(); + let admin_app = skill_routes(state).layer(Extension(current_user("user_admin-a", SiteRole::Admin))); + let alias_response = admin_app + .oneshot(json_request( + "POST", + "/api/skills/info", + serde_json::json!({ "skill_path": foreign_alias }), + )) + .await + .unwrap(); + assert_eq!(alias_response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(alias_response).await, "USER_FILESYSTEM_DENIED"); + } } diff --git a/crates/aionui-extension/src/skill_service.rs b/crates/aionui-extension/src/skill_service.rs index 3d3980b84..b8c74adbc 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -1062,7 +1062,7 @@ async fn replace_existing_path(path: &Path) -> Result<(), ExtensionError> { fn user_skill_root_for_user(paths: &SkillPaths, user_id: &str) -> PathBuf { // Type-first per-user root: skills/users/{user_dir}/ for every user, // including the default user (no more flat-root special case). - let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); + let dir = aionui_common::user_dir_name_or_fingerprint(user_id); paths.user_skills_dir.join("users").join(dir) } @@ -1580,6 +1580,23 @@ fn custom_source_slug(path: &str) -> String { /// The returned list preserves deterministic `source` slugs — see /// [`ExternalSkillSource::source`] for the contract. pub async fn detect_and_count_external_skills(custom_paths: &[NamedPath]) -> Vec { + detect_and_count_external_skills_inner(custom_paths, None).await +} + +/// Discover external skills while limiting common home-directory sources to +/// caller-authorized canonical paths. Custom paths must be authorized by the +/// caller before invoking this function. +pub(crate) async fn detect_and_count_external_skills_with_allowed_common_paths( + custom_paths: &[NamedPath], + allowed_common_paths: &[PathBuf], +) -> Vec { + detect_and_count_external_skills_inner(custom_paths, Some(allowed_common_paths)).await +} + +async fn detect_and_count_external_skills_inner( + custom_paths: &[NamedPath], + allowed_common_paths: Option<&[PathBuf]>, +) -> Vec { let Some(home) = dirs::home_dir() else { return Vec::new(); }; @@ -1589,10 +1606,15 @@ pub async fn detect_and_count_external_skills(custom_paths: &[NamedPath]) -> Vec // 1. Common paths (iterate the constant table so we keep the per-entry slug). for (name, rel_path, slug) in COMMON_SKILL_DIRS { let full_path = home.join(rel_path); - if !full_path.exists() { + let Ok(canonical_path) = tokio::fs::canonicalize(&full_path).await else { + continue; + }; + if let Some(allowed_paths) = allowed_common_paths + && !allowed_paths.contains(&canonical_path) + { continue; } - if let Ok(skills) = scan_skill_dirs(&full_path).await { + if let Ok(skills) = scan_skill_dirs(&canonical_path).await { sources.push(ExternalSkillSource { name: (*name).to_string(), path: full_path.to_string_lossy().into_owned(), @@ -1605,8 +1627,10 @@ pub async fn detect_and_count_external_skills(custom_paths: &[NamedPath]) -> Vec // 2. Custom external paths for np in custom_paths { - let path = Path::new(&np.path); - if let Ok(skills) = scan_skill_dirs(path).await { + let Ok(canonical_path) = tokio::fs::canonicalize(Path::new(&np.path)).await else { + continue; + }; + if let Ok(skills) = scan_skill_dirs(&canonical_path).await { sources.push(ExternalSkillSource { name: np.name.clone(), path: np.path.clone(), @@ -2041,8 +2065,12 @@ async fn collect_skill_dirs_recursive(dir: &Path, result: &mut Vec) -> }; while let Ok(Some(entry)) = entries.next_entry().await { + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + continue; + } let entry_path = entry.path(); - if entry_path.is_dir() { + if file_type.is_dir() { Box::pin(collect_skill_dirs_recursive(&entry_path, result)).await?; } } diff --git a/crates/aionui-extension/tests/assistant_dispatch_test.rs b/crates/aionui-extension/tests/assistant_dispatch_test.rs index 5dc0eb4e3..a7e301f71 100644 --- a/crates/aionui-extension/tests/assistant_dispatch_test.rs +++ b/crates/aionui-extension/tests/assistant_dispatch_test.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use aionui_api_types::{ApiResponse, AssistantSource, SkillImportLimitsResponse}; use aionui_auth::CurrentUser; -use aionui_db::{UserStatus, UserType}; +use aionui_db::{SiteRole, UserStatus, UserType}; use aionui_extension::classifier::{AssistantClassifier, AssistantRuleDispatcher}; use aionui_extension::error::ExtensionError; use aionui_extension::external_paths::ExternalPathsManager; @@ -179,6 +179,8 @@ async fn router_with_dispatcher(dispatcher: Arc) -> axum::Router username: "user-current".into(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Member, + must_change_password: false, })) } diff --git a/crates/aionui-file/src/routes.rs b/crates/aionui-file/src/routes.rs index d072b2e8f..da2b1ca75 100644 --- a/crates/aionui-file/src/routes.rs +++ b/crates/aionui-file/src/routes.rs @@ -165,16 +165,36 @@ pub fn file_routes(state: FileRouterState) -> Router { async fn get_files_by_dir( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let items = state.file_service.get_files_by_dir(&req.dir, &req.root).await?; + let root = authorize_path( + &state, + &user.id, + Path::new(&req.root), + aionui_project::FileOp::Browse, + false, + )?; + let dir = authorize_path( + &state, + &user.id, + Path::new(&req.dir), + aionui_project::FileOp::Browse, + false, + )?; + ensure_descendant(&state, &root, &dir)?; + let items = state + .file_service + .get_files_by_dir(&path_string(&dir), &path_string(&root)) + .await?; let response: Vec = items.into_iter().map(to_dir_or_file_response).collect(); Ok(Json(ApiResponse::ok(response))) } async fn list_workspace_files( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; @@ -182,9 +202,10 @@ async fn list_workspace_files( if root.is_empty() { return Err(ApiError::BadRequest("root is required".to_owned())); } + let root = authorize_path(&state, &user.id, Path::new(root), aionui_project::FileOp::Browse, false)?; let items = state .file_service - .list_workspace_files_with_extra_root(root, Some(Path::new(root))) + .list_workspace_files_with_extra_root(&path_string(&root), Some(&root)) .await?; let response: Vec = items.into_iter().map(to_flat_file_response).collect(); @@ -193,25 +214,52 @@ async fn list_workspace_files( async fn get_file_metadata( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let meta = state - .file_service - .get_file_metadata(&req.path, req.workspace.as_deref().map(Path::new)) - .await?; + let path = authorize_path( + &state, + &user.id, + Path::new(&req.path), + aionui_project::FileOp::Read, + true, + )?; + let meta = if state.project.allows_host_paths() { + state + .file_service + .get_file_metadata(&req.path, req.workspace.as_deref().map(Path::new)) + .await? + } else { + state + .file_service + .get_file_metadata(&path_string(&path), Some(&path)) + .await? + }; Ok(Json(ApiResponse::ok(to_metadata_response(meta)))) } async fn read_file( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let content = state - .file_service - .read_file(&req.path, req.workspace.as_deref().map(Path::new)) - .await?; + let path = authorize_path( + &state, + &user.id, + Path::new(&req.path), + aionui_project::FileOp::Read, + true, + )?; + let content = if state.project.allows_host_paths() { + state + .file_service + .read_file(&req.path, req.workspace.as_deref().map(Path::new)) + .await? + } else { + state.file_service.read_file(&path_string(&path), Some(&path)).await? + }; Ok(Json(ApiResponse::ok(content))) } @@ -227,9 +275,29 @@ async fn write_file( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default() }); + let workspace = authorize_path( + &state, + &user.id, + Path::new(&workspace), + aionui_project::FileOp::Browse, + false, + )?; + let path = authorize_path( + &state, + &user.id, + Path::new(&req.path), + aionui_project::FileOp::Write, + false, + )?; + ensure_descendant(&state, &workspace, &path)?; let ok = state .file_service - .write_file_for_user(&user.id, &req.path, req.data.as_bytes(), &workspace) + .write_file_for_user( + &user.id, + &path_string(&path), + req.data.as_bytes(), + &path_string(&workspace), + ) .await?; Ok(Json(ApiResponse::ok(ok))) } @@ -257,9 +325,24 @@ async fn copy_files( let dir = resolved .absolute_path .ok_or_else(|| ApiError::BadRequest("copy target is not a local path".to_owned()))?; + let destination = authorize_path(&state, &user.id, Path::new(&dir), aionui_project::FileOp::Write, false)?; + let source_root = req + .source_root + .as_deref() + .map(|root| authorize_path(&state, &user.id, Path::new(root), aionui_project::FileOp::Browse, true)) + .transpose()?; + let mut source_paths = Vec::with_capacity(req.file_paths.len()); + for source in &req.file_paths { + let source = authorize_path(&state, &user.id, Path::new(source), aionui_project::FileOp::Read, true)?; + if let Some(root) = &source_root { + ensure_descendant(&state, root, &source)?; + } + source_paths.push(path_string(&source)); + } + let source_root = source_root.as_ref().map(|root| path_string(root)); let result = state .file_service - .copy_files_to_workspace(&req.file_paths, &dir, req.source_root.as_deref()) + .copy_files_to_workspace(&source_paths, &path_string(&destination), source_root.as_deref()) .await?; Ok(Json(ApiResponse::ok(to_copy_response(result)))) } @@ -436,6 +519,53 @@ fn content_upload_root() -> PathBuf { std::env::temp_dir().join("aionui") } +fn authorize_path( + state: &FileRouterState, + user_id: &str, + path: &Path, + op: aionui_project::FileOp, + include_uploads: bool, +) -> Result { + state + .project + .authorize_user_path(user_id, path, op, include_uploads) + .map_err(ApiError::from) +} + +fn ensure_descendant(state: &FileRouterState, root: &Path, path: &Path) -> Result<(), ApiError> { + if state.project.allows_host_paths() || path.starts_with(root) { + Ok(()) + } else { + Err(ApiError::from(aionui_project::ProjectError::UserFilesystemDenied)) + } +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn authorize_snapshot_path( + state: &FileRouterState, + user_id: &str, + workspace: &Path, + file_path: &str, +) -> Result { + if state.project.allows_host_paths() { + return Ok(file_path.to_owned()); + } + let candidate = if Path::new(file_path).is_absolute() { + PathBuf::from(file_path) + } else { + workspace.join(file_path) + }; + let candidate = authorize_path(state, user_id, &candidate, aionui_project::FileOp::Write, false)?; + ensure_descendant(state, workspace, &candidate)?; + candidate + .strip_prefix(workspace) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|_| ApiError::from(aionui_project::ProjectError::UserFilesystemDenied)) +} + /// Parse the optional `If-Match` header as a last-modified-millisecond stamp. fn parse_if_match(headers: &axum::http::HeaderMap) -> Option { headers @@ -654,6 +784,7 @@ async fn extract_upload_multipart(mut multipart: Multipart) -> Result, + Extension(user): Extension, multipart: Multipart, ) -> Result>, ApiError> { let fields = extract_upload_multipart(multipart).await?; @@ -662,22 +793,43 @@ async fn upload_file( ApiError::BadRequest("missing file name: provide 'file_name' or a multipart filename".to_owned()) })?; + let upload_root = state.project.user_upload_root(&user.id).map_err(ApiError::from)?; let path = state .file_service - .create_upload_file(&file_name, &fields.file_data, fields.conversation_id.as_deref()) + .create_upload_file_in_root( + &upload_root, + &file_name, + &fields.file_data, + fields.conversation_id.as_deref(), + ) .await?; Ok(Json(ApiResponse::ok(path))) } async fn get_image_base64( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let data_url = state - .file_service - .get_image_base64(&req.path, req.workspace.as_deref().map(Path::new)) - .await?; + let path = authorize_path( + &state, + &user.id, + Path::new(&req.path), + aionui_project::FileOp::Read, + true, + )?; + let data_url = if state.project.allows_host_paths() { + state + .file_service + .get_image_base64(&req.path, req.workspace.as_deref().map(Path::new)) + .await? + } else { + state + .file_service + .get_image_base64(&path_string(&path), Some(&path)) + .await? + }; Ok(Json(ApiResponse::ok(data_url))) } @@ -696,124 +848,225 @@ async fn fetch_remote_image( async fn snapshot_init( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let info = state.snapshot_service.init(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let info = state.snapshot_service.init(&path_string(&workspace)).await?; Ok(Json(ApiResponse::ok(to_snapshot_info_response(info)))) } async fn snapshot_info( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let info = state.snapshot_service.get_info(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let info = state.snapshot_service.get_info(&path_string(&workspace)).await?; Ok(Json(ApiResponse::ok(to_snapshot_info_response(info)))) } async fn snapshot_compare( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let result = state.snapshot_service.compare(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let result = state.snapshot_service.compare(&path_string(&workspace)).await?; Ok(Json(ApiResponse::ok(to_compare_response(result)))) } async fn snapshot_baseline( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let file_path = authorize_snapshot_path(&state, &user.id, &workspace, &req.file_path)?; let content = state .snapshot_service - .get_baseline_content(&req.workspace, &req.file_path) + .get_baseline_content(&path_string(&workspace), &file_path) .await?; Ok(Json(ApiResponse::ok(content))) } async fn snapshot_stage_file( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let file_path = authorize_snapshot_path(&state, &user.id, &workspace, &req.file_path)?; state .snapshot_service - .stage_file(&req.workspace, &req.file_path) + .stage_file(&path_string(&workspace), &file_path) .await?; Ok(Json(ApiResponse::success())) } async fn snapshot_stage_all( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - state.snapshot_service.stage_all(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + state.snapshot_service.stage_all(&path_string(&workspace)).await?; Ok(Json(ApiResponse::success())) } async fn snapshot_unstage_file( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let file_path = authorize_snapshot_path(&state, &user.id, &workspace, &req.file_path)?; state .snapshot_service - .unstage_file(&req.workspace, &req.file_path) + .unstage_file(&path_string(&workspace), &file_path) .await?; Ok(Json(ApiResponse::success())) } async fn snapshot_unstage_all( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - state.snapshot_service.unstage_all(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + state.snapshot_service.unstage_all(&path_string(&workspace)).await?; Ok(Json(ApiResponse::success())) } async fn snapshot_discard( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let file_path = authorize_snapshot_path(&state, &user.id, &workspace, &req.file_path)?; state .snapshot_service - .discard_file(&req.workspace, &req.file_path, req.operation) + .discard_file(&path_string(&workspace), &file_path, req.operation) .await?; Ok(Json(ApiResponse::success())) } async fn snapshot_reset( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let file_path = authorize_snapshot_path(&state, &user.id, &workspace, &req.file_path)?; state .snapshot_service - .reset_file(&req.workspace, &req.file_path, req.operation) + .reset_file(&path_string(&workspace), &file_path, req.operation) .await?; Ok(Json(ApiResponse::success())) } async fn snapshot_branches( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let branches = state.snapshot_service.get_branches(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + let branches = state.snapshot_service.get_branches(&path_string(&workspace)).await?; Ok(Json(ApiResponse::ok(branches))) } async fn snapshot_dispose( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - state.snapshot_service.dispose(&req.workspace).await?; + let workspace = authorize_path( + &state, + &user.id, + Path::new(&req.workspace), + aionui_project::FileOp::Browse, + false, + )?; + state.snapshot_service.dispose(&path_string(&workspace)).await?; Ok(Json(ApiResponse::success())) } diff --git a/crates/aionui-file/src/service.rs b/crates/aionui-file/src/service.rs index 28123a086..a4856e3ae 100644 --- a/crates/aionui-file/src/service.rs +++ b/crates/aionui-file/src/service.rs @@ -62,6 +62,7 @@ pub struct FileService { broadcaster: Arc, /// Allowed root directories for path safety validation. allowed_roots: Vec, + follow_directory_symlinks: bool, /// In-memory cache for `list_workspace_files`, keyed by canonical root. workspace_files_cache: DashMap>, } @@ -71,6 +72,19 @@ impl FileService { Self { broadcaster, allowed_roots, + follow_directory_symlinks: true, + workspace_files_cache: DashMap::new(), + } + } + + /// Browser-session variant. Directory listings expose symlink entries but + /// never traverse a linked directory, which could otherwise enumerate a + /// target outside the caller's managed root after the parent was checked. + pub fn new_user_session(broadcaster: Arc, allowed_roots: Vec) -> Self { + Self { + broadcaster, + allowed_roots, + follow_directory_symlinks: false, workspace_files_cache: DashMap::new(), } } @@ -117,15 +131,27 @@ impl FileService { async fn build_dir_tree(&self, dir: &Path, root: &Path) -> Result, FileError> { let dir_owned = dir.to_path_buf(); let root_owned = root.to_path_buf(); + let follow_directory_symlinks = self.follow_directory_symlinks; - tokio::task::spawn_blocking(move || build_dir_tree_sync(&dir_owned, &root_owned)) - .await - .map_err(|e| FileError::Internal(format!("directory listing task failed: {e}")))? + tokio::task::spawn_blocking(move || { + build_dir_tree_sync_with_policy(&dir_owned, &root_owned, follow_directory_symlinks) + }) + .await + .map_err(|e| FileError::Internal(format!("directory listing task failed: {e}")))? } } /// Synchronous directory tree builder (runs in blocking thread pool). +#[cfg(test)] fn build_dir_tree_sync(dir: &Path, root: &Path) -> Result, FileError> { + build_dir_tree_sync_with_policy(dir, root, true) +} + +fn build_dir_tree_sync_with_policy( + dir: &Path, + root: &Path, + follow_directory_symlinks: bool, +) -> Result, FileError> { let entries = std::fs::read_dir(dir) .map_err(|e| FileError::BadRequest(format!("cannot read directory '{}': {e}", dir.display())))?; @@ -135,6 +161,9 @@ fn build_dir_tree_sync(dir: &Path, root: &Path) -> Result, FileEr let entry = entry.map_err(|e| FileError::Internal(format!("error reading directory entry: {e}")))?; let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|e| FileError::Internal(format!("cannot read file type for '{}': {e}", path.display())))?; let metadata = entry .metadata() .map_err(|e| FileError::Internal(format!("cannot read metadata for '{}': {e}", path.display())))?; @@ -144,11 +173,11 @@ fn build_dir_tree_sync(dir: &Path, root: &Path) -> Result, FileEr let full_path = strip_verbatim_prefix(&path.to_string_lossy()); let relative_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().into_owned(); - let is_dir = metadata.is_dir(); + let is_dir = metadata.is_dir() && (follow_directory_symlinks || !file_type.is_symlink()); // For directories, also read their immediate children let children = if is_dir { - read_children_sync(&path, root)? + read_children_sync(&path, root, follow_directory_symlinks)? } else { Vec::new() }; @@ -169,7 +198,7 @@ fn build_dir_tree_sync(dir: &Path, root: &Path) -> Result, FileEr } /// Read immediate children of a directory (one level, no grandchildren). -fn read_children_sync(dir: &Path, root: &Path) -> Result, FileError> { +fn read_children_sync(dir: &Path, root: &Path, follow_directory_symlinks: bool) -> Result, FileError> { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return Ok(Vec::new()), @@ -184,7 +213,9 @@ fn read_children_sync(dir: &Path, root: &Path) -> Result, FileErr }; let path = entry.path(); - let is_dir = entry.metadata().map(|m| m.is_dir()).unwrap_or(false); + let file_type = entry.file_type().ok(); + let is_dir = entry.metadata().map(|m| m.is_dir()).unwrap_or(false) + && (follow_directory_symlinks || !file_type.is_some_and(|kind| kind.is_symlink())); let name = entry.file_name().to_string_lossy().into_owned(); @@ -775,6 +806,17 @@ impl crate::traits::IFileService for FileService { file_name: &str, data: &[u8], conversation_id: Option<&str>, + ) -> Result { + self.create_upload_file_in_root(&std::env::temp_dir().join("aionui"), file_name, data, conversation_id) + .await + } + + async fn create_upload_file_in_root( + &self, + root: &Path, + file_name: &str, + data: &[u8], + conversation_id: Option<&str>, ) -> Result { if file_name.is_empty() { return Err(FileError::BadRequest("file name must not be empty".to_owned())); @@ -808,9 +850,10 @@ impl crate::traits::IFileService for FileService { let name = file_name.to_owned(); let bytes = data.to_vec(); + let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - let mut dir = std::env::temp_dir().join("aionui"); + let mut dir = root; if let Some(conv_id) = conv_id.as_deref() { dir = dir.join(conv_id); } else { diff --git a/crates/aionui-file/src/traits.rs b/crates/aionui-file/src/traits.rs index 6e5c62b03..125a9fbeb 100644 --- a/crates/aionui-file/src/traits.rs +++ b/crates/aionui-file/src/traits.rs @@ -107,6 +107,20 @@ pub trait IFileService: Send + Sync { conversation_id: Option<&str>, ) -> Result; + /// Write an upload below a server-authorized root. Browser-session routes + /// use this with the current user's derived upload root; the default keeps + /// compatibility for alternate implementations. + async fn create_upload_file_in_root( + &self, + root: &Path, + file_name: &str, + data: &[u8], + conversation_id: Option<&str>, + ) -> Result { + let _ = root; + self.create_upload_file(file_name, data, conversation_id).await + } + // -- Image processing -- /// Read a local image and return a base64 Data URL diff --git a/crates/aionui-mcp/Cargo.toml b/crates/aionui-mcp/Cargo.toml index b9381f165..54e1e759d 100644 --- a/crates/aionui-mcp/Cargo.toml +++ b/crates/aionui-mcp/Cargo.toml @@ -30,4 +30,5 @@ axum.workspace = true serde_json.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } +tower.workspace = true tracing-subscriber.workspace = true diff --git a/crates/aionui-mcp/src/routes.rs b/crates/aionui-mcp/src/routes.rs index 671818ab1..036733d89 100644 --- a/crates/aionui-mcp/src/routes.rs +++ b/crates/aionui-mcp/src/routes.rs @@ -14,6 +14,7 @@ use aionui_api_types::{ }; use aionui_auth::CurrentUser; use aionui_common::ApiError; +use aionui_db::SiteRole; use crate::connection_test::McpConnectionTestService; use crate::error::McpError; @@ -52,6 +53,18 @@ pub struct McpRouterState { pub oauth_service: McpOAuthService, } +fn require_infrastructure_admin(user: &CurrentUser) -> Result<(), ApiError> { + if user.site_role != SiteRole::Admin { + return Err(ApiError::coded( + StatusCode::FORBIDDEN, + "ADMIN_REQUIRED", + "Administrator access required.", + None, + )); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Router builder // --------------------------------------------------------------------------- @@ -118,6 +131,7 @@ async fn add_server( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result<(StatusCode, Json>), ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let server = state .config_service @@ -134,6 +148,7 @@ async fn edit_server( Path(id): Path, body: Result, JsonRejection>, ) -> Result>, ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let server = state .config_service @@ -163,6 +178,7 @@ async fn toggle_server( Extension(user): Extension, Path(id): Path, ) -> Result>, ApiError> { + require_infrastructure_admin(&user)?; let server = state .config_service .toggle_server(&user.id, &id) @@ -177,6 +193,7 @@ async fn batch_import( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>>, ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let servers = state .config_service @@ -198,6 +215,7 @@ async fn test_connection( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let transport = McpServerTransport::from(req.transport); let result = state @@ -263,6 +281,7 @@ async fn get_agent_configs( State(state): State, Extension(user): Extension, ) -> Result>>, ApiError> { + require_infrastructure_admin(&user)?; let configs = state .sync_service .get_agent_configs(&user.id) @@ -281,6 +300,7 @@ async fn oauth_check_status( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let status = state .oauth_service @@ -299,6 +319,7 @@ async fn oauth_login( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; let result = state .oauth_service @@ -314,6 +335,7 @@ async fn oauth_logout( Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { + require_infrastructure_admin(&user)?; let Json(req) = body.map_err(ApiError::from)?; state .oauth_service @@ -328,6 +350,7 @@ async fn oauth_authenticated( State(state): State, Extension(user): Extension, ) -> Result>>, ApiError> { + require_infrastructure_admin(&user)?; let urls = state .oauth_service .get_authenticated_servers(&user.id) @@ -339,6 +362,142 @@ async fn oauth_authenticated( #[cfg(test)] mod error_mapping_tests { use super::*; + use std::sync::Arc; + + use aionui_db::{SiteRole, SqliteMcpServerRepository, SqliteOAuthTokenRepository, UserStatus, UserType}; + use aionui_realtime::BroadcastEventBus; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, header}; + use tower::ServiceExt; + + async fn make_state() -> McpRouterState { + let db = aionui_db::init_database_memory().await.unwrap(); + let mcp_repo = Arc::new(SqliteMcpServerRepository::new(db.pool().clone())); + let oauth_repo = Arc::new(SqliteOAuthTokenRepository::new(db.pool().clone())); + let http_client = reqwest::Client::new(); + McpRouterState { + config_service: McpConfigService::new(mcp_repo.clone()), + sync_service: McpSyncService::new(mcp_repo, Vec::new()), + connection_test_service: McpConnectionTestService::new( + http_client.clone(), + Arc::new(BroadcastEventBus::new(16)), + ), + oauth_service: McpOAuthService::new(oauth_repo, http_client), + } + } + + fn current_user(site_role: SiteRole) -> CurrentUser { + CurrentUser { + id: "user_route-test".into(), + username: "route-test".into(), + user_type: UserType::Local, + status: UserStatus::Active, + site_role, + must_change_password: false, + } + } + + async fn response_code(response: Response) -> String { + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice::(&body).unwrap()["code"] + .as_str() + .unwrap() + .to_owned() + } + + fn json_request(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap() + } + + #[tokio::test] + async fn member_cannot_run_connection_tests_or_scan_host_agent_configs() { + let app = mcp_routes(make_state().await).layer(Extension(current_user(SiteRole::Member))); + let connection_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/mcp/test-connection") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(connection_response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(connection_response).await, "ADMIN_REQUIRED"); + + let configs_response = app + .oneshot( + Request::builder() + .uri("/api/mcp/agent-configs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(configs_response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(configs_response).await, "ADMIN_REQUIRED"); + } + + #[tokio::test] + async fn member_cannot_persist_or_authorize_executable_mcp_configuration() { + let app = mcp_routes(make_state().await).layer(Extension(current_user(SiteRole::Member))); + let requests = [ + json_request("POST", "/api/mcp/servers"), + json_request("PUT", "/api/mcp/servers/server-1"), + json_request("POST", "/api/mcp/servers/server-1/toggle"), + json_request("POST", "/api/mcp/servers/import"), + json_request("POST", "/api/mcp/oauth/check-status"), + json_request("POST", "/api/mcp/oauth/login"), + json_request("POST", "/api/mcp/oauth/logout"), + Request::builder() + .uri("/api/mcp/oauth/authenticated") + .body(Body::empty()) + .unwrap(), + ]; + + for request in requests { + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response_code(response).await, "ADMIN_REQUIRED"); + } + } + + #[tokio::test] + async fn admin_can_scan_agent_configs_and_reaches_connection_validation() { + let app = mcp_routes(make_state().await).layer(Extension(current_user(SiteRole::Admin))); + let configs_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/mcp/agent-configs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(configs_response.status(), StatusCode::OK); + + let connection_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/mcp/test-connection") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(connection_response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response_code(connection_response).await, "BAD_REQUEST"); + } #[test] fn not_found_maps_to_app_not_found() { diff --git a/crates/aionui-office/src/routes.rs b/crates/aionui-office/src/routes.rs index 6e7318f54..914b68ffd 100644 --- a/crates/aionui-office/src/routes.rs +++ b/crates/aionui-office/src/routes.rs @@ -173,7 +173,7 @@ async fn start_preview( .await .map_err(ApiError::from)? } - None => validate_office_path(&state, &req.file_path, req.workspace.as_deref())? + None => validate_office_path(&state, user_id, &req.file_path, req.workspace.as_deref())? .to_string_lossy() .into_owned(), }; @@ -240,7 +240,7 @@ async fn refresh_preview( .await .map_err(refresh_resolve_error)? } - None => validate_office_path(&state, &req.file_path, req.workspace.as_deref())? + None => validate_office_path(&state, user_id, &req.file_path, req.workspace.as_deref())? .to_string_lossy() .into_owned(), }; @@ -330,11 +330,11 @@ async fn stop_preview( async fn convert_document( State(state): State, - Extension(_user): Extension, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let validated_path = validate_office_path(&state, &req.file_path, req.workspace.as_deref())?; + let validated_path = validate_office_path(&state, &user.id, &req.file_path, req.workspace.as_deref())?; let resp = state .conversion_service .convert(validated_path.to_string_lossy().as_ref(), req.to) @@ -344,9 +344,17 @@ async fn convert_document( fn validate_office_path( state: &OfficeRouterState, + user_id: &str, file_path: &str, workspace: Option<&str>, ) -> Result { + if !state.project.allows_host_paths() { + return state + .project + .authorize_user_path(user_id, FsPath::new(file_path), aionui_project::FileOp::Read, true) + .map_err(ApiError::from); + } + let allowed_roots: Vec<&FsPath> = state.allowed_roots.iter().map(PathBuf::as_path).collect(); validate_path_with_extra_root(file_path, &allowed_roots, workspace.map(FsPath::new)) .map_err(file_error_to_api_error) diff --git a/crates/aionui-project/src/chat_files.rs b/crates/aionui-project/src/chat_files.rs index 4a16a3ce3..df88756f5 100644 --- a/crates/aionui-project/src/chat_files.rs +++ b/crates/aionui-project/src/chat_files.rs @@ -68,9 +68,9 @@ impl ProjectService { /// - `Project` → [`resolve_reference`](Self::resolve_reference) with the caller's `op` (lexical + /// realpath containment; read paths pass `Read`, the write endpoint passes `Write`); must exist /// (file or folder). - /// - `Upload` → an existing regular file under the managed `upload_root` (D2 invariant). - /// - `Local` → a canonicalized existing regular file; **no sandbox** (the host picker that - /// produced it already exposes the whole filesystem). + /// - `Upload` → an existing regular file under the current user's managed upload root. + /// - `Local` → unrestricted only in embedded local mode; browser sessions are constrained + /// to the current user's managed workspace and upload roots. /// /// `op` only affects the `Project` arm's containment mode; `Upload`/`Local` are path-based and /// identical regardless of op. @@ -108,10 +108,23 @@ impl ProjectService { if !candidate.is_file() { return Err(ProjectError::ChatFileMissing { path: path.clone() }); } - if !path_within(upload_root, candidate) { + let effective_upload_root = if self.allows_host_paths() { + upload_root.to_path_buf() + } else { + self.user_upload_root(user_id)? + }; + if !path_within(&effective_upload_root, candidate) { return Err(ProjectError::UploadPathOutsideRoot { path: path.clone() }); } - Ok(path.clone()) + let canonical = std::fs::canonicalize(candidate) + .map_err(|_| ProjectError::ChatFileMissing { path: path.clone() })? + .to_string_lossy() + .into_owned(); + if self.allows_host_paths() { + Ok(path.clone()) + } else { + Ok(canonical) + } } ChatFileRef::Local { path } => { // A path the user explicitly picked in the host-file browser, @@ -124,7 +137,8 @@ impl ProjectService { if !canonical.is_file() { return Err(ProjectError::LocalPathNotReadable { path: path.clone() }); } - Ok(canonical.to_string_lossy().into_owned()) + let authorized = self.authorize_user_path(user_id, &canonical, op, true)?; + Ok(authorized.to_string_lossy().into_owned()) } } } diff --git a/crates/aionui-project/src/monitor/wire.rs b/crates/aionui-project/src/monitor/wire.rs index 7e0cf82e5..3d58b5253 100644 --- a/crates/aionui-project/src/monitor/wire.rs +++ b/crates/aionui-project/src/monitor/wire.rs @@ -311,6 +311,7 @@ pub fn project_error_to_rpc(err: &ProjectError) -> (i64, &'static str) { ProjectError::ProjectExplorerNotFound { .. } | ProjectError::ProjectNotFound { .. } => { (CODE_OUT_OF_SCOPE, "out_of_scope") } + ProjectError::UserFilesystemDenied => (CODE_OUT_OF_SCOPE, "out_of_scope"), ProjectError::InvalidRelativePath { .. } => (CODE_INVALID_RELATIVE_PATH, "invalid_relative_path"), ProjectError::ResourceOutsideFolder { .. } => (CODE_RESOURCE_OUTSIDE_FOLDER, "resource_outside_folder"), ProjectError::UnsupportedResourceScheme { .. } => { diff --git a/crates/aionui-project/src/routes.rs b/crates/aionui-project/src/routes.rs index e1dfb106f..6dc324384 100644 --- a/crates/aionui-project/src/routes.rs +++ b/crates/aionui-project/src/routes.rs @@ -238,6 +238,7 @@ impl From for ApiError { "local_path_not_readable", Some(json!({ "path": path })), ), + ProjectError::UserFilesystemDenied => (StatusCode::FORBIDDEN, "user_filesystem_denied", None), ProjectError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", None), }; // Never leak internal DB detail to clients (Security: no internal leakage). diff --git a/crates/aionui-project/src/routes_test.rs b/crates/aionui-project/src/routes_test.rs index 7838e706e..4e27b3ff1 100644 --- a/crates/aionui-project/src/routes_test.rs +++ b/crates/aionui-project/src/routes_test.rs @@ -48,6 +48,8 @@ async fn setup() -> (Router, String, String, TempDir, Database) { username: "admin".to_owned(), user_type: aionui_db::UserType::Local, status: aionui_db::UserStatus::Active, + site_role: aionui_db::SiteRole::Member, + must_change_password: false, })); (router, project_id, workspace_pe_id, dir, db) } diff --git a/crates/aionui-project/src/scm/actor.rs b/crates/aionui-project/src/scm/actor.rs index 11c9493fb..e3d42d8ee 100644 --- a/crates/aionui-project/src/scm/actor.rs +++ b/crates/aionui-project/src/scm/actor.rs @@ -201,12 +201,24 @@ impl ScmActor { .and_then(Value::as_str) .ok_or(ScmError::InvalidParams { what: "project_id" })?; + // Resolve before registering interest. Registering an unverified project + // id would leave this session subscribed even though the request fails, + // allowing a guessed cross-account id to receive later root-change events. + self.roots_of(user_id, project_id).await?; + // Listing a project's repositories is also how a connection registers its // interest in them: from now until it disconnects it receives that - // project's `repositoriesChanged` frames. Registering before discovery - // closes the window where an attach lands between the two. + // project's `repositoriesChanged` frames. Resolve again after registering + // so a root change in the authorization-to-registration window is included + // in the response; later changes are covered by the registered interest. self.runtime.register_interest(session, project_id).await; - let roots = self.roots_of(user_id, project_id).await?; + let roots = match self.roots_of(user_id, project_id).await { + Ok(roots) => roots, + Err(error) => { + self.runtime.unregister_interest(session, project_id).await; + return Err(error); + } + }; let repositories = self.runtime.discover(project_id, &roots).await; Ok(json!({ "repositories": repositories })) } @@ -447,11 +459,11 @@ impl ScmActor { // A pe the user cannot reach, or a path escaping its root: both // are scope violations, not engine failures. ProjectError::ProjectExplorerNotFound { pe_id } => ScmError::OutOfScope { pe_id }, - ProjectError::ResourceOutsideFolder { .. } | ProjectError::InvalidRelativePath { .. } => { - ScmError::OutOfScope { - pe_id: file.pe_id.clone(), - } - } + ProjectError::ResourceOutsideFolder { .. } + | ProjectError::InvalidRelativePath { .. } + | ProjectError::UserFilesystemDenied => ScmError::OutOfScope { + pe_id: file.pe_id.clone(), + }, other => ScmError::OperationFailed { context: "authorize", message: other.to_string(), diff --git a/crates/aionui-project/src/scm/runtime.rs b/crates/aionui-project/src/scm/runtime.rs index 1e1d3e093..bdd29ff24 100644 --- a/crates/aionui-project/src/scm/runtime.rs +++ b/crates/aionui-project/src/scm/runtime.rs @@ -210,6 +210,20 @@ impl ScmRuntime { .insert(session.to_owned()); } + /// Remove one session's interest in one project. + /// + /// Used when the actor's post-registration refresh discovers that the + /// project is no longer authorized. Normal interest remains session-lived. + pub(super) async fn unregister_interest(&self, session: &str, project_id: &str) { + let mut interest = self.project_interest.write().await; + if let Some(sessions) = interest.get_mut(project_id) { + sessions.remove(session); + if sessions.is_empty() { + interest.remove(project_id); + } + } + } + /// Connections that should receive a project's `repositoriesChanged` frame. pub(super) async fn project_subscribers_of(&self, project_id: &str) -> Vec { self.project_interest diff --git a/crates/aionui-project/src/service.rs b/crates/aionui-project/src/service.rs index 792fb3e4a..a4d73b959 100644 --- a/crates/aionui-project/src/service.rs +++ b/crates/aionui-project/src/service.rs @@ -1,8 +1,11 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, OnceLock}; use aionui_common::generate_short_id; -use aionui_db::{FolderRow, IProjectStore, ProjectExplorerRow, ProjectKind, Role}; +use aionui_db::{ + FolderRow, IProjectStore, IResourceShareRepository, ProjectExplorerRow, ProjectKind, ResourceAccess, Role, + ShareResourceType, +}; use chrono::{Datelike, Local}; use tokio::sync::mpsc::UnboundedSender; @@ -10,17 +13,28 @@ use crate::canonical::{self, Canonical}; use crate::containment; use crate::scm::ScmInbound; use crate::types::{ - AttachInput, FolderDto, ProjectDetail, ProjectError, ProjectExplorerEntry, ProjectExplorerView, ReferenceInput, - ResolveOutput, ResolvedResource, RuntimeStatus, + AttachInput, FileOp, FolderDto, ProjectDetail, ProjectError, ProjectExplorerEntry, ProjectExplorerView, + ReferenceInput, ResolveOutput, ResolvedResource, RuntimeStatus, }; +#[derive(Clone)] +enum FilesystemAccess { + Local, + UserSession { + upload_root: PathBuf, + bootstrap_workspace: Option, + }, +} + /// Orchestrates the three project-bind tables through an injected /// [`IProjectStore`]. Owns filesystem operations (temp-dir creation, /// access checks) and the temp-root path; holds no transactions. #[derive(Clone)] pub struct ProjectService { store: Arc, + share_repo: Option>, temp_root: PathBuf, + filesystem_access: FilesystemAccess, /// Sink for project-root changes to the source-control actor. Shared across /// clones (behind `Arc`) so the one instance the scm monitor installs the /// sender on is the same one the HTTP handlers see. Set once at startup; @@ -33,11 +47,128 @@ impl ProjectService { pub fn new(store: Arc, temp_root: PathBuf) -> Self { Self { store, + share_repo: None, temp_root, + filesystem_access: FilesystemAccess::Local, scm_roots_tx: Arc::new(OnceLock::new()), } } + /// Attach the resource-share repository so collaborators with `edit` can + /// mutate shared projects (reads already work via store-level share checks). + pub fn with_share_repo(mut self, share_repo: Arc) -> Self { + self.share_repo = Some(share_repo); + self + } + + /// Construct the project service for a browser session deployment. + /// + /// Unlike embedded local mode, browser users may only bind and dereference + /// paths below their server-managed workspace and upload roots. + pub fn new_user_session( + store: Arc, + temp_root: PathBuf, + upload_root: PathBuf, + bootstrap_workspace: Option, + ) -> Self { + Self { + store, + share_repo: None, + temp_root, + filesystem_access: FilesystemAccess::UserSession { + upload_root, + bootstrap_workspace, + }, + scm_roots_tx: Arc::new(OnceLock::new()), + } + } + + /// Resolve the store actor user id for a write: the project owner when the + /// caller is owner or holds an `edit` share. View-only and outsiders get + /// not-found (no existence leak). + async fn resolve_write_owner(&self, user_id: &str, project_id: &str) -> Result { + let Some(owner_id) = self.store.project_owner_user_id(project_id).await? else { + return Err(ProjectError::ProjectNotFound { + project_id: project_id.to_owned(), + }); + }; + if owner_id == user_id { + return Ok(owner_id); + } + if let Some(share_repo) = &self.share_repo { + let access = share_repo + .resolve_access(ShareResourceType::Project, project_id, user_id) + .await?; + if access == ResourceAccess::Edit { + return Ok(owner_id); + } + } + Err(ProjectError::ProjectNotFound { + project_id: project_id.to_owned(), + }) + } + + /// Whether callers retain the embedded application's host-path behavior. + pub fn allows_host_paths(&self) -> bool { + matches!(&self.filesystem_access, FilesystemAccess::Local) + } + + /// Server-derived root for an authenticated user's conversation workspaces. + pub fn user_workspace_root(&self, user_id: &str) -> Result { + let dir = aionui_common::user_dir_name(user_id).map_err(|_| ProjectError::UserFilesystemDenied)?; + Ok(self.temp_root.join("users").join(dir)) + } + + /// Server-derived upload root for an authenticated user. + pub fn user_upload_root(&self, user_id: &str) -> Result { + match &self.filesystem_access { + FilesystemAccess::Local => Ok(std::env::temp_dir().join("aionui")), + FilesystemAccess::UserSession { upload_root, .. } => { + let dir = aionui_common::user_dir_name(user_id).map_err(|_| ProjectError::UserFilesystemDenied)?; + Ok(upload_root.join("users").join(dir)) + } + } + } + + /// Resolve a client-supplied path and enforce the current identity mode's + /// filesystem boundary. Local mode intentionally preserves the desktop + /// application's host-path behavior. User-session modes resolve symlinks + /// and allow only the caller's managed workspace (and, when requested, + /// upload) root. + pub fn authorize_user_path( + &self, + user_id: &str, + path: &Path, + op: FileOp, + include_uploads: bool, + ) -> Result { + if self.allows_host_paths() { + return Ok(path.to_path_buf()); + } + + let candidate = canonical_path_for_operation(path, op)?; + let mut roots = vec![self.user_workspace_root(user_id)?]; + if include_uploads { + roots.push(self.user_upload_root(user_id)?); + } + if user_id == "system_default_user" + && let FilesystemAccess::UserSession { + bootstrap_workspace: Some(root), + .. + } = &self.filesystem_access + { + roots.push(root.clone()); + } + let allowed = roots + .iter() + .filter_map(|root| std::fs::canonicalize(root).ok()) + .any(|root| candidate.starts_with(root)); + if !allowed { + return Err(ProjectError::UserFilesystemDenied); + } + Ok(candidate) + } + /// Install the sink that carries project-root changes to the source-control /// actor. Called once, at startup, by the scm monitor's composition wiring; /// later calls are ignored (set-once). Because the field is shared across @@ -67,6 +198,7 @@ impl ProjectService { pub async fn create_standard(&self, user_id: &str, uri: String) -> Result { let canonical = canonical::canonicalize(&uri)?; self.ensure_accessible(&canonical)?; + self.ensure_user_managed_folder(user_id, &canonical)?; self.resolve_core(user_id, canonical, uri, ProjectKind::Standard, None) .await } @@ -76,9 +208,9 @@ impl ProjectService { /// surfaces `temp_dir_exists`; an auto `short_uuid` collision is retried. pub async fn create_temp(&self, user_id: &str, basename: Option) -> Result { let dir = match basename.as_deref() { - Some(name) if !name.is_empty() => self.make_temp_dir(name)?, + Some(name) if !name.is_empty() => self.make_temp_dir(user_id, name)?, _ => loop { - match self.make_temp_dir(&generate_short_id()) { + match self.make_temp_dir(user_id, &generate_short_id()) { Ok(dir) => break dir, Err(ProjectError::TempDirExists { .. }) => continue, Err(err) => return Err(err), @@ -97,6 +229,7 @@ impl ProjectService { pub async fn resolve_existing(&self, user_id: &str, uri: String) -> Result { let canonical = canonical::canonicalize(&uri)?; self.ensure_accessible(&canonical)?; + self.ensure_user_managed_folder(user_id, &canonical)?; let kind = if self.is_under_temp_root(&canonical) { ProjectKind::Temp } else { @@ -157,16 +290,12 @@ impl ProjectService { /// Attach a non-workspace folder. Rejects duplicates and parent-overlap; /// a child of an existing entry returns that entry (focus-in-place). pub async fn attach_folder(&self, user_id: &str, input: AttachInput) -> Result { - // Ownership gate: without it a caller could hang entries off another - // user's project_id (their scoped entry list would just look empty). - self.store - .get_project(user_id, &input.project_id) - .await? - .ok_or_else(|| ProjectError::ProjectNotFound { - project_id: input.project_id.clone(), - })?; + // Write gate: owner or edit share. Store ops run as the project owner so + // explorer rows keep a single owner_user_id. + let owner_id = self.resolve_write_owner(user_id, &input.project_id).await?; let canonical = canonical::canonicalize(&input.uri)?; self.ensure_accessible(&canonical)?; + self.ensure_user_managed_folder(user_id, &canonical)?; let folder = self.store.upsert_folder(canonical.as_str(), &input.uri).await?; let entries = self.store.list_entries(user_id, &input.project_id).await?; @@ -197,7 +326,7 @@ impl ProjectService { let row = self .store .insert_attached_entry( - user_id, + &owner_id, &input.project_id, &folder.folder_id, input.display_name.as_deref(), @@ -220,12 +349,13 @@ impl ProjectService { .ok_or_else(|| ProjectError::ProjectExplorerNotFound { pe_id: pe_id.to_owned(), })?; + let owner_id = self.resolve_write_owner(user_id, &entry.project_id).await?; if entry.role == Role::Workspace.as_str() { return Err(ProjectError::WorkspaceEntryImmutable { pe_id: pe_id.to_owned(), }); } - self.store.remove_entry(user_id, pe_id).await?; + self.store.remove_entry(&owner_id, pe_id).await?; // Detaching a folder can drop a repository from the project's set. self.notify_roots_changed(&entry.project_id, user_id); Ok(()) @@ -237,7 +367,8 @@ impl ProjectService { project_id: &str, ordered_pe_ids: &[String], ) -> Result<(), ProjectError> { - self.store.reorder(user_id, project_id, ordered_pe_ids).await?; + let owner_id = self.resolve_write_owner(user_id, project_id).await?; + self.store.reorder(&owner_id, project_id, ordered_pe_ids).await?; Ok(()) } @@ -247,7 +378,15 @@ impl ProjectService { pe_id: &str, display_name: Option, ) -> Result { - match self.store.rename_entry(user_id, pe_id, display_name.as_deref()).await { + let entry = + self.store + .get_entry(user_id, pe_id) + .await? + .ok_or_else(|| ProjectError::ProjectExplorerNotFound { + pe_id: pe_id.to_owned(), + })?; + let owner_id = self.resolve_write_owner(user_id, &entry.project_id).await?; + match self.store.rename_entry(&owner_id, pe_id, display_name.as_deref()).await { Ok(row) => Ok(row), // A row this owner cannot see (missing or another user's) is the // same "not found" to the caller — never leak internal_db_error. @@ -312,6 +451,10 @@ impl ProjectService { pe_id: input.pe_id.clone(), } })?; + // Mutations through the explorer path require edit (or ownership). + if !matches!(input.op, FileOp::Read | FileOp::Browse) { + self.resolve_write_owner(user_id, &entry.project_id).await?; + } let folder = self .store .get_folder(&entry.folder_id) @@ -338,7 +481,8 @@ impl ProjectService { } else { real_root.join(&resolved.relative_path) }; - Some(abs.to_string_lossy().into_owned()) + let authorized = self.authorize_user_path(user_id, &abs, input.op, false)?; + Some(authorized.to_string_lossy().into_owned()) } None => None, }; @@ -402,12 +546,28 @@ impl ProjectService { } } + fn ensure_user_managed_folder(&self, user_id: &str, canonical: &Canonical) -> Result<(), ProjectError> { + let path = canonical::fs_path(canonical)?; + self.authorize_user_path(user_id, &path, FileOp::Browse, false)?; + Ok(()) + } + /// Create `{temp_root}/YYYY/MM/DD/{leaf}`. Errors `temp_dir_exists` if the /// leaf already exists (caller-name collision). - fn make_temp_dir(&self, leaf: &str) -> Result { + fn make_temp_dir(&self, user_id: &str, leaf: &str) -> Result { + let mut components = Path::new(leaf).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + return Err(ProjectError::InvalidRelativePath { + relative_path: leaf.to_owned(), + }); + } let now = Local::now(); - let dir = self - .temp_root + let root = if self.allows_host_paths() { + self.temp_root.clone() + } else { + self.user_workspace_root(user_id)? + }; + let dir = root .join(format!("{:04}", now.year())) .join(format!("{:02}", now.month())) .join(format!("{:02}", now.day())) @@ -463,6 +623,27 @@ impl ProjectService { } } +fn canonical_path_for_operation(path: &Path, op: FileOp) -> Result { + match std::fs::canonicalize(path) { + Ok(path) => Ok(path), + Err(error) + if !matches!(op, FileOp::Browse) + && error.kind() == std::io::ErrorKind::NotFound + && std::fs::symlink_metadata(path).is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + let parent = path.parent().ok_or(ProjectError::UserFilesystemDenied)?; + let file_name = path.file_name().ok_or(ProjectError::UserFilesystemDenied)?; + let parent = std::fs::canonicalize(parent).map_err(|_| ProjectError::LocalPathNotReadable { + path: path.to_string_lossy().into_owned(), + })?; + Ok(parent.join(file_name)) + } + Err(_) => Err(ProjectError::LocalPathNotReadable { + path: path.to_string_lossy().into_owned(), + }), + } +} + /// The final path segment of a directory, used as a temp project name. fn leaf_of(dir: &Path) -> String { dir.file_name() diff --git a/crates/aionui-project/src/types.rs b/crates/aionui-project/src/types.rs index 35268d0b3..5704dd760 100644 --- a/crates/aionui-project/src/types.rs +++ b/crates/aionui-project/src/types.rs @@ -182,6 +182,9 @@ pub enum ProjectError { #[error("local file path is not a readable file: {path}")] LocalPathNotReadable { path: String }, + #[error("path is outside the current user's managed filesystem")] + UserFilesystemDenied, + #[error(transparent)] Database(#[from] DbError), } @@ -209,6 +212,7 @@ impl ProjectError { ProjectError::UploadPathOutsideRoot { .. } => "upload_path_outside_root", ProjectError::ChatFileMissing { .. } => "chat_file_missing", ProjectError::LocalPathNotReadable { .. } => "local_path_not_readable", + ProjectError::UserFilesystemDenied => "user_filesystem_denied", ProjectError::Database(_) => "internal_db_error", } } diff --git a/crates/aionui-project/tests/scm_request_path.rs b/crates/aionui-project/tests/scm_request_path.rs index 5be4fd55a..c6c7a0f38 100644 --- a/crates/aionui-project/tests/scm_request_path.rs +++ b/crates/aionui-project/tests/scm_request_path.rs @@ -107,11 +107,17 @@ impl Fixture { /// Send one request as `session` and wait for the actor's reply to it. async fn call_as(&self, session: &str, id: u64, method: &str, params: Value) -> Value { + self.call_as_user(session, "system_default_user", id, method, params) + .await + } + + /// Send one request as a specific authenticated user and wait for its reply. + async fn call_as_user(&self, session: &str, user_id: &str, id: u64, method: &str, params: Value) -> Value { let before = self.push.sent.lock().expect("sink").len(); self.inbound .send(ScmInbound::Frame { session: session.to_owned(), - user_id: "system_default_user".to_owned(), + user_id: user_id.to_owned(), frame: json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }), }) .expect("actor alive"); @@ -566,6 +572,60 @@ async fn repositories_changed_reaches_only_interested_sessions() { ); } +/// A failed cross-account list must not register project interest as a side +/// effect. Otherwise a guessed project id receives later repository metadata. +#[tokio::test] +async fn rejected_cross_account_list_does_not_receive_repository_changes() { + let fx = fixture().await; + + let rejected = fx + .call_as_user( + "attacker-conn", + "other-user", + 1, + "scm/listRepositories", + json!({ "project_id": fx.project_id }), + ) + .await; + assert!( + rejected.get("error").is_some(), + "cross-account list is rejected: {rejected}" + ); + + let listed = fx + .call_as( + "owner-conn", + 2, + "scm/listRepositories", + json!({ "project_id": fx.project_id }), + ) + .await; + assert!(listed.get("result").is_some(), "owner can list repositories: {listed}"); + + let second_dir = tempfile::tempdir().expect("tempdir"); + init_committed_repo(second_dir.path()); + let before = fx.frame_count(); + fx.service + .attach_folder( + "system_default_user", + AttachInput { + project_id: fx.project_id.clone(), + uri: to_file_uri(second_dir.path()).expect("uri"), + display_name: Some("Private repository".to_owned()), + }, + ) + .await + .expect("owner attach"); + + let _ = fx + .wait_for_notification("owner-conn", "scm/repositoriesChanged", before) + .await; + assert!( + !fx.pushed_to("attacker-conn", "scm/repositoriesChanged"), + "rejected cross-account interest must not receive repository metadata" + ); +} + /// Removing a repository then re-adding the same folder re-discovers it. This is /// the race the release-on-remove could break: because a recompute discovers /// *before* it releases, a folder that is present again is never torn down, so the diff --git a/crates/aionui-project/tests/service.rs b/crates/aionui-project/tests/service.rs index 224f511bd..9e47557b4 100644 --- a/crates/aionui-project/tests/service.rs +++ b/crates/aionui-project/tests/service.rs @@ -77,6 +77,24 @@ async fn create_temp_auto_uuid_yields_distinct_projects() { assert_ne!(a.project.project_id, b.project.project_id); } +#[tokio::test] +async fn create_temp_rejects_a_basename_that_escapes_the_managed_root() { + let temp_root = tempfile::tempdir().unwrap(); + let (svc, _store, _db) = harness(temp_root.path().to_path_buf()).await; + let escaped_name = format!("escape-{}", aionui_common::generate_short_id()); + + let error = svc + .create_temp("system_default_user", Some(format!("../{escaped_name}"))) + .await + .unwrap_err(); + + assert!(matches!( + error, + aionui_project::ProjectError::InvalidRelativePath { .. } + )); + assert!(!temp_root.path().parent().unwrap().join(escaped_name).exists()); +} + #[tokio::test] async fn resolve_existing_classifies_temp_vs_standard_by_temp_root() { let temp_root = tempfile::tempdir().unwrap(); diff --git a/crates/aionui-shell/Cargo.toml b/crates/aionui-shell/Cargo.toml index afa9f3a65..7c2aa0fc2 100644 --- a/crates/aionui-shell/Cargo.toml +++ b/crates/aionui-shell/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true aionui-common.workspace = true aionui-api-types.workspace = true aionui-auth.workspace = true +aionui-db.workspace = true aionui-system.workspace = true aionui-runtime.workspace = true async-trait.workspace = true @@ -27,7 +28,6 @@ tokio-tungstenite.workspace = true tracing.workspace = true [dev-dependencies] -aionui-db.workspace = true http-body-util.workspace = true sqlx.workspace = true tempfile.workspace = true diff --git a/crates/aionui-shell/src/routes.rs b/crates/aionui-shell/src/routes.rs index eedf9956d..14b2f6041 100644 --- a/crates/aionui-shell/src/routes.rs +++ b/crates/aionui-shell/src/routes.rs @@ -3,6 +3,7 @@ use axum::extract::ws::{Message, WebSocket}; use axum::extract::{Extension, Multipart, State, WebSocketUpgrade}; use axum::http::StatusCode; +use axum::middleware::from_fn; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -13,8 +14,11 @@ use aionui_api_types::{ ApiResponse, CheckToolInstalledRequest, CheckToolInstalledResponse, OpenExternalRequest, OpenFileRequest, OpenFolderWithRequest, ShowItemInFolderRequest, SpeechToTextConfig, SttStreamServerMessage, }; -use aionui_auth::CurrentUser; +use aionui_auth::{CurrentUser, admin_required_middleware}; use aionui_common::ApiError; +use aionui_db::SiteRole; +#[cfg(test)] +use aionui_db::UserType; use aionui_system::ClientPrefService; use crate::error::{ShellError, SttError}; @@ -54,14 +58,23 @@ impl From for ApiError { } pub fn shell_routes(state: ShellRouterState) -> Router { - Router::new() + let host_integration = Router::new() .route("/api/shell/open-file", post(open_file)) .route("/api/shell/show-item-in-folder", post(show_item_in_folder)) .route("/api/shell/open-external", post(open_external)) .route("/api/shell/check-tool-installed", post(check_tool_installed)) - .route("/api/shell/open-folder-with", post(open_folder_with)) + .route("/api/shell/open-folder-with", post(open_folder_with)); + + let host_integration = if state.require_host_admin { + host_integration.route_layer(from_fn(admin_required_middleware)) + } else { + host_integration + }; + + Router::new() .route("/api/stt", post(speech_to_text)) .route("/api/stt/stream", get(speech_to_text_stream)) + .merge(host_integration) .with_state(state) } @@ -218,6 +231,16 @@ async fn speech_to_text( (status, Json(body)) })?; + enforce_member_stt_endpoint(&state, &user, &config).map_err(|error| { + let status = error.status_code(); + let body = serde_json::json!({ + "success": false, + "error": error.public_message(), + "code": error.error_code(), + }); + (status, Json(body)) + })?; + let result = state .stt_service .transcribe( @@ -298,17 +321,17 @@ async fn speech_to_text_stream( Extension(user): Extension, ws: WebSocketUpgrade, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| speech_to_text_stream_socket(socket, state, user.id)) + ws.on_upgrade(move |socket| speech_to_text_stream_socket(socket, state, user)) } /// Pump WebSocket frames in/out of the transport-agnostic streaming session. /// /// This stays a pure frame adapter: all protocol and business logic lives in /// `stt_stream::run_stream_session`. -async fn speech_to_text_stream_socket(socket: WebSocket, state: ShellRouterState, user_id: String) { +async fn speech_to_text_stream_socket(socket: WebSocket, state: ShellRouterState, user: CurrentUser) { let (mut ws_tx, mut ws_rx) = socket.split(); - let config = match load_stt_config(&state.client_pref_service, &user_id).await { + let config = match load_stt_config(&state.client_pref_service, &user.id).await { Ok(config) => config, Err(e) => { tracing::error!(error = %e, "stt stream: failed to load config"); @@ -324,6 +347,18 @@ async fn speech_to_text_stream_socket(socket: WebSocket, state: ShellRouterState } }; + if enforce_member_stt_endpoint(&state, &user, &config).is_err() { + let frame = SttStreamServerMessage::Error { + code: "STT_ENDPOINT_ADMIN_REQUIRED".to_owned(), + msg: "Custom speech-to-text endpoints require administrator access.".to_owned(), + }; + if let Ok(text) = serde_json::to_string(&frame) { + let _ = ws_tx.send(Message::Text(text.into())).await; + } + let _ = ws_tx.send(Message::Close(None)).await; + return; + } + let (client_tx, client_rx) = mpsc::channel(STT_STREAM_CHANNEL_CAPACITY); let (server_tx, mut server_rx) = mpsc::channel(STT_STREAM_CHANNEL_CAPACITY); @@ -367,9 +402,54 @@ async fn speech_to_text_stream_socket(socket: WebSocket, state: ShellRouterState read_task.abort(); } +fn enforce_member_stt_endpoint( + state: &ShellRouterState, + user: &CurrentUser, + config: &SpeechToTextConfig, +) -> Result<(), ApiError> { + let is_host_admin = user.site_role == SiteRole::Admin; + if !state.require_host_admin || is_host_admin || uses_official_stt_endpoint(config) { + return Ok(()); + } + Err(ApiError::coded( + StatusCode::FORBIDDEN, + "STT_ENDPOINT_ADMIN_REQUIRED", + "Custom speech-to-text endpoints require administrator access.", + None, + )) +} + +fn uses_official_stt_endpoint(config: &SpeechToTextConfig) -> bool { + match config.provider { + aionui_api_types::SpeechToTextProvider::Openai => config + .openai + .as_ref() + .is_some_and(|provider| official_https_endpoint(provider.base_url.as_deref(), "api.openai.com")), + aionui_api_types::SpeechToTextProvider::Deepgram => config + .deepgram + .as_ref() + .is_some_and(|provider| official_https_endpoint(provider.base_url.as_deref(), "api.deepgram.com")), + } +} + +fn official_https_endpoint(configured: Option<&str>, expected_host: &str) -> bool { + let Some(configured) = configured.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + let Ok(url) = reqwest::Url::parse(configured) else { + return false; + }; + url.scheme() == "https" + && url.host_str() == Some(expected_host) + && url.port().is_none_or(|port| port == 443) + && url.username().is_empty() + && url.password().is_none() +} + #[cfg(test)] mod tests { use super::*; + use axum::Extension; use axum::body::Body; use axum::http::Request; use http_body_util::BodyExt; @@ -389,6 +469,7 @@ mod tests { shell_service: Arc::new(ShellService::new(Arc::new(NoopSystemOpener))), stt_service: Arc::new(SttService::new(reqwest::Client::new())), client_pref_service, + require_host_admin: false, } } @@ -396,6 +477,42 @@ mod tests { shell_routes(make_state()) } + fn member() -> CurrentUser { + CurrentUser { + id: "member-user".into(), + username: "member-user".into(), + user_type: UserType::Local, + status: aionui_db::UserStatus::Active, + site_role: SiteRole::Member, + must_change_password: false, + } + } + + fn stt_config(provider: aionui_api_types::SpeechToTextProvider, base_url: Option<&str>) -> SpeechToTextConfig { + SpeechToTextConfig { + enabled: true, + provider, + auto_send: None, + openai: Some(aionui_api_types::OpenAISpeechToTextConfig { + api_key: "secret".into(), + base_url: base_url.map(str::to_owned), + model: "whisper-1".into(), + language: None, + prompt: None, + temperature: None, + }), + deepgram: Some(aionui_api_types::DeepgramSpeechToTextConfig { + api_key: "secret".into(), + base_url: base_url.map(str::to_owned), + model: "nova-2".into(), + language: None, + detect_language: None, + punctuate: None, + smart_format: None, + }), + } + } + async fn body_json(resp: axum::response::Response) -> serde_json::Value { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() @@ -513,6 +630,74 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + #[tokio::test] + async fn hosted_member_cannot_operate_host_shell_integrations() { + let mut state = make_state(); + state.require_host_admin = true; + let app = shell_routes(state).layer(Extension(member())); + let requests = [ + ("/api/shell/open-file", r#"{"filePath":"/etc/passwd"}"#), + ("/api/shell/show-item-in-folder", r#"{"filePath":"/etc/passwd"}"#), + ("/api/shell/open-external", r#"{"url":"https://example.com"}"#), + ("/api/shell/check-tool-installed", r#"{"tool":"terminal"}"#), + ( + "/api/shell/open-folder-with", + r#"{"folderPath":"/tmp","tool":"terminal"}"#, + ), + ]; + + for (uri, body) in requests { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "ADMIN_REQUIRED"); + } + } + + #[tokio::test] + async fn hosted_member_stt_allows_only_official_https_endpoints() { + let mut state = make_state(); + state.require_host_admin = true; + let member = member(); + + for config in [ + stt_config(aionui_api_types::SpeechToTextProvider::Openai, None), + stt_config( + aionui_api_types::SpeechToTextProvider::Openai, + Some("https://api.openai.com/v1"), + ), + stt_config(aionui_api_types::SpeechToTextProvider::Deepgram, None), + stt_config( + aionui_api_types::SpeechToTextProvider::Deepgram, + Some("https://api.deepgram.com"), + ), + ] { + assert!(enforce_member_stt_endpoint(&state, &member, &config).is_ok()); + } + + for base_url in [ + "http://api.openai.com", + "https://api.openai.com.evil.example", + "https://127.0.0.1", + "https://user:password@api.openai.com", + ] { + let config = stt_config(aionui_api_types::SpeechToTextProvider::Openai, Some(base_url)); + let error = enforce_member_stt_endpoint(&state, &member, &config).unwrap_err(); + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + assert_eq!(error.error_code(), "STT_ENDPOINT_ADMIN_REQUIRED"); + } + } + #[test] fn file_not_found_maps_to_bad_request() { let err = ApiError::from(ShellError::FileNotFound("/tmp/missing.txt".into())); diff --git a/crates/aionui-shell/src/state.rs b/crates/aionui-shell/src/state.rs index 11a3155c6..5aa8cd046 100644 --- a/crates/aionui-shell/src/state.rs +++ b/crates/aionui-shell/src/state.rs @@ -10,4 +10,7 @@ pub struct ShellRouterState { pub shell_service: Arc, pub stt_service: Arc, pub client_pref_service: ClientPrefService, + /// In hosted identity modes, require a live site administrator for + /// operating host applications and restrict member STT to official endpoints. + pub require_host_admin: bool, } diff --git a/crates/aionui-system/src/bedrock_probe/routes.rs b/crates/aionui-system/src/bedrock_probe/routes.rs index 805c595e0..550d1aa3f 100644 --- a/crates/aionui-system/src/bedrock_probe/routes.rs +++ b/crates/aionui-system/src/bedrock_probe/routes.rs @@ -8,6 +8,9 @@ use axum::routing::post; use aionui_api_types::{ApiResponse, TestBedrockConnectionRequest}; use aionui_auth::CurrentUser; use aionui_common::ApiError; +use aionui_db::SiteRole; +#[cfg(test)] +use aionui_db::UserType; use super::service::ConnectionTestService; @@ -36,10 +39,15 @@ pub fn connection_test_routes(state: ConnectionTestRouterState) -> Router { /// invalid credentials (mapped to 400 with descriptive message). async fn test_bedrock( State(state): State, - Extension(_user): Extension, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + if user.site_role != SiteRole::Admin { + return Err(ApiError::Forbidden( + "Bedrock connection testing is available only to site administrators".into(), + )); + } state .service .test_bedrock_connection(req.bedrock_config) @@ -51,6 +59,9 @@ async fn test_bedrock( #[cfg(test)] mod tests { use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; #[test] fn test_router_state_clone() { @@ -67,4 +78,43 @@ mod tests { }; let _router = connection_test_routes(state); } + + #[tokio::test] + async fn member_cannot_test_bedrock_credentials() { + let state = ConnectionTestRouterState { + service: ConnectionTestService::new(reqwest::Client::new()), + }; + let router = connection_test_routes(state); + for config in [ + serde_json::json!({ + "auth_method": "profile", + "region": "us-east-1", + "profile": "default" + }), + serde_json::json!({ + "auth_method": "accessKey", + "region": "us-east-1", + "access_key_id": "AKIA_TEST", + "secret_access_key": "secret" + }), + ] { + let mut request = Request::builder() + .method("POST") + .uri("/api/bedrock/test-connection") + .header("content-type", "application/json") + .body(Body::from(serde_json::json!({"bedrock_config": config}).to_string())) + .unwrap(); + request.extensions_mut().insert(CurrentUser { + id: "member-user".into(), + username: "member-user".into(), + user_type: UserType::Local, + status: aionui_db::UserStatus::Active, + site_role: SiteRole::Member, + must_change_password: false, + }); + + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } } diff --git a/crates/aionui-system/src/lib.rs b/crates/aionui-system/src/lib.rs index cad82b660..8c96c07f2 100644 --- a/crates/aionui-system/src/lib.rs +++ b/crates/aionui-system/src/lib.rs @@ -9,6 +9,7 @@ pub mod keep_awake; pub mod model_fetcher; pub mod protocol; pub mod provider; +mod provider_network; pub mod routes; pub mod runtime_prepare; pub mod settings; @@ -20,9 +21,10 @@ pub use client_pref::ClientPrefService; pub use diagnostics::FeedbackDiagnosticsService; pub use error::SystemError; pub use keep_awake::{KeepAwakeController, NoopKeepAwakeController, SystemKeepAwakeController}; -pub use model_fetcher::ModelFetchService; +pub use model_fetcher::{ModelFetchService, OutboundNetworkPolicy}; pub use protocol::ProtocolDetectionService; pub use provider::ProviderService; +pub use provider_network::validate_member_provider_runtime; pub use routes::{SystemRouterState, settings_routes, system_routes}; pub use runtime_prepare::RuntimePrepareService; pub use settings::SettingsService; diff --git a/crates/aionui-system/src/model_fetcher/fetchers.rs b/crates/aionui-system/src/model_fetcher/fetchers.rs index 1fe6f3201..826f76613 100644 --- a/crates/aionui-system/src/model_fetcher/fetchers.rs +++ b/crates/aionui-system/src/model_fetcher/fetchers.rs @@ -168,8 +168,8 @@ async fn fetch_gemini(client: &reqwest::Client, base_url: &str, api_key: &str) - ); Ok(fallback_models(GEMINI_FALLBACK_MODELS)) } - Err(e) => { - warn!(error = %e, "Gemini models API unreachable, using fallback list"); + Err(_) => { + warn!("Gemini models API unreachable, using fallback list"); Ok(fallback_models(GEMINI_FALLBACK_MODELS)) } } diff --git a/crates/aionui-system/src/model_fetcher/mod.rs b/crates/aionui-system/src/model_fetcher/mod.rs index ff0c3d11a..554287c50 100644 --- a/crates/aionui-system/src/model_fetcher/mod.rs +++ b/crates/aionui-system/src/model_fetcher/mod.rs @@ -1,4 +1,5 @@ mod fetchers; +pub(crate) mod network_guard; mod url_fixer; use std::sync::Arc; @@ -10,6 +11,8 @@ use aionui_db::IProviderRepository; use crate::error::SystemError; use crate::provider::deserialize_opt; +pub use network_guard::OutboundNetworkPolicy; + /// Internal configuration extracted from a provider row for model fetching. #[derive(Debug)] pub(crate) struct FetchConfig { @@ -52,9 +55,10 @@ impl ModelFetchService { user_id: &str, provider_id: &str, req: &FetchModelsRequest, + network_policy: OutboundNetworkPolicy, ) -> Result { let config = self.load_provider_config(user_id, provider_id).await?; - self.fetch_with_config(&config, req.try_fix).await + self.fetch_with_config(&config, req.try_fix, network_policy).await } /// Fetch models using credentials supplied in the request, without a @@ -63,6 +67,7 @@ impl ModelFetchService { pub async fn fetch_models_anonymous( &self, req: &FetchModelsAnonymousRequest, + network_policy: OutboundNetworkPolicy, ) -> Result { validate_anonymous_request(req)?; let config = FetchConfig { @@ -71,19 +76,36 @@ impl ModelFetchService { api_key: extract_first_key(&req.api_key), bedrock_config: req.bedrock_config.clone(), }; - self.fetch_with_config(&config, req.try_fix).await + self.fetch_with_config(&config, req.try_fix, network_policy).await } /// Shared fetch+try_fix branch used by both the by-id and anonymous /// entry points. - async fn fetch_with_config(&self, config: &FetchConfig, try_fix: bool) -> Result { - match fetchers::fetch_for_platform(&self.http_client, config).await { + async fn fetch_with_config( + &self, + config: &FetchConfig, + try_fix: bool, + network_policy: OutboundNetworkPolicy, + ) -> Result { + if network_policy == OutboundNetworkPolicy::PublicOnly && config.platform == "bedrock" { + return Err(SystemError::BadRequest( + "Bedrock provider access is available only to site administrators".into(), + )); + } + + let http_client = if uses_configured_http_endpoint(&config.platform) { + network_guard::client_for_url(&self.http_client, network_policy, &config.base_url).await? + } else { + self.http_client.clone() + }; + + match fetchers::fetch_for_platform(&http_client, config).await { Ok(models) => Ok(FetchModelsResponse { models, fixed_base_url: None, }), Err(err) if try_fix && supports_url_fix(&config.platform) => { - url_fixer::try_fix_url(&self.http_client, config).await.map_err(|_| err) + url_fixer::try_fix_url(&http_client, config).await.map_err(|_| err) } Err(err) => Err(err), } @@ -113,6 +135,10 @@ impl ModelFetchService { } } +fn uses_configured_http_endpoint(platform: &str) -> bool { + !matches!(platform, "bedrock" | "vertex-ai" | "minimax") +} + /// Validate a `FetchModelsAnonymousRequest` — platform / base_url / api_key /// must all be non-empty after trim. fn validate_anonymous_request(req: &FetchModelsAnonymousRequest) -> Result<(), SystemError> { @@ -238,7 +264,10 @@ mod tests { let (svc, db) = setup().await; let id = create_provider(&db, "vertex-ai", "https://unused", "fake-key").await; let req = FetchModelsRequest { try_fix: false }; - let resp = svc.fetch_models(TEST_USER_ID, &id, &req).await.unwrap(); + let resp = svc + .fetch_models(TEST_USER_ID, &id, &req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap(); assert_eq!(resp.models.len(), 2); assert!(resp.fixed_base_url.is_none()); } @@ -248,7 +277,10 @@ mod tests { let (svc, db) = setup().await; let id = create_provider(&db, "minimax", "https://unused", "fake-key").await; let req = FetchModelsRequest { try_fix: false }; - let resp = svc.fetch_models(TEST_USER_ID, &id, &req).await.unwrap(); + let resp = svc + .fetch_models(TEST_USER_ID, &id, &req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap(); assert_eq!(resp.models.len(), 3); } @@ -256,7 +288,10 @@ mod tests { async fn fetch_models_nonexistent_provider() { let (svc, _db) = setup().await; let req = FetchModelsRequest { try_fix: false }; - let err = svc.fetch_models(TEST_USER_ID, "no_such_id", &req).await.unwrap_err(); + let err = svc + .fetch_models(TEST_USER_ID, "no_such_id", &req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap_err(); assert!(matches!(err, SystemError::NotFound(_))); } @@ -270,7 +305,10 @@ mod tests { bedrock_config: None, try_fix: false, }; - let resp = svc.fetch_models_anonymous(&req).await.unwrap(); + let resp = svc + .fetch_models_anonymous(&req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap(); assert_eq!(resp.models.len(), 3); assert!(resp.fixed_base_url.is_none()); } @@ -285,7 +323,10 @@ mod tests { bedrock_config: None, try_fix: false, }; - let err = svc.fetch_models_anonymous(&req).await.unwrap_err(); + let err = svc + .fetch_models_anonymous(&req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap_err(); assert!(matches!(err, SystemError::BadRequest(_))); } @@ -299,7 +340,10 @@ mod tests { bedrock_config: None, try_fix: false, }; - let err = svc.fetch_models_anonymous(&req).await.unwrap_err(); + let err = svc + .fetch_models_anonymous(&req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap_err(); assert!(matches!(err, SystemError::BadRequest(_))); } @@ -319,6 +363,33 @@ mod tests { assert!(validate_anonymous_request(&req).is_ok()); } + #[tokio::test] + async fn hosted_member_cannot_probe_bedrock_with_access_keys() { + let (svc, _db) = setup().await; + let req = FetchModelsAnonymousRequest { + platform: "bedrock".into(), + base_url: "https://bedrock.us-east-1.amazonaws.com".into(), + api_key: "".into(), + bedrock_config: Some(aionui_api_types::BedrockConfig { + auth_method: aionui_api_types::BedrockAuthMethod::AccessKey, + region: "us-east-1".into(), + access_key_id: Some("AKIA_TEST".into()), + secret_access_key: Some("secret".into()), + profile: None, + }), + try_fix: false, + }; + + let error = svc + .fetch_models_anonymous(&req, OutboundNetworkPolicy::PublicOnly) + .await + .unwrap_err(); + match error { + SystemError::BadRequest(message) => assert!(message.contains("only to site administrators")), + other => panic!("unexpected error: {other}"), + } + } + // ── extract_first_key ───────────────────────────────────────────── #[test] @@ -357,7 +428,10 @@ mod tests { bedrock_config: None, try_fix: false, }; - let resp = svc.fetch_models_anonymous(&req).await.unwrap(); + let resp = svc + .fetch_models_anonymous(&req, OutboundNetworkPolicy::Unrestricted) + .await + .unwrap(); assert_eq!(resp.models.len(), 3); } diff --git a/crates/aionui-system/src/model_fetcher/network_guard.rs b/crates/aionui-system/src/model_fetcher/network_guard.rs new file mode 100644 index 000000000..6b2b0df74 --- /dev/null +++ b/crates/aionui-system/src/model_fetcher/network_guard.rs @@ -0,0 +1,171 @@ +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use aionui_common::{ + PublicHttpUrlError, validate_public_http_url, validate_public_http_url_value, validate_public_resolved_addresses, +}; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use reqwest::redirect::Policy; + +use crate::error::SystemError; + +const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_REDIRECTS: usize = 10; + +/// Controls whether a provider probe may intentionally connect to services on +/// the host or private network. WebUI members use `PublicOnly`; local mode and +/// live site administrators may use `Unrestricted` for local model servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutboundNetworkPolicy { + PublicOnly, + Unrestricted, +} + +/// Select an HTTP client for one provider operation. +/// +/// The public-only client disables environment proxies, filters DNS results at +/// connection time, and applies the same literal-address checks to each +/// redirect. The separate up-front lookup produces a useful client error while +/// the resolver remains the enforcement point if DNS changes between checks. +pub(crate) async fn client_for_url( + unrestricted_client: &reqwest::Client, + policy: OutboundNetworkPolicy, + raw_url: &str, +) -> Result { + validate_url(policy, raw_url).await?; + if policy == OutboundNetworkPolicy::Unrestricted { + return Ok(unrestricted_client.clone()); + } + + reqwest::Client::builder() + .no_proxy() + .dns_resolver(Arc::new(PublicDnsResolver)) + .redirect(Policy::custom(|attempt| { + if attempt.previous().len() >= MAX_REDIRECTS { + return attempt.error("too many redirects"); + } + if let Some(previous) = attempt.previous().last() + && !redirect_target_allowed(previous, attempt.url()) + { + return attempt.error("cross-host or insecure provider redirect blocked"); + } + match validate_public_http_url_value(attempt.url()) { + Ok(()) => attempt.follow(), + Err(error) => attempt.error(error), + } + })) + .build() + .map_err(|_| SystemError::Internal("Failed to configure secure provider HTTP client".into())) +} + +fn redirect_target_allowed(previous: &reqwest::Url, next: &reqwest::Url) -> bool { + if previous.host_str() != next.host_str() { + return false; + } + + match (previous.scheme(), next.scheme()) { + ("http", "https") => previous.port_or_known_default() == Some(80) && next.port_or_known_default() == Some(443), + (previous_scheme, next_scheme) if previous_scheme == next_scheme => { + previous.port_or_known_default() == next.port_or_known_default() + } + _ => false, + } +} + +pub(crate) async fn validate_url(policy: OutboundNetworkPolicy, raw_url: &str) -> Result<(), SystemError> { + if policy == OutboundNetworkPolicy::Unrestricted { + let url = reqwest::Url::parse(raw_url.trim()) + .map_err(|_| SystemError::BadRequest("baseUrl must be a valid http or https URL".into()))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(SystemError::BadRequest( + "baseUrl must be a valid http or https URL".into(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(SystemError::BadRequest("baseUrl must not include credentials".into())); + } + return Ok(()); + } + validate_public_destination(raw_url).await +} + +async fn validate_public_destination(raw_url: &str) -> Result<(), SystemError> { + let url = validate_public_http_url(raw_url).map_err(public_url_error)?; + + let host = url + .host_str() + .ok_or_else(|| SystemError::BadRequest("baseUrl must include a host".into()))?; + if host.trim_matches(['[', ']']).parse::().is_ok() { + return Ok(()); + } + + let port = url + .port_or_known_default() + .ok_or_else(|| SystemError::BadRequest("baseUrl must include a valid port".into()))?; + let host = host.to_owned(); + let resolved = tokio::time::timeout(DNS_LOOKUP_TIMEOUT, tokio::net::lookup_host((host.as_str(), port))) + .await + .map_err(|_| SystemError::Timeout("Provider hostname lookup timed out".into()))? + .map_err(|_| SystemError::BadGateway("Provider hostname could not be resolved".into()))?; + + validate_public_resolved_addresses(resolved.map(|address| address.ip())).map_err(public_url_error) +} + +fn public_url_error(error: PublicHttpUrlError) -> SystemError { + match error { + PublicHttpUrlError::NoResolvedAddresses => { + SystemError::BadGateway("Provider hostname did not resolve to an address".into()) + } + _ => SystemError::BadRequest(format!("Provider {error}")), + } +} + +#[derive(Debug)] +struct PublicDnsResolver; + +impl Resolve for PublicDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_owned(); + Box::pin(async move { + let resolved = tokio::net::lookup_host((host.as_str(), 0)).await?; + let addresses: Vec<_> = resolved.collect(); + validate_public_resolved_addresses(addresses.iter().map(|address| address.ip())) + .map_err(|error| io::Error::new(io::ErrorKind::PermissionDenied, error.to_string()))?; + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn localhost_dns_resolution_is_blocked() { + let error = validate_public_destination("http://localhost:8080").await.unwrap_err(); + assert!(matches!(error, SystemError::BadRequest(_))); + } + + #[tokio::test] + async fn connection_time_resolver_rejects_private_dns_answers() { + let result = PublicDnsResolver.resolve("localhost".parse().unwrap()).await; + assert!(result.is_err()); + } + + #[test] + fn redirects_stay_on_the_same_host_and_cannot_downgrade_tls() { + let https = reqwest::Url::parse("https://api.example.com/v1/models").unwrap(); + let same_host = reqwest::Url::parse("https://api.example.com/v2/models").unwrap(); + let upgrade = reqwest::Url::parse("https://api.example.com/v1/models").unwrap(); + let http = reqwest::Url::parse("http://api.example.com/v1/models").unwrap(); + let other_port = reqwest::Url::parse("https://api.example.com:8443/v1/models").unwrap(); + let other_host = reqwest::Url::parse("https://redirect.example.net/v1/models").unwrap(); + + assert!(redirect_target_allowed(&https, &same_host)); + assert!(redirect_target_allowed(&http, &upgrade)); + assert!(!redirect_target_allowed(&https, &http)); + assert!(!redirect_target_allowed(&https, &other_port)); + assert!(!redirect_target_allowed(&https, &other_host)); + } +} diff --git a/crates/aionui-system/src/protocol.rs b/crates/aionui-system/src/protocol.rs index 92be6a7d1..9cbfb43c9 100644 --- a/crates/aionui-system/src/protocol.rs +++ b/crates/aionui-system/src/protocol.rs @@ -11,6 +11,8 @@ use tokio::task::JoinSet; use tracing::debug; use crate::error::SystemError; +use crate::model_fetcher::OutboundNetworkPolicy; +use crate::model_fetcher::network_guard; const DEFAULT_TIMEOUT_MS: u64 = 10_000; const MAX_CONCURRENT_KEY_TESTS: usize = 5; @@ -98,8 +100,13 @@ impl ProtocolDetectionService { Self { http_client } } - pub async fn detect_protocol(&self, req: &DetectProtocolRequest) -> Result { + pub async fn detect_protocol( + &self, + req: &DetectProtocolRequest, + network_policy: OutboundNetworkPolicy, + ) -> Result { validate_request(req)?; + let http_client = network_guard::client_for_url(&self.http_client, network_policy, &req.base_url).await?; let keys = parse_keys(&req.api_key); let primary_key = &keys[0]; @@ -120,7 +127,7 @@ impl ProtocolDetectionService { for protocol in &test_order { match self - .probe_protocol(*protocol, &req.base_url, primary_key, timeout) + .probe_protocol(&http_client, *protocol, &req.base_url, primary_key, timeout) .await { Ok(ProbeOutcome::Success { @@ -131,7 +138,10 @@ impl ProtocolDetectionService { let suggestion = success_suggestion(*protocol, req.preferred_protocol); let multi_key_result = if req.test_all_keys && keys.len() > 1 { let effective = fixed_base_url.as_deref().unwrap_or(&req.base_url); - Some(self.test_all_keys(&keys, *protocol, effective, timeout).await) + Some( + self.test_all_keys(&http_client, &keys, *protocol, effective, timeout) + .await, + ) } else { None }; @@ -159,7 +169,10 @@ impl ProtocolDetectionService { if let Some((protocol, fixed_base_url)) = auth_failure { let multi_key_result = if req.test_all_keys && keys.len() > 1 { let effective = fixed_base_url.as_deref().unwrap_or(&req.base_url); - Some(self.test_all_keys(&keys, protocol, effective, timeout).await) + Some( + self.test_all_keys(&http_client, &keys, protocol, effective, timeout) + .await, + ) } else { None }; @@ -188,6 +201,7 @@ impl ProtocolDetectionService { async fn probe_protocol( &self, + client: &reqwest::Client, protocol: ProtocolType, base_url: &str, api_key: &str, @@ -195,14 +209,20 @@ impl ProtocolDetectionService { ) -> Result { let base = base_url.trim_end_matches('/'); match protocol { - ProtocolType::OpenAI => self.probe_openai(base, api_key, timeout).await, - ProtocolType::Anthropic => self.probe_anthropic(base, api_key, timeout).await, - ProtocolType::Gemini => self.probe_gemini(base, api_key, timeout).await, + ProtocolType::OpenAI => self.probe_openai(client, base, api_key, timeout).await, + ProtocolType::Anthropic => self.probe_anthropic(client, base, api_key, timeout).await, + ProtocolType::Gemini => self.probe_gemini(client, base, api_key, timeout).await, ProtocolType::Unknown => Err(SystemError::Internal("Cannot probe unknown".into())), } } - async fn probe_openai(&self, base: &str, api_key: &str, timeout: Duration) -> Result { + async fn probe_openai( + &self, + client: &reqwest::Client, + base: &str, + api_key: &str, + timeout: Duration, + ) -> Result { let urls = [ (format!("{base}/models"), None), (format!("{base}/v1/models"), Some(format!("{base}/v1"))), @@ -211,8 +231,7 @@ impl ProtocolDetectionService { let mut last_auth_failure: Option> = None; for (url, fixed) in &urls { - let resp = self - .http_client + let resp = client .get(url) .header("Authorization", format!("Bearer {api_key}")) .timeout(timeout) @@ -246,17 +265,22 @@ impl ProtocolDetectionService { Err(SystemError::BadGateway("OpenAI probe failed".into())) } - async fn probe_anthropic(&self, base: &str, api_key: &str, timeout: Duration) -> Result { + async fn probe_anthropic( + &self, + client: &reqwest::Client, + base: &str, + api_key: &str, + timeout: Duration, + ) -> Result { let url = format!("{base}/v1/models"); - let resp = self - .http_client + let resp = client .get(&url) .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .timeout(timeout) .send() .await - .map_err(|e| SystemError::BadGateway(format!("Anthropic probe failed: {e}")))?; + .map_err(|_| SystemError::BadGateway("Anthropic probe request failed".into()))?; if resp.status().is_success() { let body: DataResponse = resp @@ -277,15 +301,20 @@ impl ProtocolDetectionService { Err(SystemError::BadGateway(format!("Anthropic returned {}", resp.status()))) } - async fn probe_gemini(&self, base: &str, api_key: &str, timeout: Duration) -> Result { + async fn probe_gemini( + &self, + client: &reqwest::Client, + base: &str, + api_key: &str, + timeout: Duration, + ) -> Result { let url = format!("{base}/v1beta/models?key={api_key}"); - let resp = self - .http_client + let resp = client .get(&url) .timeout(timeout) .send() .await - .map_err(|e| SystemError::BadGateway(format!("Gemini probe failed: {e}")))?; + .map_err(|_| SystemError::BadGateway("Gemini probe request failed".into()))?; if resp.status().is_success() { let body: GeminiResponse = resp @@ -315,6 +344,7 @@ impl ProtocolDetectionService { async fn test_all_keys( &self, + client: &reqwest::Client, keys: &[String], protocol: ProtocolType, effective_base: &str, @@ -325,7 +355,7 @@ impl ProtocolDetectionService { let mut set = JoinSet::new(); for (i, key) in keys.iter().enumerate() { - let client = self.http_client.clone(); + let client = client.clone(); let key = key.clone(); let base = base.clone(); let sem = sem.clone(); @@ -507,7 +537,7 @@ async fn test_single_key( let resp = req .send() .await - .map_err(|e| SystemError::BadGateway(format!("Request failed: {e}")))?; + .map_err(|_| SystemError::BadGateway("Provider key test request failed".into()))?; if resp.status().is_success() { Ok(()) diff --git a/crates/aionui-system/src/provider.rs b/crates/aionui-system/src/provider.rs index da9c90084..f59bb9821 100644 --- a/crates/aionui-system/src/provider.rs +++ b/crates/aionui-system/src/provider.rs @@ -3,27 +3,47 @@ use std::sync::Arc; use aionui_api_types::{CreateProviderRequest, ProviderResponse, UpdateProviderRequest}; use aionui_common::{decrypt_string, encrypt_string}; -use aionui_db::{CreateProviderParams, IProviderRepository, UpdateProviderParams, models::Provider}; +use aionui_db::{ + CreateProviderParams, IProviderRepository, IResourceShareRepository, ResourceAccess, ShareResourceType, + UpdateProviderParams, models::Provider, +}; use serde::de::DeserializeOwned; use crate::error::SystemError; +use crate::model_fetcher::OutboundNetworkPolicy; +use crate::model_fetcher::network_guard; +use crate::provider_network::{validate_member_provider_endpoint, validate_member_provider_runtime}; /// Business logic for model provider CRUD with API key encryption/masking. #[derive(Clone)] pub struct ProviderService { repo: Arc, + share_repo: Option>, encryption_key: [u8; 32], } impl ProviderService { pub fn new(repo: Arc, encryption_key: [u8; 32]) -> Self { - Self { repo, encryption_key } + Self { + repo, + share_repo: None, + encryption_key, + } + } + + pub fn with_share_repo(mut self, share_repo: Arc) -> Self { + self.share_repo = Some(share_repo); + self } /// List all providers with masked API keys. pub async fn list(&self, user_id: &str) -> Result, SystemError> { let rows = self.repo.list(user_id).await?; - rows.into_iter().map(|row| self.row_to_response(row)).collect() + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(self.row_to_response_for_user(user_id, row).await?); + } + Ok(out) } /// Create a new provider. The API key is encrypted before storage. @@ -32,8 +52,14 @@ impl ProviderService { /// otherwise a fresh id is generated by the repository. This supports the /// frontend-local-store → backend migration path where existing provider /// ids must be preserved. - pub async fn create(&self, user_id: &str, req: CreateProviderRequest) -> Result { + pub async fn create( + &self, + user_id: &str, + req: CreateProviderRequest, + network_policy: OutboundNetworkPolicy, + ) -> Result { validate_create_request(&req)?; + validate_create_network_access(&req, network_policy).await?; let encrypted_key = encrypt_string(&req.api_key, &self.encryption_key)?; let models_json = serialize_json(&req.models, "models")?; @@ -65,7 +91,7 @@ impl ProviderService { }; let row = self.repo.create(params).await?; - self.row_to_response(row) + self.row_to_response_for_user(user_id, row).await } /// Update an existing provider. Only provided fields are changed. @@ -74,8 +100,20 @@ impl ProviderService { user_id: &str, id: &str, req: UpdateProviderRequest, + network_policy: OutboundNetworkPolicy, ) -> Result { validate_update_request(&req)?; + let existing = if network_policy == OutboundNetworkPolicy::PublicOnly { + Some( + self.repo + .find_by_id(user_id, id) + .await? + .ok_or_else(|| SystemError::NotFound(format!("Provider '{id}' not found")))?, + ) + } else { + None + }; + validate_update_network_access(&req, network_policy, existing.as_ref()).await?; let encrypted_key = req .api_key @@ -108,7 +146,7 @@ impl ProviderService { }; let row = self.repo.update(user_id, id, params).await?; - self.row_to_response(row) + self.row_to_response_for_user(user_id, row).await } /// Delete a provider by ID. @@ -121,6 +159,30 @@ impl ProviderService { // Internal helpers // ----------------------------------------------------------------------- + async fn row_to_response_for_user(&self, user_id: &str, row: Provider) -> Result { + let mut response = self.row_to_response(row)?; + if self.should_mask_secrets(user_id, &response.id).await? { + // View-only grantees never receive raw secrets. + response.api_key.clear(); + if let Some(ref mut bedrock) = response.bedrock_config { + bedrock.access_key_id = None; + bedrock.secret_access_key = None; + } + } + Ok(response) + } + + async fn should_mask_secrets(&self, user_id: &str, provider_id: &str) -> Result { + let Some(share_repo) = &self.share_repo else { + return Ok(false); + }; + let access = share_repo + .resolve_access(ShareResourceType::Provider, provider_id, user_id) + .await?; + // Owner and edit grantees may see secrets; view-only must not. + Ok(access == ResourceAccess::View) + } + /// Convert a DB row into a response DTO with the plaintext API key /// (decrypted) and deserialized JSON fields. /// @@ -298,6 +360,59 @@ fn validate_base_url(url: &str) -> Result<(), SystemError> { Ok(()) } +async fn validate_create_network_access( + req: &CreateProviderRequest, + network_policy: OutboundNetworkPolicy, +) -> Result<(), SystemError> { + validate_bedrock_profile_access(req.bedrock_config.as_ref(), network_policy)?; + if network_policy == OutboundNetworkPolicy::PublicOnly { + return validate_member_provider_runtime(&req.platform, &req.base_url).await; + } + if !req.base_url.trim().is_empty() { + network_guard::validate_url(network_policy, &req.base_url).await?; + } + Ok(()) +} + +async fn validate_update_network_access( + req: &UpdateProviderRequest, + network_policy: OutboundNetworkPolicy, + existing: Option<&Provider>, +) -> Result<(), SystemError> { + validate_bedrock_profile_access(req.bedrock_config.as_ref(), network_policy)?; + if network_policy == OutboundNetworkPolicy::PublicOnly { + let existing = existing.ok_or_else(|| SystemError::Internal("Missing provider network context".into()))?; + let platform = req.platform.as_deref().unwrap_or(&existing.platform); + let base_url = req.base_url.as_deref().unwrap_or(&existing.base_url); + validate_member_provider_endpoint(platform, base_url)?; + + if req.platform.is_some() || req.base_url.is_some() { + return validate_member_provider_runtime(platform, base_url).await; + } + return Ok(()); + } + if let Some(base_url) = req.base_url.as_deref() + && !base_url.trim().is_empty() + { + network_guard::validate_url(network_policy, base_url).await?; + } + Ok(()) +} + +fn validate_bedrock_profile_access( + config: Option<&aionui_api_types::BedrockConfig>, + network_policy: OutboundNetworkPolicy, +) -> Result<(), SystemError> { + if network_policy == OutboundNetworkPolicy::PublicOnly + && config.is_some_and(|config| matches!(config.auth_method, aionui_api_types::BedrockAuthMethod::Profile)) + { + return Err(SystemError::BadRequest( + "Bedrock host profiles are available only to site administrators".into(), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -307,6 +422,7 @@ mod tests { // A fixed 32-byte key for testing const TEST_KEY: [u8; 32] = [0x42; 32]; + const TEST_NETWORK_POLICY: OutboundNetworkPolicy = OutboundNetworkPolicy::Unrestricted; async fn setup() -> ProviderService { let db = init_database_memory().await.unwrap(); @@ -524,7 +640,10 @@ mod tests { #[tokio::test] async fn create_and_list() { let svc = setup().await; - let created = svc.create(TEST_USER_ID, sample_create_request()).await.unwrap(); + let created = svc + .create(TEST_USER_ID, sample_create_request(), TEST_NETWORK_POLICY) + .await + .unwrap(); assert!(created.id.starts_with("prov_")); assert_eq!(created.platform, "anthropic"); @@ -548,7 +667,7 @@ mod tests { id: Some("caller-id-xyz".into()), ..sample_create_request() }; - let created = svc.create(TEST_USER_ID, req).await.unwrap(); + let created = svc.create(TEST_USER_ID, req, TEST_NETWORK_POLICY).await.unwrap(); assert_eq!(created.id, "caller-id-xyz"); } @@ -559,7 +678,7 @@ mod tests { id: Some(" ".into()), ..sample_create_request() }; - let err = svc.create(TEST_USER_ID, req).await.unwrap_err(); + let err = svc.create(TEST_USER_ID, req, TEST_NETWORK_POLICY).await.unwrap_err(); assert!(matches!(err, SystemError::BadRequest(_))); } @@ -570,13 +689,13 @@ mod tests { id: Some("dup-id".into()), ..sample_create_request() }; - svc.create(TEST_USER_ID, req1).await.unwrap(); + svc.create(TEST_USER_ID, req1, TEST_NETWORK_POLICY).await.unwrap(); let req2 = CreateProviderRequest { id: Some("dup-id".into()), ..sample_create_request() }; - let err = svc.create(TEST_USER_ID, req2).await.unwrap_err(); + let err = svc.create(TEST_USER_ID, req2, TEST_NETWORK_POLICY).await.unwrap_err(); assert!(matches!(err, SystemError::Conflict(_))); } @@ -589,7 +708,7 @@ mod tests { model_enabled: Some(HashMap::from([("gpt-4".into(), true), ("gpt-3.5".into(), false)])), ..sample_create_request() }; - let created = svc.create(TEST_USER_ID, req).await.unwrap(); + let created = svc.create(TEST_USER_ID, req, TEST_NETWORK_POLICY).await.unwrap(); assert_eq!( created.model_protocols.as_ref().and_then(|m| m.get("gpt-4")), @@ -615,7 +734,7 @@ mod tests { api_key: "sk-secret-original-value".into(), ..sample_create_request() }; - let created = svc.create(TEST_USER_ID, req).await.unwrap(); + let created = svc.create(TEST_USER_ID, req, TEST_NETWORK_POLICY).await.unwrap(); assert_eq!(created.api_key, "sk-secret-original-value"); assert!(!created.api_key.contains("***")); } @@ -627,14 +746,17 @@ mod tests { platform: "".into(), ..sample_create_request() }; - let err = svc.create(TEST_USER_ID, req).await.unwrap_err(); + let err = svc.create(TEST_USER_ID, req, TEST_NETWORK_POLICY).await.unwrap_err(); assert!(matches!(err, SystemError::BadRequest(_))); } #[tokio::test] async fn update_name() { let svc = setup().await; - let created = svc.create(TEST_USER_ID, sample_create_request()).await.unwrap(); + let created = svc + .create(TEST_USER_ID, sample_create_request(), TEST_NETWORK_POLICY) + .await + .unwrap(); let updated = svc .update( @@ -644,6 +766,7 @@ mod tests { name: Some("New Name".into()), ..Default::default() }, + TEST_NETWORK_POLICY, ) .await .unwrap(); @@ -655,7 +778,10 @@ mod tests { #[tokio::test] async fn update_api_key_re_encrypts() { let svc = setup().await; - let created = svc.create(TEST_USER_ID, sample_create_request()).await.unwrap(); + let created = svc + .create(TEST_USER_ID, sample_create_request(), TEST_NETWORK_POLICY) + .await + .unwrap(); let updated = svc .update( @@ -665,6 +791,7 @@ mod tests { api_key: Some("new-key-abcdefgh".into()), ..Default::default() }, + TEST_NETWORK_POLICY, ) .await .unwrap(); @@ -677,7 +804,12 @@ mod tests { async fn update_nonexistent_returns_not_found() { let svc = setup().await; let err = svc - .update(TEST_USER_ID, "no_such_id", UpdateProviderRequest::default()) + .update( + TEST_USER_ID, + "no_such_id", + UpdateProviderRequest::default(), + TEST_NETWORK_POLICY, + ) .await .unwrap_err(); assert!(matches!(err, SystemError::NotFound(_))); @@ -686,7 +818,10 @@ mod tests { #[tokio::test] async fn delete_existing() { let svc = setup().await; - let created = svc.create(TEST_USER_ID, sample_create_request()).await.unwrap(); + let created = svc + .create(TEST_USER_ID, sample_create_request(), TEST_NETWORK_POLICY) + .await + .unwrap(); svc.delete(TEST_USER_ID, &created.id).await.unwrap(); let all = svc.list(TEST_USER_ID).await.unwrap(); @@ -743,6 +878,7 @@ mod undecryptable_row_tests { bedrock_config: None, is_full_url: false, }, + OutboundNetworkPolicy::Unrestricted, ) .await .unwrap(); @@ -766,6 +902,7 @@ mod undecryptable_row_tests { bedrock_config: None, is_full_url: false, }, + OutboundNetworkPolicy::Unrestricted, ) .await .unwrap(); diff --git a/crates/aionui-system/src/provider_network.rs b/crates/aionui-system/src/provider_network.rs new file mode 100644 index 000000000..e2a7d03d2 --- /dev/null +++ b/crates/aionui-system/src/provider_network.rs @@ -0,0 +1,147 @@ +use aionui_common::validate_public_http_url; + +use crate::error::SystemError; +use crate::model_fetcher::OutboundNetworkPolicy; +use crate::model_fetcher::network_guard; + +/// Exact official provider hosts available to WebUI members. Aionrs v0.2.10 +/// owns its HTTP client, so its resolver and redirect behavior cannot be +/// replaced by AionCore. This list removes user-owned origins from that +/// transport; never add wildcards or user-controlled subdomains. +const MEMBER_PROVIDER_HOSTS: &[&str] = &[ + "api-inference.modelscope.cn", + "api.anthropic.com", + "api.deepseek.com", + "api.hunyuan.cloud.tencent.com", + "api.lingyiwanwu.com", + "api.minimaxi.com", + "api.moonshot.ai", + "api.moonshot.cn", + "api.novita.ai", + "api.openai.com", + "api.poe.com", + "api.ppinfra.com", + "api.siliconflow.cn", + "api.siliconflow.com", + "api.stepfun.com", + "api.x.ai", + "ark.cn-beijing.volces.com", + "cloud.infini-ai.com", + "coding.dashscope.aliyuncs.com", + "dashscope.aliyuncs.com", + "generativelanguage.googleapis.com", + "open.bigmodel.cn", + "openrouter.ai", + "qianfan.baidubce.com", + "wishub-x1.ctyun.cn", +]; + +const MEMBER_HTTP_PROVIDER_PLATFORMS: &[&str] = &[ + "anthropic", + "claude", + "custom", + "dashscope-coding", + "gemini", + "minimax", + "new-api", + "openai", +]; + +/// Validate the effective provider destination immediately before a WebUI +/// member's Aionrs runtime is built. +/// +/// The exact-host policy compensates for Aionrs owning its HTTP client, while +/// the fresh DNS lookup rejects non-public and mixed public/private answers at +/// the closest available point to the live request. +pub async fn validate_member_provider_runtime(platform: &str, base_url: &str) -> Result<(), SystemError> { + validate_member_provider_endpoint(platform, base_url)?; + network_guard::validate_url(OutboundNetworkPolicy::PublicOnly, base_url).await +} + +pub(crate) fn validate_member_provider_endpoint(platform: &str, base_url: &str) -> Result<(), SystemError> { + let platform = platform.trim().to_ascii_lowercase(); + if !MEMBER_HTTP_PROVIDER_PLATFORMS.contains(&platform.as_str()) { + return Err(SystemError::BadRequest( + "Provider platform is not available to WebUI members".into(), + )); + } + + let url = + validate_public_http_url(base_url).map_err(|error| SystemError::BadRequest(format!("Provider {error}")))?; + if url.scheme() != "https" { + return Err(SystemError::BadRequest("Provider URL must use https".into())); + } + if url.port().is_some_and(|port| port != 443) { + return Err(SystemError::BadRequest( + "Provider URL must use the default https port".into(), + )); + } + + let host = url + .host_str() + .ok_or_else(|| SystemError::BadRequest("Provider URL must include a host".into()))?; + let allowed_for_platform = match platform.as_str() { + "anthropic" | "claude" => host == "api.anthropic.com", + "dashscope-coding" => host == "coding.dashscope.aliyuncs.com", + "gemini" => host == "generativelanguage.googleapis.com", + "minimax" => host == "api.minimaxi.com", + "openai" => host == "api.openai.com", + "custom" | "new-api" => MEMBER_PROVIDER_HOSTS.contains(&host), + _ => false, + }; + if !allowed_for_platform { + return Err(SystemError::BadRequest( + "Provider host is not an approved WebUI member endpoint".into(), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn member_provider_presets_are_allowed_on_exact_https_hosts() { + for host in MEMBER_PROVIDER_HOSTS { + let url = format!("https://{host}/v1"); + assert!( + validate_member_provider_endpoint("custom", &url).is_ok(), + "{url} must be allowed" + ); + } + + assert!(validate_member_provider_endpoint("anthropic", "https://api.anthropic.com").is_ok()); + assert!(validate_member_provider_endpoint("gemini", "https://generativelanguage.googleapis.com").is_ok()); + assert!(validate_member_provider_endpoint("openai", "https://api.openai.com/v1").is_ok()); + } + + #[test] + fn member_provider_endpoint_rejects_unapproved_hosts_and_http() { + for (platform, base_url) in [ + ("custom", "https://api.example.com/v1"), + ("custom", "https://evil.api.openai.com/v1"), + ("custom", "https://api.openai.com.attacker.test/v1"), + ("custom", "http://api.openai.com/v1"), + ("custom", "https://api.openai.com:8443/v1"), + ("anthropic", "https://api.openai.com/v1"), + ("gemini", "https://api.anthropic.com"), + ] { + assert!( + validate_member_provider_endpoint(platform, base_url).is_err(), + "{platform} endpoint {base_url} must be blocked" + ); + } + } + + #[test] + fn member_provider_endpoint_rejects_sdk_and_unknown_platforms() { + for platform in ["bedrock", "gemini-vertex-ai", "vertex-ai", "unknown", ""] { + assert!( + validate_member_provider_endpoint(platform, "https://api.openai.com/v1").is_err(), + "{platform} must be blocked" + ); + } + } +} diff --git a/crates/aionui-system/src/routes.rs b/crates/aionui-system/src/routes.rs index 22831d819..4f3489f42 100644 --- a/crates/aionui-system/src/routes.rs +++ b/crates/aionui-system/src/routes.rs @@ -15,11 +15,12 @@ use aionui_api_types::{ }; use aionui_auth::CurrentUser; use aionui_common::ApiError; +use aionui_db::SiteRole; use crate::client_pref::ClientPrefService; use crate::diagnostics::FeedbackDiagnosticsService; use crate::error::SystemError; -use crate::model_fetcher::ModelFetchService; +use crate::model_fetcher::{ModelFetchService, OutboundNetworkPolicy}; use crate::protocol::ProtocolDetectionService; use crate::provider::ProviderService; use crate::runtime_prepare::RuntimePrepareService; @@ -208,7 +209,7 @@ async fn create_provider( let Json(req) = body.map_err(ApiError::from)?; let provider = state .provider_service - .create(&user.id, req) + .create(&user.id, req, outbound_network_policy(&user)) .await .map_err(ApiError::from)?; Ok((StatusCode::CREATED, Json(ApiResponse::ok(provider)))) @@ -223,7 +224,7 @@ async fn update_provider( let Json(req) = body.map_err(ApiError::from)?; let provider = state .provider_service - .update(&user.id, &id, req) + .update(&user.id, &id, req, outbound_network_policy(&user)) .await .map_err(ApiError::from)?; Ok(Json(ApiResponse::ok(provider))) @@ -251,7 +252,7 @@ async fn fetch_models( let Json(req) = body.map_err(ApiError::from)?; let result = state .model_fetch_service - .fetch_models(&user.id, &id, &req) + .fetch_models(&user.id, &id, &req, outbound_network_policy(&user)) .await .map_err(ApiError::from)?; Ok(Json(ApiResponse::ok(result))) @@ -259,12 +260,13 @@ async fn fetch_models( async fn fetch_models_anonymous( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; let result = state .model_fetch_service - .fetch_models_anonymous(&req) + .fetch_models_anonymous(&req, outbound_network_policy(&user)) .await .map_err(ApiError::from)?; Ok(Json(ApiResponse::ok(result))) @@ -272,12 +274,13 @@ async fn fetch_models_anonymous( async fn detect_protocol( State(state): State, + Extension(user): Extension, body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; let result = state .protocol_detection_service - .detect_protocol(&req) + .detect_protocol(&req, outbound_network_policy(&user)) .await .map_err(ApiError::from)?; Ok(Json(ApiResponse::ok(result))) @@ -287,11 +290,27 @@ async fn detect_protocol( // System info & version check handlers // =========================================================================== -async fn get_system_info() -> Json> { - let info = crate::sysinfo::get_system_info(); +async fn get_system_info(Extension(user): Extension) -> Json> { + let info = if may_access_host_infrastructure(&user) { + crate::sysinfo::get_system_info() + } else { + crate::sysinfo::get_redacted_system_info() + }; Json(ApiResponse::ok(info)) } +fn outbound_network_policy(user: &CurrentUser) -> OutboundNetworkPolicy { + if may_access_host_infrastructure(user) { + OutboundNetworkPolicy::Unrestricted + } else { + OutboundNetworkPolicy::PublicOnly + } +} + +fn may_access_host_infrastructure(user: &CurrentUser) -> bool { + user.site_role == SiteRole::Admin +} + async fn check_update( State(state): State, body: Result, JsonRejection>, diff --git a/crates/aionui-system/src/sysinfo.rs b/crates/aionui-system/src/sysinfo.rs index e9281b3ca..ba4eede1b 100644 --- a/crates/aionui-system/src/sysinfo.rs +++ b/crates/aionui-system/src/sysinfo.rs @@ -1,5 +1,7 @@ use aionui_api_types::SystemInfoResponse; +const REDACTED_PATH: &str = "[redacted]"; + /// Map Rust `std::env::consts::OS` to the Node.js-compatible platform name /// used by the API contract. fn map_platform() -> &'static str { @@ -86,6 +88,15 @@ pub fn get_system_info() -> SystemInfoResponse { } } +/// Build system information without exposing server filesystem paths. +pub fn get_redacted_system_info() -> SystemInfoResponse { + let mut info = get_system_info(); + info.cache_dir = REDACTED_PATH.to_owned(); + info.work_dir = REDACTED_PATH.to_owned(); + info.log_dir = REDACTED_PATH.to_owned(); + info +} + #[cfg(test)] mod tests { use super::*; @@ -113,6 +124,16 @@ mod tests { assert!(!info.arch.is_empty()); } + #[test] + fn test_redacted_system_info_hides_host_paths() { + let info = get_redacted_system_info(); + assert_eq!(info.cache_dir, REDACTED_PATH); + assert_eq!(info.work_dir, REDACTED_PATH); + assert_eq!(info.log_dir, REDACTED_PATH); + assert!(!info.platform.is_empty()); + assert!(!info.arch.is_empty()); + } + #[test] fn test_env_override_cache_dir() { // This test verifies the resolve logic reads env vars. diff --git a/crates/aionui-system/tests/feedback_diagnostics_routes.rs b/crates/aionui-system/tests/feedback_diagnostics_routes.rs index bc1d915fe..365427750 100644 --- a/crates/aionui-system/tests/feedback_diagnostics_routes.rs +++ b/crates/aionui-system/tests/feedback_diagnostics_routes.rs @@ -13,7 +13,7 @@ use serde_json::json; use tower::ServiceExt; use aionui_db::{ - SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, + SiteRole, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, SqliteSettingsRepository, UserStatus, UserType, init_database_memory, }; @@ -134,6 +134,8 @@ fn diagnostics_request(uri: &str) -> Request { username: "system_default_user".to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, }); req } diff --git a/crates/aionui-system/tests/model_fetch_routes.rs b/crates/aionui-system/tests/model_fetch_routes.rs index b99291058..958300e79 100644 --- a/crates/aionui-system/tests/model_fetch_routes.rs +++ b/crates/aionui-system/tests/model_fetch_routes.rs @@ -16,8 +16,9 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aionui_auth::CurrentUser; use aionui_common::encrypt_string; use aionui_db::{ - CreateProviderParams, IProviderRepository, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, - SqliteProviderRepository, SqliteSettingsRepository, UserStatus, UserType, init_database_memory, + CreateProviderParams, IProviderRepository, SiteRole, SqliteClientPreferenceRepository, + SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, SqliteSettingsRepository, UserStatus, UserType, + init_database_memory, }; use aionui_realtime::BroadcastEventBus; use aionui_system::{ @@ -97,6 +98,10 @@ async fn body_json(resp: axum::response::Response) -> serde_json::Value { } fn post_request(uri: &str, body: serde_json::Value) -> Request { + post_request_with_role(uri, body, SiteRole::Admin) +} + +fn post_request_with_role(uri: &str, body: serde_json::Value, site_role: SiteRole) -> Request { let mut req = Request::builder() .method("POST") .uri(uri) @@ -108,6 +113,8 @@ fn post_request(uri: &str, body: serde_json::Value) -> Request { username: TEST_USER_ID.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role, + must_change_password: false, }); req } @@ -124,6 +131,42 @@ async fn fetch_models_nonexistent_provider() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn member_cannot_fetch_models_from_loopback_provider() { + let (router, db) = setup().await; + let id = create_provider(&db, "openai", "http://127.0.0.1:9", "test-key").await; + let req = post_request_with_role( + &format!("/api/providers/{id}/models"), + json!({"try_fix": false}), + SiteRole::Member, + ); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); +} + +#[tokio::test] +async fn member_cannot_fetch_models_anonymously_from_metadata_address() { + let (router, _db) = setup().await; + let req = post_request_with_role( + "/api/providers/fetch-models", + json!({ + "platform": "openai", + "base_url": "http://169.254.169.254/latest/meta-data", + "api_key": "test-key", + "try_fix": false + }), + SiteRole::Member, + ); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); +} + #[tokio::test] async fn fetch_models_vertex_ai_hardcoded() { let (router, db) = setup().await; diff --git a/crates/aionui-system/tests/protocol_detection_routes.rs b/crates/aionui-system/tests/protocol_detection_routes.rs index 3ea71c194..582f3c36e 100644 --- a/crates/aionui-system/tests/protocol_detection_routes.rs +++ b/crates/aionui-system/tests/protocol_detection_routes.rs @@ -14,9 +14,10 @@ use tower::ServiceExt; use wiremock::matchers::{header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use aionui_auth::CurrentUser; use aionui_db::{ - SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, - SqliteSettingsRepository, init_database_memory, + SiteRole, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, + SqliteSettingsRepository, UserStatus, UserType, init_database_memory, }; use aionui_system::{ ClientPrefService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, @@ -53,12 +54,28 @@ async fn setup() -> axum::Router { } async fn detect(router: &axum::Router, body: serde_json::Value) -> (StatusCode, serde_json::Value) { - let req = Request::builder() + detect_with_role(router, body, SiteRole::Admin).await +} + +async fn detect_with_role( + router: &axum::Router, + body: serde_json::Value, + site_role: SiteRole, +) -> (StatusCode, serde_json::Value) { + let mut req = Request::builder() .method("POST") .uri("/api/providers/detect-protocol") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(); + req.extensions_mut().insert(CurrentUser { + id: "protocol-test-user".to_owned(), + username: "protocol-test-user".to_owned(), + user_type: UserType::Local, + status: UserStatus::Active, + site_role, + must_change_password: false, + }); let resp = router.clone().oneshot(req).await.unwrap(); let status = resp.status(); @@ -101,6 +118,21 @@ async fn detect_protocol_empty_api_key() { assert_eq!(status, StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn member_cannot_probe_loopback_or_metadata_protocol_endpoints() { + let router = setup().await; + for base_url in ["http://127.0.0.1:9", "http://169.254.169.254/latest/meta-data"] { + let (status, json) = detect_with_role( + &router, + json!({"base_url": base_url, "api_key": "sk-test"}), + SiteRole::Member, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{base_url} must be blocked"); + assert_eq!(json["code"], "BAD_REQUEST"); + } +} + // --------------------------------------------------------------------------- // OpenAI detection with mock server // --------------------------------------------------------------------------- diff --git a/crates/aionui-system/tests/provider_routes.rs b/crates/aionui-system/tests/provider_routes.rs index 4e233346f..22916d4b6 100644 --- a/crates/aionui-system/tests/provider_routes.rs +++ b/crates/aionui-system/tests/provider_routes.rs @@ -15,8 +15,8 @@ use tower::ServiceExt; use aionui_auth::CurrentUser; use aionui_db::{ - SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, - SqliteSettingsRepository, UserStatus, UserType, init_database_memory, + IProviderRepository, SiteRole, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, + SqliteProviderRepository, SqliteSettingsRepository, UserStatus, UserType, init_database_memory, }; use aionui_system::{ ClientPrefService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, @@ -81,6 +81,8 @@ fn get_request_for_user(user_id: &str, uri: &str) -> Request { username: user_id.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, }); req } @@ -90,6 +92,16 @@ fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request Request { + json_request_for_user_with_role(user_id, method, uri, body, SiteRole::Admin) +} + +fn json_request_for_user_with_role( + user_id: &str, + method: &str, + uri: &str, + body: serde_json::Value, + site_role: SiteRole, +) -> Request { let mut req = Request::builder() .method(method) .uri(uri) @@ -101,6 +113,8 @@ fn json_request_for_user(user_id: &str, method: &str, uri: &str, body: serde_jso username: user_id.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role, + must_change_password: false, }); req } @@ -120,6 +134,8 @@ fn delete_request_for_user(user_id: &str, uri: &str) -> Request { username: user_id.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, }); req } @@ -330,6 +346,35 @@ async fn create_provider_with_optional_fields() { assert_eq!(data["bedrock_config"]["region"], "us-east-1"); } +#[tokio::test] +async fn member_cannot_create_bedrock_provider_with_host_profile() { + let (app, _db) = setup().await; + let body = json!({ + "platform": "bedrock", + "name": "Host AWS profile", + "base_url": "", + "api_key": "", + "bedrock_config": { + "auth_method": "profile", + "region": "us-east-1", + "profile": "default" + } + }); + let resp = app + .oneshot(json_request_for_user_with_role( + TEST_USER_ID, + "POST", + "/api/providers", + body, + SiteRole::Member, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); +} + #[tokio::test] async fn create_provider_persists_model_settings() { let (_app, db) = setup().await; @@ -434,6 +479,70 @@ async fn create_provider_invalid_url() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn member_cannot_create_provider_targeting_private_network() { + let (app, _db) = setup().await; + for base_url in ["http://127.0.0.1:11434", "http://169.254.169.254/latest/meta-data"] { + let body = json!({ + "platform": "openai", + "name": "Blocked provider", + "base_url": base_url, + "api_key": "sk-test" + }); + let resp = app + .clone() + .oneshot(json_request_for_user_with_role( + TEST_USER_ID, + "POST", + "/api/providers", + body, + SiteRole::Member, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{base_url} must be blocked"); + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); + } +} + +#[tokio::test] +async fn member_cannot_create_provider_on_unapproved_public_host() { + let (app, _db) = setup().await; + let body = json!({ + "platform": "custom", + "name": "Unapproved relay", + "base_url": "https://api.example.com/v1", + "api_key": "sk-test" + }); + let resp = app + .oneshot(json_request_for_user_with_role( + TEST_USER_ID, + "POST", + "/api/providers", + body, + SiteRole::Member, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); +} + +#[tokio::test] +async fn admin_can_create_provider_targeting_loopback() { + let (app, _db) = setup().await; + let body = json!({ + "platform": "openai", + "name": "Local model server", + "base_url": "http://127.0.0.1:11434", + "api_key": "local-key" + }); + let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); +} + // =========================================================================== // PUT /api/providers/{id} — update // =========================================================================== @@ -459,6 +568,67 @@ async fn update_provider_name() { assert_eq!(json["data"]["platform"], "anthropic"); } +#[tokio::test] +async fn member_cannot_update_provider_to_private_network() { + let (_app, db) = setup().await; + let (_, id) = create_one(&db).await; + + let app = system_routes(build_state(&db)); + let resp = app + .oneshot(json_request_for_user_with_role( + TEST_USER_ID, + "PUT", + &format!("/api/providers/{id}"), + json!({"base_url": "http://10.0.0.1:8080"}), + SiteRole::Member, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = body_json(resp).await; + assert_eq!(json["code"], "BAD_REQUEST"); + + let stored = SqliteProviderRepository::new(db.pool().clone()) + .find_by_id(TEST_USER_ID, &id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.base_url, "https://api.anthropic.com"); +} + +#[tokio::test] +async fn member_cannot_retain_admin_created_private_provider_on_partial_update() { + let (app, _db) = setup().await; + let create = app + .clone() + .oneshot(json_request( + "POST", + "/api/providers", + json!({ + "platform": "custom", + "name": "Admin local model", + "base_url": "http://127.0.0.1:11434", + "api_key": "local-key" + }), + )) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::CREATED); + let id = body_json(create).await["data"]["id"].as_str().unwrap().to_owned(); + + let update = app + .oneshot(json_request_for_user_with_role( + TEST_USER_ID, + "PUT", + &format!("/api/providers/{id}"), + json!({"name": "Still unsafe"}), + SiteRole::Member, + )) + .await + .unwrap(); + assert_eq!(update.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn update_provider_api_key_returns_plaintext() { let (_app, db) = setup().await; diff --git a/crates/aionui-system/tests/settings_routes.rs b/crates/aionui-system/tests/settings_routes.rs index 49a4b3d0d..3d3bb08d2 100644 --- a/crates/aionui-system/tests/settings_routes.rs +++ b/crates/aionui-system/tests/settings_routes.rs @@ -14,7 +14,7 @@ use tower::ServiceExt; use aionui_auth::CurrentUser; use aionui_db::{ - SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, + SiteRole, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, SqliteSettingsRepository, UserStatus, UserType, init_database_memory, }; use aionui_system::{ @@ -80,6 +80,8 @@ fn get_request_for_user(user_id: &str, uri: &str) -> Request { username: user_id.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, }); req } @@ -100,6 +102,8 @@ fn json_request_for_user(user_id: &str, method: &str, uri: &str, body: serde_jso username: user_id.to_owned(), user_type: UserType::Local, status: UserStatus::Active, + site_role: SiteRole::Admin, + must_change_password: false, }); req } diff --git a/crates/aionui-system/tests/system_info_routes.rs b/crates/aionui-system/tests/system_info_routes.rs index fb3f52526..923392867 100644 --- a/crates/aionui-system/tests/system_info_routes.rs +++ b/crates/aionui-system/tests/system_info_routes.rs @@ -17,9 +17,10 @@ use tower::ServiceExt; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use aionui_auth::CurrentUser; use aionui_db::{ - SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, - SqliteSettingsRepository, init_database_memory, + SiteRole, SqliteClientPreferenceRepository, SqliteFeedbackDiagnosticsRepository, SqliteProviderRepository, + SqliteSettingsRepository, UserStatus, UserType, init_database_memory, }; use aionui_system::{ ClientPrefService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, @@ -71,7 +72,20 @@ async fn body_json(resp: axum::response::Response) -> serde_json::Value { } fn get_request(uri: &str) -> Request { - Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap() + get_request_with_role(uri, SiteRole::Admin) +} + +fn get_request_with_role(uri: &str, site_role: SiteRole) -> Request { + let mut request = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap(); + request.extensions_mut().insert(CurrentUser { + id: "system-info-test-user".to_owned(), + username: "system-info-test-user".to_owned(), + user_type: UserType::Local, + status: UserStatus::Active, + site_role, + must_change_password: false, + }); + request } fn json_request(method_str: &str, uri: &str, body: serde_json::Value) -> Request { @@ -126,6 +140,24 @@ async fn test_system_info_returns_all_fields() { assert!(data["arch"].as_str().is_some_and(|s| !s.is_empty())); } +#[tokio::test] +async fn test_system_info_redacts_host_paths_for_members() { + let app = setup().await; + let resp = app + .oneshot(get_request_with_role("/api/system/info", SiteRole::Member)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + let data = &json["data"]; + assert_eq!(data["cache_dir"], "[redacted]"); + assert_eq!(data["work_dir"], "[redacted]"); + assert_eq!(data["log_dir"], "[redacted]"); + assert!(data["platform"].as_str().is_some_and(|value| !value.is_empty())); + assert!(data["arch"].as_str().is_some_and(|value| !value.is_empty())); +} + #[tokio::test] async fn test_system_info_platform_is_known() { let app = setup().await; diff --git a/crates/aionui-team/src/mailbox.rs b/crates/aionui-team/src/mailbox.rs index f9e93f75a..b9b40f745 100644 --- a/crates/aionui-team/src/mailbox.rs +++ b/crates/aionui-team/src/mailbox.rs @@ -135,7 +135,7 @@ impl Mailbox { if let Some(events) = &self.events && !ids.is_empty() { - let rows = self.repo.list_messages_by_ids(ids).await?; + let rows = self.repo.list_messages_by_ids(&self.user_id, team_id, ids).await?; for row in &rows { let mut resp = mailbox_row_to_response(row); resp.read = true; diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 6e4c29fbf..4a3f638bb 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -5,6 +5,7 @@ use aionui_api_types::{AddAgentRequest, GetConfigOptionsResponse, TeamAgentInput use aionui_common::{AgentKillReason, AgentType, ProviderWithModel, generate_id}; use aionui_db::models::{AgentMetadataRow, TeamRow}; use aionui_db::{IAgentMetadataRepository, IProviderRepository, ITeamRepository, UpdateTeamParams}; +use aionui_project::ProjectService; use async_trait::async_trait; use tracing::{info, warn}; @@ -25,6 +26,7 @@ pub struct TeamAgentProvisioner { assistant_catalog: Arc, provider_repo: Arc, conversation_port: Arc, + project_service: Option>, } pub(crate) struct InitialProvisioningResult { @@ -140,11 +142,21 @@ impl TeamAgentProvisioner { assistant_catalog, provider_repo, conversation_port, + project_service: None, } } + pub(crate) fn with_project_service(mut self, project_service: Option>) -> Self { + self.project_service = project_service; + self + } + fn workspace_resolver(&self) -> TeamWorkspaceResolver { - TeamWorkspaceResolver::new(self.repo.clone(), self.conversation_port.clone()) + TeamWorkspaceResolver::new( + self.repo.clone(), + self.conversation_port.clone(), + self.project_service.clone(), + ) } pub(crate) async fn provision_initial_agents( diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 97076fb75..1d9370927 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -21,7 +21,7 @@ use aionui_db::{ ActivityCursor, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, ITeamRepository, PageDirection, UpdateTeamParams, }; -use aionui_project::{ProjectService, canonical}; +use aionui_project::{FileOp, ProjectError, ProjectService, canonical}; use aionui_realtime::EventBroadcaster; use dashmap::DashMap; use tracing::{debug, info, warn}; @@ -230,6 +230,7 @@ impl TeamSessionService { } pub(crate) fn provisioner(&self) -> TeamAgentProvisioner { + let project_service = self.project_service.read().ok().and_then(|guard| guard.clone()); TeamAgentProvisioner::new( self.repo.clone(), self.agent_metadata_repo.clone(), @@ -237,6 +238,7 @@ impl TeamSessionService { self.provider_repo.clone(), self.conversation_port.clone(), ) + .with_project_service(project_service) } /// Inject the project-bind service (project-bind side branch). When unset, @@ -247,6 +249,29 @@ impl TeamSessionService { } } + fn authorize_workspace_path(&self, user_id: &str, workspace: &str) -> Result { + let normalized = validate_create_workspace_path(workspace)?; + let project = self + .project_service + .read() + .map_err(|_| TeamError::InvalidRequest("Project filesystem policy is unavailable".into()))? + .clone(); + let Some(project) = project else { + // Standalone unit consumers retain local semantics. App + // composition always injects the identity-aware project service. + return Ok(normalized); + }; + project + .authorize_user_path(user_id, Path::new(&normalized), FileOp::Browse, false) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| match error { + ProjectError::UserFilesystemDenied => { + TeamError::Forbidden("Workspace is outside the current user's managed filesystem".into()) + } + _ => TeamError::WorkspacePathUnavailable(normalized), + }) + } + /// Resolve a team workspace into `(project_id, folder_id)`. Best-effort: /// missing service / empty workspace / bad URI / resolve error → `(None, None)`, /// logged at `warn`. Never affects team create/read. @@ -328,7 +353,7 @@ impl TeamSessionService { ) -> Result, TeamError> { self.load_owned_team(user_id, team_id).await?; let clamped = limit.clamp(1, MAX_ACTIVITY_LIMIT); - let rows = self.repo.list_messages_by_team(team_id, clamped).await?; + let rows = self.repo.list_messages_by_team(user_id, team_id, clamped).await?; let responses: Vec = rows.iter().map(mailbox_row_to_response).collect(); info!(kind = "team", team_id, count = responses.len(), "team mailbox listed"); Ok(responses) @@ -406,7 +431,7 @@ impl TeamSessionService { ActivityKind::Message => { let rows = self .repo - .list_messages_by_team_paged(team_id, cursor.clone(), direction, limit) + .list_messages_by_team_paged(user_id, team_id, cursor.clone(), direction, limit) .await?; let full = rows.len() as i64 == limit; ( @@ -430,7 +455,7 @@ impl TeamSessionService { ActivityKind::All => { let msgs = self .repo - .list_messages_by_team_paged(team_id, cursor.clone(), direction, limit) + .list_messages_by_team_paged(user_id, team_id, cursor.clone(), direction, limit) .await?; let tasks = self .repo @@ -563,7 +588,7 @@ impl TeamSessionService { } let shared_workspace = match req.workspace.as_deref() { - Some(workspace) if !workspace.is_empty() => Some(validate_create_workspace_path(workspace)?), + Some(workspace) if !workspace.is_empty() => Some(self.authorize_workspace_path(user_id, workspace)?), _ => None, }; diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 67592d5f8..b8aa07f4c 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -136,7 +136,12 @@ impl ITeamRepository for MockTeamRepo { Ok(msgs) } - async fn list_messages_by_team(&self, team_id: &str, limit: i64) -> Result, DbError> { + async fn list_messages_by_team( + &self, + _user_id: &str, + team_id: &str, + limit: i64, + ) -> Result, DbError> { let state = self.state.lock().unwrap(); let mut msgs: Vec = state .messages @@ -152,6 +157,7 @@ impl ITeamRepository for MockTeamRepo { async fn list_messages_by_team_paged( &self, + _user_id: &str, team_id: &str, cursor: Option, direction: PageDirection, @@ -178,7 +184,12 @@ impl ITeamRepository for MockTeamRepo { Ok(msgs) } - async fn list_messages_by_ids(&self, ids: &[String]) -> Result, DbError> { + async fn list_messages_by_ids( + &self, + _user_id: &str, + _team_id: &str, + ids: &[String], + ) -> Result, DbError> { if ids.is_empty() { return Ok(Vec::new()); } @@ -684,6 +695,7 @@ pub(crate) mod workspace_harness { async fn list_messages_by_team( &self, + _user_id: &str, _team_id: &str, _limit: i64, ) -> Result, DbError> { @@ -692,6 +704,7 @@ pub(crate) mod workspace_harness { async fn list_messages_by_team_paged( &self, + _user_id: &str, _team_id: &str, _cursor: Option, _direction: PageDirection, @@ -702,6 +715,8 @@ pub(crate) mod workspace_harness { async fn list_messages_by_ids( &self, + _user_id: &str, + _team_id: &str, _ids: &[String], ) -> Result, DbError> { Ok(vec![]) diff --git a/crates/aionui-team/src/workspace.rs b/crates/aionui-team/src/workspace.rs index dcdbdbb93..4beb740b3 100644 --- a/crates/aionui-team/src/workspace.rs +++ b/crates/aionui-team/src/workspace.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use aionui_common::{WorkspacePathValidationError, validate_workspace_path_availability}; use aionui_db::models::TeamRow; use aionui_db::{ITeamRepository, UpdateTeamParams}; +use aionui_project::{FileOp, ProjectError, ProjectService}; use tracing::warn; use crate::error::TeamError; @@ -34,25 +35,47 @@ fn usable_runtime_workspace(workspace: &str) -> Option { pub(crate) struct TeamWorkspaceResolver { repo: Arc, conversation_port: Arc, + project_service: Option>, } impl TeamWorkspaceResolver { pub(crate) fn new( repo: Arc, conversation_port: Arc, + project_service: Option>, ) -> Self { Self { repo, conversation_port, + project_service, } } - pub(crate) async fn resolve_for_new_agent(&self, row: &TeamRow, team: &Team) -> Result { - if let Some(workspace) = usable_runtime_workspace(row.workspace.trim()) { + fn authorize_runtime_workspace(&self, user_id: &str, workspace: &str) -> Result { + let workspace = validate_runtime_workspace_path(workspace)?; + let Some(project_service) = &self.project_service else { return Ok(workspace); + }; + project_service + .authorize_user_path(user_id, std::path::Path::new(&workspace), FileOp::Browse, false) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| match error { + ProjectError::UserFilesystemDenied => { + TeamError::Forbidden("Workspace is outside the current user's managed filesystem".into()) + } + _ => TeamError::WorkspacePathRuntimeUnavailable(workspace), + }) + } + + pub(crate) async fn resolve_for_new_agent(&self, row: &TeamRow, team: &Team) -> Result { + match self.authorize_runtime_workspace(&row.user_id, row.workspace.trim()) { + Ok(workspace) => return Ok(workspace), + Err(error @ TeamError::Forbidden(_)) => return Err(error), + Err(_) => {} } if let Some(leader_workspace) = self.resolve_from_leader(team).await? { + let leader_workspace = self.authorize_runtime_workspace(&row.user_id, &leader_workspace)?; self.write_team_workspace(&row.id, &leader_workspace).await?; warn!( team_id = %row.id, @@ -66,7 +89,7 @@ impl TeamWorkspaceResolver { .conversation_port .create_team_temp_workspace(&row.user_id, &row.id) .await?; - let workspace = validate_runtime_workspace_path(&workspace)?; + let workspace = self.authorize_runtime_workspace(&row.user_id, &workspace)?; self.write_team_workspace(&row.id, &workspace).await?; self.patch_leader_workspace_best_effort(&row.id, team, &workspace).await; warn!( diff --git a/crates/aionui-team/tests/common/mod.rs b/crates/aionui-team/tests/common/mod.rs index 1ff18a9ca..f616e4486 100644 --- a/crates/aionui-team/tests/common/mod.rs +++ b/crates/aionui-team/tests/common/mod.rs @@ -112,7 +112,12 @@ impl ITeamRepository for MockTeamRepo { Ok(msgs) } - async fn list_messages_by_team(&self, team_id: &str, limit: i64) -> Result, DbError> { + async fn list_messages_by_team( + &self, + _user_id: &str, + team_id: &str, + limit: i64, + ) -> Result, DbError> { let state = self.state.lock().unwrap(); let mut msgs: Vec = state .messages @@ -127,6 +132,7 @@ impl ITeamRepository for MockTeamRepo { async fn list_messages_by_team_paged( &self, + _user_id: &str, team_id: &str, cursor: Option, direction: PageDirection, @@ -153,7 +159,12 @@ impl ITeamRepository for MockTeamRepo { Ok(msgs) } - async fn list_messages_by_ids(&self, ids: &[String]) -> Result, DbError> { + async fn list_messages_by_ids( + &self, + _user_id: &str, + _team_id: &str, + ids: &[String], + ) -> Result, DbError> { if ids.is_empty() { return Ok(Vec::new()); } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 2f676b922..024793489 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -856,24 +856,31 @@ impl ITeamRepository for FullMockTeamRepo { } async fn list_messages_by_team( &self, + user_id: &str, team_id: &str, limit: i64, ) -> Result, DbError> { - self.inner.list_messages_by_team(team_id, limit).await + self.inner.list_messages_by_team(user_id, team_id, limit).await } async fn list_messages_by_team_paged( &self, + user_id: &str, team_id: &str, cursor: Option, direction: PageDirection, limit: i64, ) -> Result, DbError> { self.inner - .list_messages_by_team_paged(team_id, cursor, direction, limit) + .list_messages_by_team_paged(user_id, team_id, cursor, direction, limit) .await } - async fn list_messages_by_ids(&self, ids: &[String]) -> Result, DbError> { - self.inner.list_messages_by_ids(ids).await + async fn list_messages_by_ids( + &self, + user_id: &str, + team_id: &str, + ids: &[String], + ) -> Result, DbError> { + self.inner.list_messages_by_ids(user_id, team_id, ids).await } async fn delete_mailbox_by_team(&self, user_id: &str, team_id: &str) -> Result<(), DbError> { self.inner.delete_mailbox_by_team(user_id, team_id).await