diff --git a/TASKS.md b/TASKS.md index 6cda721..9d60701 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,55 +2,9 @@ ## Current -### `feat/private-token-service-seam` - -Status: in_progress - -Goal: -- подготовить public codebase к private реализации короткоживущих и одноразовых токенов. - -Main code areas: -- `crates/crank-core/src/auth.rs` -- `crates/crank-core/src/agent.rs` -- `apps/admin-api/src/app.rs` -- `apps/admin-api/src/service.rs` -- `apps/mcp-server/src/app.rs` -- `docs/agent-auth-model.md` -- `docs/admin-api.md` -- `docs/mcp-interface.md` - -Implementation slices: -1. Определить stable contracts для: - - `POST /mcp-auth/v1/token` - - `POST /mcp-auth/v1/token/one-time` -2. Ввести abstraction для token verification в `mcp-server`. -3. Подготовить capability-gated UI/API surface без private implementation в Community. -4. Отделить Community `standard` flow от commercial `elevated/strict`. - -DoD: -- public contracts зафиксированы и тестируемы; -- Community продолжает работать без private token service; -- premium auth paths не размазываются по Community logic. - -Verification: -- contract tests for auth payload shapes; -- unit tests for credential-kind enforcement; -- docs sync check. - -Progress: -- done: - - stable DTO и Community stub endpoints для `POST /mcp-auth/v1/token` и `POST /mcp-auth/v1/token/one-time` - - structured `403 forbidden` contract с `edition`, `machine_access_mode` и `upgrade_required` - - verification seam в `mcp-server` для bearer-token credentials без private Community implementation - - capability-gated `API Keys` surface показывает Community static-key flow и честно помечает commercial token modes как edition-locked -- pending: - - final separation of Community `standard` flow from commercial `elevated/strict` - -## Planned - ### `feat/frontend-commercial-polish` -Status: ready +Status: in_progress Goal: - закрыть оставшиеся UI/UX дефекты, мешающие коммерческой демонстрации и продажной версии продукта. @@ -92,6 +46,8 @@ Verification: - manual mobile checks at 375px width; - EN/RU copy audit. +## Planned + ### `feat/open-core-repo-boundary` Status: ready diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 6d4f142..498b0a7 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -21,8 +21,8 @@ use axum::{ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ AsyncJobHandle, AsyncJobId, AuthProfile, InvocationLevel, InvocationLog, InvocationLogId, - InvocationSource, InvocationStatus, JobStatus, PlatformApiKeyScope, SecretId, StreamSession, - StreamSessionId, StreamStatus, + InvocationSource, InvocationStatus, JobStatus, OperationSecurityLevel, PlatformApiKeyScope, + SecretId, StreamSession, StreamSessionId, StreamStatus, }; use crank_registry::{ CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest, @@ -325,9 +325,12 @@ async fn mcp_post( Some("tools/call") => PlatformApiKeyScope::Write, _ => PlatformApiKeyScope::Read, }; - if let Err(status) = require_machine_access(&state, &path, &headers, required_scope).await { - return with_request_id_header(status.into_response(), &transport_request_id); - } + let credential = match require_machine_access(&state, &path, &headers, required_scope).await { + Ok(credential) => credential, + Err(status) => { + return with_request_id_header(status.into_response(), &transport_request_id); + } + }; let response = match method_name(&message) { Some("initialize") if is_request(&message) => { @@ -417,6 +420,7 @@ async fn mcp_post( &session, &message, response_mode, + &credential, resolved, arguments, &transport_request_id, @@ -470,10 +474,31 @@ async fn handle_tool_call( session: &SessionState, message: &Value, response_mode: ResponseMode, + credential: &VerifiedMachineCredential, resolved: ResolvedToolCall, arguments: Value, transport_request_id: &str, ) -> Response { + if !credential_allows_security_level(credential, resolved.tool.operation.security_level) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "machine_access_insufficient", + format!( + "machine access mode {} does not satisfy {} operation security", + serialize_machine_access_mode(credential.machine_access_mode), + serialize_security_level(resolved.tool.operation.security_level), + ), + Some(json!({ + "machine_access_mode": credential.machine_access_mode, + "credential_security_level": credential.max_security_level, + "required_security_level": resolved.tool.operation.security_level, + "upgrade_required": true, + })), + ); + } + match resolved.kind { GeneratedToolKind::Base => { handle_base_tool_call( @@ -1979,6 +2004,37 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc } } +fn credential_allows_security_level( + credential: &VerifiedMachineCredential, + required_level: OperationSecurityLevel, +) -> bool { + security_level_rank(credential.max_security_level) >= security_level_rank(required_level) +} + +fn security_level_rank(level: OperationSecurityLevel) -> u8 { + match level { + OperationSecurityLevel::Standard => 0, + OperationSecurityLevel::Elevated => 1, + OperationSecurityLevel::Strict => 2, + } +} + +fn serialize_security_level(level: OperationSecurityLevel) -> &'static str { + match level { + OperationSecurityLevel::Standard => "standard", + OperationSecurityLevel::Elevated => "elevated", + OperationSecurityLevel::Strict => "strict", + } +} + +fn serialize_machine_access_mode(mode: crank_core::MachineAccessMode) -> &'static str { + match mode { + crank_core::MachineAccessMode::StaticAgentKey => "static_agent_key", + crank_core::MachineAccessMode::ShortLivedToken => "short_lived_token", + crank_core::MachineAccessMode::OneTimeToken => "one_time_token", + } +} + fn validate_origin( allowed_origins: &AllowedOrigins, headers: &HeaderMap, diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 5e58c50..2a3f79f 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -1588,6 +1588,147 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } + #[tokio::test] + async fn rejects_elevated_operation_with_static_agent_key() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_elevated_static"); + operation.security_level = crank_core::OperationSecurityLevel::Elevated; + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-elevated-static").await; + let api_key = create_platform_api_key( + ®istry, + "sales-elevated-static", + "mcp-elevated-static", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-elevated-static"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + let call_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_elevated_static", + "arguments": { + "email": "alice@example.com" + } + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], json!(true)); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["code"], + "machine_access_insufficient" + ); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["context"]["machine_access_mode"], + "static_agent_key" + ); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["context"]["required_security_level"], + "elevated" + ); + } + + #[tokio::test] + async fn allows_elevated_operation_with_verified_short_lived_token() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_elevated_token"); + operation.security_level = crank_core::OperationSecurityLevel::Elevated; + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-elevated-token").await; + + let verifier = Arc::new(StubMachineCredentialVerifier { + token: "issued_token_elevated".to_owned(), + credential: VerifiedMachineCredential { + machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, + max_security_level: crank_core::OperationSecurityLevel::Elevated, + scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + }, + }); + + let base_url = spawn_mcp_server(build_test_app_with_store( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + RequestRateLimitConfig::new(10_000, 10_000).unwrap(), + std::sync::Arc::new(InMemorySessionStore::default()), + verifier, + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-elevated-token"); + let initialized_session = + initialize_session(&client, &mcp_url, "issued_token_elevated").await; + let call_result = post_jsonrpc( + &client, + &mcp_url, + "issued_token_elevated", + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_elevated_token", + "arguments": { + "email": "alice@example.com" + } + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], json!(false)); + assert_eq!(call_result["result"]["structuredContent"]["id"], "lead_123"); + } + #[tokio::test] async fn exposes_and_runs_session_tool_family_via_mcp() { let registry = test_registry().await;