From ffcce360fc19139a6226ab32f1cbfea01ffa2b76 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 3 May 2026 18:26:03 +0000 Subject: [PATCH] auth: add community token service seam --- TASKS.md | 9 ++ apps/admin-api/src/app.rs | 88 +++++++++++++++++++ apps/admin-api/src/error.rs | 7 ++ apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/machine_auth.rs | 20 +++++ apps/admin-api/src/service.rs | 49 +++++++++-- crates/crank-core/src/auth.rs | 102 +++++++++++++++++++++- crates/crank-core/src/lib.rs | 5 +- docs/admin-api.md | 39 +++++++++ docs/agent-auth-model.md | 19 ++++ 10 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 apps/admin-api/src/routes/machine_auth.rs diff --git a/TASKS.md b/TASKS.md index 4ad44d2..f63f628 100644 --- a/TASKS.md +++ b/TASKS.md @@ -37,6 +37,15 @@ Verification: - 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` +- pending: + - abstraction для token verification в `mcp-server` + - capability-gated UI/API surface для premium machine access + - final separation of Community `standard` flow from commercial `elevated/strict` + ## Planned ### `feat/frontend-commercial-polish` diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 0c29700..7feed1f 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -24,6 +24,7 @@ use crate::{ }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, + machine_auth::{issue_agent_token, issue_one_time_agent_token}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ archive_operation, create_operation, create_version, delete_operation, @@ -209,6 +210,12 @@ pub fn build_app(state: AppState) -> Router { .route("/login", post(login)) .merge(protected_auth_router), ) + .nest( + "/mcp-auth/v1", + Router::new() + .route("/token", post(issue_agent_token)) + .route("/token/one-time", post(issue_one_time_agent_token)), + ) .nest("/api/admin", admin_router) .layer(middleware::from_fn_with_state( state.clone(), @@ -1038,6 +1045,87 @@ mod tests { assert_eq!(response["limits"]["max_agents_per_workspace"], 1); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_short_lived_machine_token_issue_in_community() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_short_lived_token_contract"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap(); + + let response = reqwest::Client::new() + .post(format!("{root_url}/mcp-auth/v1/token")) + .json(&json!({ + "grant_type": "agent_key", + "agent_key": "crk_agent_demo_secret", + "scope": ["tools:call"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + let payload = response.json::().await.unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!( + payload["error"]["message"], + "short-lived machine access is not available in Community" + ); + assert_eq!(payload["error"]["context"]["edition"], "community"); + assert_eq!( + payload["error"]["context"]["machine_access_mode"], + "short_lived_token" + ); + assert_eq!(payload["error"]["context"]["grant_type"], "agent_key"); + assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_one_time_machine_token_issue_in_community() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_one_time_token_contract"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap(); + + let response = reqwest::Client::new() + .post(format!("{root_url}/mcp-auth/v1/token/one-time")) + .json(&json!({ + "agent_key": "crk_agent_demo_secret", + "operation_id": "op_sensitive_01", + "scope": ["tools:call"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + let payload = response.json::().await.unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!( + payload["error"]["message"], + "one-time machine access is not available in Community" + ); + assert_eq!(payload["error"]["context"]["edition"], "community"); + assert_eq!( + payload["error"]["context"]["machine_access_mode"], + "one_time_token" + ); + assert_eq!( + payload["error"]["context"]["operation_id"], + "op_sensitive_01" + ); + assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_community_protocol_capabilities_only_for_supported_protocols() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 0551b24..ad9b492 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -109,6 +109,13 @@ impl ApiError { } } + pub(crate) fn forbidden_with_context(message: impl Into, context: Value) -> Self { + Self::Forbidden { + message: message.into(), + context: Some(context), + } + } + fn status_code(&self) -> StatusCode { match self { Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index ff76237..a86d765 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -3,6 +3,7 @@ pub mod agents; pub mod auth; pub mod auth_profiles; pub mod capabilities; +pub mod machine_auth; pub mod observability; pub mod operations; pub mod secrets; diff --git a/apps/admin-api/src/routes/machine_auth.rs b/apps/admin-api/src/routes/machine_auth.rs new file mode 100644 index 0000000..ede6bea --- /dev/null +++ b/apps/admin-api/src/routes/machine_auth.rs @@ -0,0 +1,20 @@ +use axum::{Json, extract::State}; +use crank_core::{IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest}; + +use crate::{error::ApiError, state::AppState}; + +pub async fn issue_agent_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let response = state.service.issue_agent_token(payload).await?; + Ok(Json(serde_json::json!(response))) +} + +pub async fn issue_one_time_agent_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let response = state.service.issue_one_time_agent_token(payload).await?; + Ok(Json(serde_json::json!(response))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c618b4f..3385194 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -12,12 +12,13 @@ use crank_core::{ AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, EditionCapabilities, EditionLimits, ExecutionMode, ExportMode, GeneratedDraft, GeneratedDraftStatus, InvitationId, InvitationStatus, InvitationToken, InvocationLevel, - InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, JobStatus, - MachineAccessMode, MembershipRole, OperationId, OperationSecurityLevel, OperationStatus, - PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition, - Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, - StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, - WorkspaceId, WorkspaceStatus, + InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, IssueAgentTokenRequest, + IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, JobStatus, MachineAccessMode, + MembershipRole, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition, Protocol, + SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, + Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, + WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; @@ -1584,6 +1585,42 @@ impl AdminService { } } + pub async fn issue_agent_token( + &self, + payload: IssueAgentTokenRequest, + ) -> Result { + let edition = self.get_capabilities().await.edition; + let machine_access_mode = MachineAccessMode::ShortLivedToken; + + Err(ApiError::forbidden_with_context( + "short-lived machine access is not available in Community", + json!({ + "edition": edition, + "machine_access_mode": machine_access_mode, + "grant_type": payload.grant_type, + "upgrade_required": true, + }), + )) + } + + pub async fn issue_one_time_agent_token( + &self, + payload: IssueOneTimeAgentTokenRequest, + ) -> Result { + let edition = self.get_capabilities().await.edition; + let machine_access_mode = MachineAccessMode::OneTimeToken; + + Err(ApiError::forbidden_with_context( + "one-time machine access is not available in Community", + json!({ + "edition": edition, + "machine_access_mode": machine_access_mode, + "operation_id": payload.operation_id, + "upgrade_required": true, + }), + )) + } + #[instrument(skip(self))] pub async fn list_stream_sessions( &self, diff --git a/crates/crank-core/src/auth.rs b/crates/crank-core/src/auth.rs index e9b1f8b..b1cc2bf 100644 --- a/crates/crank-core/src/auth.rs +++ b/crates/crank-core/src/auth.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::{ - ids::{AuthProfileId, SecretId, WorkspaceId}, + edition::{MachineAccessMode, OperationSecurityLevel}, + ids::{AgentId, AuthProfileId, OperationId, SecretId, WorkspaceId}, protocol::AuthKind, }; @@ -63,13 +64,55 @@ pub struct AuthProfile { pub updated_at: OffsetDateTime, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentTokenGrantType { + AgentKey, + RefreshToken, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssueAgentTokenRequest { + pub grant_type: AgentTokenGrantType, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scope: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssueOneTimeAgentTokenRequest { + pub agent_key: String, + pub operation_id: OperationId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scope: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssuedAgentTokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + pub machine_access_mode: MachineAccessMode, + pub security_level: OperationSecurityLevel, + pub agent_id: AgentId, +} + #[cfg(test)] mod tests { use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - use super::{ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile}; + use super::{ + AgentTokenGrantType, ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile, + IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, + }; use crate::{ + edition::{MachineAccessMode, OperationSecurityLevel}, ids::{AuthProfileId, SecretId, WorkspaceId}, protocol::AuthKind, }; @@ -94,4 +137,59 @@ mod tests { assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z")); assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z")); } + + #[test] + fn issue_agent_token_request_serializes_stable_contract() { + let request = IssueAgentTokenRequest { + grant_type: AgentTokenGrantType::AgentKey, + agent_key: Some("crk_agent_secret".to_owned()), + refresh_token: None, + scope: vec!["tools:call".to_owned()], + }; + + let value = serde_json::to_value(&request).unwrap(); + + assert_eq!(value["grant_type"], json!("agent_key")); + assert_eq!(value["agent_key"], json!("crk_agent_secret")); + assert_eq!(value["scope"], json!(["tools:call"])); + assert_eq!(value.get("refresh_token"), None); + } + + #[test] + fn issue_one_time_agent_token_request_serializes_stable_contract() { + let request = IssueOneTimeAgentTokenRequest { + agent_key: "crk_agent_secret".to_owned(), + operation_id: "op_01".into(), + scope: vec!["tools:call".to_owned()], + }; + + let value = serde_json::to_value(&request).unwrap(); + + assert_eq!(value["agent_key"], json!("crk_agent_secret")); + assert_eq!(value["operation_id"], json!("op_01")); + assert_eq!(value["scope"], json!(["tools:call"])); + } + + #[test] + fn issued_agent_token_response_serializes_machine_access_metadata() { + let response = IssuedAgentTokenResponse { + access_token: "tok_01".to_owned(), + token_type: "Bearer".to_owned(), + expires_in: 300, + refresh_token: Some("rfr_01".to_owned()), + machine_access_mode: MachineAccessMode::ShortLivedToken, + security_level: OperationSecurityLevel::Elevated, + agent_id: "agt_01".into(), + }; + + let value = serde_json::to_value(&response).unwrap(); + + assert_eq!(value["access_token"], json!("tok_01")); + assert_eq!(value["token_type"], json!("Bearer")); + assert_eq!(value["expires_in"], json!(300)); + assert_eq!(value["refresh_token"], json!("rfr_01")); + assert_eq!(value["machine_access_mode"], json!("short_lived_token")); + assert_eq!(value["security_level"], json!("elevated")); + assert_eq!(value["agent_id"], json!("agt_01")); + } } diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 272fce2..a53a94f 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -18,8 +18,9 @@ pub use access::{ }; pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; pub use auth::{ - ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, - BearerAuthConfig, + AgentTokenGrantType, ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, + BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, + IssuedAgentTokenResponse, }; pub use edition::{ EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition, diff --git a/docs/admin-api.md b/docs/admin-api.md index c68a30c..acb35d4 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -230,6 +230,45 @@ - открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки; - детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`. +JSON payloads: + +- `POST /mcp-auth/v1/token` + - request: + - `grant_type`: `agent_key | refresh_token` + - `agent_key?` + - `refresh_token?` + - `scope[]` + - response: + - `access_token` + - `token_type` + - `expires_in` + - `refresh_token?` + - `machine_access_mode` + - `security_level` + - `agent_id` + +- `POST /mcp-auth/v1/token/one-time` + - request: + - `agent_key` + - `operation_id` + - `scope[]` + - response: + - `access_token` + - `token_type` + - `expires_in` + - `machine_access_mode` + - `security_level` + - `agent_id` + +Community behavior: + +- открытая редакция публикует эти конечные точки как stable public contract; +- в Community вызовы возвращают `403 forbidden` с structured `error.context`, где явно указаны: + - `edition` + - `machine_access_mode` + - `upgrade_required` +- private реализация short-lived и one-time token service подключается без изменения публичного HTTP surface. + ### 5.8. Observability - `GET /api/admin/workspaces/{workspace_id}/logs` diff --git a/docs/agent-auth-model.md b/docs/agent-auth-model.md index e5a3b1f..9cf4e8f 100644 --- a/docs/agent-auth-model.md +++ b/docs/agent-auth-model.md @@ -283,3 +283,22 @@ Enterprise-реализация должна поставляться через - в Enterprise — при необходимости одноразовые токены; - уровень защиты определяется операцией, а не агентом; - агент никогда не ослабляет обязательную защиту опубликованной операции. + +## 13. Public contract seam + +В public Community-коде должны существовать стабильные HTTP contracts для будущего token service: + +- `POST /mcp-auth/v1/token` +- `POST /mcp-auth/v1/token/one-time` + +На первом этапе Community может не содержать private issuer implementation, но должна: + +- сохранить DTO и shape ответов; +- возвращать предсказуемый `403 forbidden` для short-lived и one-time token flows; +- явно сообщать через structured context, что для выбранного `machine_access_mode` требуется расширенная редакция. + +Это позволяет: + +- не менять public API при подключении private реализации; +- держать open-core границу на уровне реализации, а не на уровне transport contract; +- не смешивать Community static agent-key flow с commercial `elevated/strict` реализацией.